Most CI/CD pipelines I encounter in the wild are terrible. They take 20 minutes to run, fail on flaky tests, cache nothing, and cost a fortune in compute minutes. It does not have to be this way.
GitHub Actions is a powerful platform, but power without discipline leads to slow, expensive pipelines. Let me walk you through building a CI/CD setup that is fast, reliable, and cheap.
Why Most Pipelines Are Bad
The usual problems:
- No caching. Every run installs dependencies from scratch.
- Sequential steps that could be parallel. Linting waits for tests that wait for type checking.
- Running everything on every push. Full E2E suites on a README change.
- No path filtering. Backend tests run when you change frontend code.
- Oversized runners. Using
ubuntu-latestfor everything when a smaller runner would do.
Let us fix all of these.
GitHub Actions Fundamentals
If you are new to Actions, here is the mental model. A workflow is a YAML file in .github/workflows/. It contains jobs, which run on runners (virtual machines). Jobs contain steps, which are either shell commands or reusable actions.
Jobs run in parallel by default. Steps within a job run sequentially. This distinction matters for pipeline design.
Building a Real Pipeline
Here is a production-ready pipeline for a Next.js application:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
build:
needs: [lint-and-typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [build]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}Key design decisions here:
concurrencywithcancel-in-progress— If you push again before the pipeline finishes, the old run is cancelled. No wasted minutes.- Parallel jobs — Lint/typecheck and tests run simultaneously. Build only starts after both pass.
--frozen-lockfile— Ensures CI uses the exact dependencies from the lockfile. No surprise version bumps.- Conditional deploy — Only deploys on pushes to main, not on pull requests.
Caching Strategies
The built-in cache option on setup-node handles node_modules. But for larger projects, you need more aggressive caching.
Cache the Next.js build cache:
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-Cache Playwright browsers for E2E tests:
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright (if not cached)
run: pnpm exec playwright install --with-deps chromiumCaching alone can cut pipeline time by 40-60%.
Matrix Builds
Need to test across multiple Node versions or operating systems? Matrix builds run them in parallel:
test:
strategy:
matrix:
node-version: [20, 22]
os: [ubuntu-latest, windows-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: pnpm install --frozen-lockfile
- run: pnpm testSet fail-fast: false so all matrix combinations run even if one fails. You want to see the full picture.
Secrets Management
Never hardcode secrets. Use GitHub's encrypted secrets, scoped appropriately:
- Repository secrets for project-specific values (API keys, deploy tokens)
- Environment secrets for deployment-specific values (production vs staging URLs)
- Organization secrets for shared values across repos
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}For secrets that multiple steps need, set them at the job level. For secrets only one step needs, set them at the step level. Principle of least privilege applies here too.
Use OIDC for cloud providers instead of long-lived credentials:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-arn: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1No AWS access keys stored as secrets. The token is generated per-run and expires immediately.
Reusable Workflows
Stop copying YAML between repos. Create reusable workflows:
# .github/workflows/reusable-deploy.yml
name: Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
DEPLOY_TOKEN:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}Call it from other workflows:
deploy-staging:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
secrets:
DEPLOY_TOKEN: ${{ secrets.STAGING_TOKEN }}Cost Optimization
GitHub Actions bills by the minute. Here is how to keep costs down:
- Use path filters. Do not run backend tests when only docs changed:
on:
push:
paths:
- 'src/**'
- 'package.json'
- 'pnpm-lock.yaml'-
Cancel redundant runs. The
concurrencygroup shown earlier handles this. -
Use smaller runners where possible. Not every job needs a full
ubuntu-latest. GitHub now offers smaller runner options. -
Timeout your jobs. Prevent runaway processes from burning minutes:
jobs:
test:
timeout-minutes: 10- Skip CI when appropriate. Add
[skip ci]to commit messages for documentation-only changes.
The Pipeline Checklist
Before calling your pipeline done, verify:
- Runs in under 5 minutes for the common case (push to feature branch)
- Caches dependencies and build artifacts
- Runs lint, typecheck, and tests in parallel
- Only deploys from the main branch
- Cancels redundant runs
- Has path filters to skip irrelevant jobs
- Uses OIDC instead of long-lived cloud credentials
- Has timeouts on every job
- Uses
--frozen-lockfilefor reproducible installs
A well-built pipeline is invisible. It catches problems early, deploys reliably, and never makes you wait. Invest the time to get it right — every developer on your team benefits from every minute you shave off the build.