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.envfile — 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
actas an early syntax-error filter, not a full test environment.
4. Production-Standard Pipeline Checklist
- Cache via the official action — use
cache: 'npm'inactions/setup-node, oractions/cachewith 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 havewrite. - 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 conditionif: github.ref == 'refs/heads/main'so deploy only runs after tests pass onmain.
Sources: