Skip to main content
DevOps7 min readMarch 3, 2026

GitHub Actions CI/CD: A Complete Setup Guide for Modern Projects

Set up GitHub Actions CI/CD pipelines from scratch — automated testing, builds, and deployments that actually work in production environments.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

GitHub Actions CI/CD: A Complete Setup Guide for Modern Projects

Continuous integration is one of those practices that sounds obvious until you work on a team that does not do it. I have been brought into enough legacy projects — codebases where deployments are manual SSH sessions, where nobody knows what is actually running in production, where "we'll test it on staging" is the quality gate — to know exactly what it costs. Bugs shipped faster, rollbacks handled manually, developers afraid to merge.

GitHub Actions changed the calculus for small and mid-sized teams. You do not need a dedicated DevOps engineer to run a solid CI/CD pipeline. You need a YAML file and some discipline. Here is how I set it up on every project.

The Two Pipelines You Need

Most projects need exactly two workflows: one that runs on every pull request, and one that deploys when you merge to main. The PR workflow is your quality gate. The deploy workflow is your delivery mechanism. Keep them separate.

The CI Workflow (Pull Request)

This runs on every PR and on every push to main. It must be fast — under five minutes ideally — or developers start skipping it mentally.

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Run type check
        run: npm run typecheck

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm run test
        env:
          DATABASE_URL: postgres://postgres:testpassword@localhost:5432/testdb
          NODE_ENV: test

A few things to notice. The actions/setup-node@v4 with cache: npm automatically caches your node_modules between runs. This alone can cut your CI time by two minutes. The services block spins up a real PostgreSQL instance for integration tests. Testing against a real database catches a class of bugs that mocking never will.

Type checking and linting run as separate steps, not bundled with tests. This gives you precise failure messages. When CI fails, you want to know immediately whether it was a type error, a lint violation, or a broken test.

The Deploy Workflow

This runs only when a push lands on main — typically via a merged PR.

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    needs: [] # Reference your test job if in same file
    environment: production

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci
      - run: npm run build

      - name: Deploy to production
        run: |
          # Your deployment command here
          # e.g., fly deploy, railway up, docker push + ssh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

The environment: production key is important. GitHub Environments let you require manual approval before deploying, restrict which branches can deploy, and store environment-specific secrets separately from repository secrets. Even on solo projects, I use it — the separation of secrets alone is worth it.

Managing Secrets Correctly

Secrets in GitHub Actions live at three levels: repository secrets, environment secrets, and organization secrets. Use environment secrets for anything production-specific. Use repository secrets for things like NPM tokens that apply to all environments.

Set secrets in your repository under Settings > Secrets and variables > Actions. Reference them in workflows as ${{ secrets.SECRET_NAME }}. GitHub automatically masks secret values in logs.

What you should never do: hardcode values in workflow files, commit .env files, or use the same secret across environments. Your production database password and your staging database password should be different secrets.

Caching Dependencies Efficiently

Beyond the built-in npm cache in setup-node, you can cache other expensive operations. If you run database migrations or generate Prisma client during CI, cache those artifacts:

- name: Cache Prisma generated client
  uses: actions/cache@v4
  with:
    path: node_modules/.prisma
    key: ${{ runner.os }}-prisma-${{ hashFiles('prisma/schema.prisma') }}

The hashFiles function generates a cache key based on file content. When schema.prisma changes, the cache invalidates automatically. When it has not changed, you skip the generation step entirely.

Matrix Builds for Multi-Version Testing

If you need to verify compatibility across Node versions or operating systems:

strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest, windows-latest]

runs-on: ${{ matrix.os }}
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node-version }}

Matrix builds run in parallel, so six combinations take roughly the same time as one. Use this when you ship a library or CLI that needs to support multiple environments. For application code targeting a single deployment environment, matrices add noise.

Reusable Workflows

When you manage multiple repositories, you will find yourself duplicating CI config. GitHub supports reusable workflows via the workflow_call trigger.

Define a reusable workflow in a dedicated .github/workflows/ file:

# .github/workflows/node-ci.yml
on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: "20"

Then call it from any repository:

jobs:
  ci:
    uses: your-org/.github/.github/workflows/node-ci.yml@main
    with:
      node-version: "20"
    secrets: inherit

This lets you maintain one canonical CI definition and pull it into every project. When you improve the workflow, every project benefits immediately.

Handling Deployment Rollbacks

Automated deployment is only half the problem. The other half is knowing what to do when deployment breaks production. Your workflow should capture the deployment artifact version:

- name: Tag deployment
  run: |
    git tag "deploy-$(date +%Y%m%d%H%M%S)" ${{ github.sha }}
    git push origin --tags

When something goes wrong, you know exactly which commit is live and can revert to the previous tag. Some platforms — Fly.io, Railway, Vercel — maintain deployment history natively. In that case, rollback is a CLI command. For self-hosted deployments, the git tag approach gives you the same audit trail.

The Workflow Hygiene Rules I Enforce

Pin action versions to a commit SHA, not a mutable tag. actions/checkout@v4 can change without you knowing. actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 cannot. GitHub Dependabot can keep these updated automatically — enable it in .github/dependabot.yml.

Keep workflows focused. A workflow file that handles CI, deployment, release notes, and dependency updates is a maintenance burden. One workflow, one purpose.

Add a concurrency group to your deploy workflow to cancel in-progress deployments when a new push arrives:

concurrency:
  group: production
  cancel-in-progress: true

This prevents race conditions where an older, slower deployment overwrites a newer one.

Starting Point for New Projects

Every new project I start gets a .github/workflows/ directory with a ci.yml on day one. Not after the first bug. Not when the team grows. Day one. The cost of adding CI after the fact — retrofitting tests, untangling implicit environment dependencies — is always higher than building it in from the start.

GitHub Actions has made solid CI/CD accessible to every team regardless of size. There is no excuse for manual deployments in 2026.


Want help designing a CI/CD pipeline that fits your team's workflow? Let's talk. Book a session at https://calendly.com/jamesrossjr.


Keep Reading