GitHub Actions has become one of the most popular CI/CD platforms thanks to its tight integration with GitHub, generous free tier, and a massive marketplace of reusable actions. But a poorly configured pipeline can be slow, flaky, and hard to maintain. Here is how to build one that is fast and reliable.
Workflow Structure
A well-organized workflow separates concerns into distinct jobs. Here is a typical Node.js CI pipeline:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
Notice the concurrency block -- it cancels in-progress runs when a new commit is pushed to the same branch, saving CI minutes.
Caching Dependencies
Caching is the single biggest performance win. The setup-node action supports caching for npm, yarn, and pnpm out of the box:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm' # automatically caches ~/.pnpm-store
For more granular control, use actions/cache:
- uses: actions/cache@v4
with:
path: |
~/.pnpm-store
node_modules/.cache
key: deps-${{ runner.os }}-${{ hashFiles('/pnpm-lock.yaml') }}
restore-keys: |
deps-${{ runner.os }}-
Secrets Management
Never hardcode secrets. Use GitHub encrypted secrets and pass them as environment variables:
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
steps:
- run: pnpm test
env:
# Only expose secrets to steps that need them
TEST_DB_URL: ${{ secrets.TEST_DATABASE_URL }}
Best practices:
- Use Environment-scoped secrets** for production credentials (requires approval gates).
- Rotate secrets regularly.
- Use
GITHUB_TOKENfor GitHub API operations -- it is automatically provided and scoped to the repository.
Matrix Builds
Test across multiple versions or platforms in parallel:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
database: [postgres, mysql]
fail-fast: false # don't cancel other jobs if one fails
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test
env:
DB_TYPE: ${{ matrix.database }}
Deployment Strategies
For deployments, use GitHub Environments to add approval gates and protection rules:
deploy-production:
needs: [build]
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-args: '--prod'
Key Takeaways
- Use
concurrencyto cancel redundant runs and save CI minutes. - Always cache dependencies -- it can cut install times from minutes to seconds.
- Scope secrets tightly: prefer environment-scoped secrets for production credentials.
- Use
matrixfor cross-platform/cross-version testing, but keep the matrix reasonable to avoid combinatorial explosion. - Leverage GitHub Environments for deployment approval gates.
Crafted by
Abiyyu Abidiffatir Al MajidSoftware Engineer passionate about building scalable web applications and sharing knowledge about modern web development, system design, and emerging technologies.



