Hit git push. The Actions tab spins for twelve minutes. End of the month, an email warns you are out of free Runner minutes.

That is the familiar scenario when developers copy a sample YAML file from the internet straight into a real project. Most pipelines today make two serious mistakes: wasting build time because caching is misconfigured, and burying a large security risk by pasting a long-lived AWS Access Key into GitHub Secrets.

This post rewrites a GitHub Actions pipeline to the production standard: noticeably faster, lower security risk with OIDC, and a workflow to test the pipeline locally before pushing.

1. Two Bottlenecks That Make Your Pipeline Slow AND Risky

❌ Bottleneck 1: Reinstalling dependencies on every commit

Every job spins up a brand-new Ubuntu virtual machine. Without proper caching, npm ci or pip install has to redownload hundreds of MB of libraries over the network — even though the previous build’s result is still perfectly valid — simply because the runner doesn’t know how to reuse it.

❌ Bottleneck 2: Using long-lived AWS Access Keys

Storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets is the old way. If the GitHub account or the repo is compromised, that key pair can leak, and attackers will use it to mine crypto on your AWS account. Long-lived keys are also hard to control because they have no clear expiry — they live until you remember to revoke them.

2. The Production Fix: OIDC + Caching Done Right

Instead of long-lived keys, the modern security standard is OpenID Connect (OIDC). GitHub Actions negotiates with AWS (or GCP, Azure) to obtain a short-lived token — living only a few minutes — to perform the deploy, then the token expires. No secret sits dormant in Secrets waiting to leak.

Below is a production-grade workflow YAML that integrates OIDC, least privilege, and automatic caching via the cache key managed by GitHub Actions itself:

name: Production CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

# REQUIRED: only grant read access to code and request an ID Token for OIDC.
# Do not leave the default — the default GITHUB_TOKEN already has write contents.
permissions:
  id-token: write   # Required to authenticate OIDC with AWS/GCP
  contents: read    # Read source code only, no write access

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      # Step 1: Checkout source code
      - name: Checkout Code
        uses: actions/checkout@v4

      # Step 2: Setup Node.js with automatic caching based on package-lock.json
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'  # Cache the npm package cache based on package-lock.json

      # Step 3: Install dependencies (very fast when cache hits).
      # npm ci reads the cache that setup-node just restored.
      - name: Install Dependencies
        run: npm ci

      # Step 4: Run tests
      - name: Run Tests
        run: npm test

  deploy-aws:
    needs: build-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      # Step 5: Authenticate to AWS via OIDC — no long-lived Access Key required.
      # The Role ARN must exist in IAM with a trust policy allowing
      # token.actions.githubusercontent.com from this repo.
      - name: Configure AWS Credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
          aws-region: ap-southeast-1

      # Step 6: Run the deploy. The OIDC token lives only a few minutes,
      # and expires automatically as soon as the job ends.
      - name: Deploy to Production
        run: |
          echo "OIDC authentication successful! Deploying application..."
          # aws s3 sync ./build s3://your-bucket --delete
          # aws ecs update-service --cluster prod --service web --force-new-deployment

An important technical note: cache: 'npm' does not directly cache node_modules. It caches the npm package cache (~/.npm) and only reuses it when package-lock.json is unchanged. npm ci then reads from this cache and skips the download step. If you change a single line in package-lock.json, the cache key changes and a new cache is created — and that is the correct behaviour, because the dependency contents have changed.

3. Debug Workflows Locally Without Pushing

Editing a YAML file and then git commit + git push just to see whether it runs is a terrible developer experience.

Use the open-source tool act to simulate and run GitHub Actions locally through Docker:

# macOS / Linux
brew install act

# Windows (Chocolatey)
choco install act-cli

# Run the 'build-and-test' job locally in your terminal
act -j build-and-test

act reads files in .github/workflows/ and pulls an Ubuntu container onto your machine to run them. You find out exactly which line of your YAML is broken — without polluting the commit history.

Two practical caveats when using act:

  • Secrets and OIDC are not auto-available. You have to pass them manually via act --secret AWS_ROLE_TO_ASSUME=... or a .env file — pushing to GitHub is not enough for the OIDC part to work.
  • The container runner is slightly different from GitHub-hosted. Some edge cases (e.g. service containers, complex matrices) may behave differently on real GitHub Actions. Treat act as an early syntax-error filter, not a full test environment.

4. Production-Standard Pipeline Checklist

  • Cache via the official action — use cache: 'npm' in actions/setup-node, or actions/cache with a key derived from the hash of the matching lock file (package-lock.json, requirements.txt, go.sum).
  • Narrow the default token permissions — declare a permissions: block at the top of the YAML following least privilege, do not let the GITHUB_TOKEN silently have write.
  • Switch to OIDC — remove long-lived access keys from Secrets; set up an IAM role with a trust policy allowing token.actions.githubusercontent.com.
  • Test locally with act — make sure the YAML has no indentation errors or bad step references before pushing.
  • Separate build and deploy jobs — use needs: plus the condition if: github.ref == 'refs/heads/main' so deploy only runs after tests pass on main.

Sources: