Saikat
← Back to blog

CI/CD for NestJS with GitHub Actions: Lint, Test, Build, Deploy

July 19, 2026·10 min read
CI/CDGitHub ActionsDockerDevOps
Share:
CI/CD for NestJS with GitHub Actions: Lint, Test, Build, Deploy

For a long time my deploy process was ssh into the server, git pull, pnpm build, restart PM2, and hope. It worked until it didn't — the night I pulled a branch with a failing migration onto production at 11 p.m. is the night I decided the robot should do this, not me.

The fix wasn't some enterprise platform. It was one GitHub Actions workflow file that has barely changed in a year: every push gets linted, type-checked, and tested; merges to main additionally build a Docker image, push it to GitHub Container Registry, and roll it out to a VPS with docker compose. This post walks through that pipeline end to end — the trigger strategy, the caching that keeps it fast, and the small details (concurrency groups, required checks) that make it boring in the best way.

Trigger strategy: PRs verify, main deploys

The first decision is when the pipeline runs and how much of it runs. My rule:

Event lint typecheck test build image deploy
pull_request → main
push → main (merge)

Pull requests get the full verification suite but never touch a registry or a server. Merges to main re-run verification and then ship. Yes, that means checks run twice for every merged PR — I accept that redundancy because "the commit that deployed was itself verified" is a property I want, not "some ancestor of it was verified on a branch that later got rebased."

In workflow terms:

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

The deploy jobs then gate themselves with if: github.ref == 'refs/heads/main' && github.event_name == 'push', so the same file serves both purposes.

One more line that saves real money and real confusion — a concurrency group:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

If I push three commits to a PR in two minutes, the first two runs are cancelled immediately instead of burning runner minutes on code that's already stale. On main this also guarantees deploys serialize per-branch — two merges in quick succession won't race each other onto the server.

The verification stages

I split lint, typecheck, and test into separate jobs rather than one mega-job. They run in parallel, they fail independently (a lint failure doesn't hide a test failure), and each shows up as its own required check on the PR. The cost is repeating setup in each job, which is why the caching section below matters.

For a NestJS project the three stages are exactly what you'd run locally:

pnpm lint          # eslint .
pnpm typecheck     # tsc --noEmit
pnpm test          # jest, unit tests

Two opinions baked into that list. First, tsc --noEmit as its own stage: NestJS builds with SWC or Babel-style transpilation in a lot of setups, which means pnpm build succeeding does not prove the types are sound. A dedicated typecheck catches what the build won't. Second, unit tests only in the default path — e2e tests that need Postgres run in a separate job with a service container, and I only make them required once they've proven they're not flaky:

  e2e:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: app_test
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-timeout 5s --health-retries 10

A flaky required check trains the team to click "re-run" without reading the logs, and at that point the check is worse than useless.

Caching: the difference between 90 seconds and 6 minutes

Every job starts on a blank runner, so without caching each one pays full price for pnpm install. With pnpm the right thing to cache is the content-addressable store, not node_modules — the store survives lockfile changes (unchanged packages stay cached), while a cached node_modules is invalidated wholesale by any change.

The good news is actions/setup-node does it in one line now:

      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile

That caches the pnpm store keyed on pnpm-lock.yaml, and --frozen-lockfile makes CI fail loudly if someone committed a package.json change without updating the lockfile — which is exactly when you want it to fail. In my project this took install time from ~75 seconds cold to ~12 seconds warm, and since three verification jobs run in parallel, that saving lands three times per run.

The second cache that matters is Docker layer caching for the build job, which I'll get to below — it's a separate mechanism (BuildKit's cache exporters, not actions/cache) and it's the one that takes image builds from six minutes to under two.

Building and pushing to GHCR

Once checks pass on main, the build job assembles the production image. I use the multi-stage Dockerfile from my monorepo deployment setup — deps stage, build stage, slim runtime stage — and the size-trimming tricks from my Docker image optimization post apply here unchanged. The workflow's job is just to build it fast and put it somewhere the server can pull from.

GHCR is the lowest-friction registry when you're already on GitHub: the built-in GITHUB_TOKEN can push to it, so there's no registry credential to create, rotate, or leak.

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/setup-buildx-action@v3

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/api:latest
            ghcr.io/${{ github.repository }}/api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Three details worth calling out:

  • cache-from/cache-to: type=gha exports BuildKit layers to the GitHub Actions cache backend. On the next run, unchanged layers — the deps install layer especially — are pulled from cache instead of rebuilt. mode=max caches intermediate stages too, which is what makes multi-stage builds fast; without it only the final stage's layers are cached and you rebuild the expensive build stage every time.
  • Tag with the SHA, not just latest. latest is convenient for humans; the SHA tag is what gives you instant rollback (docker compose pointing at a known-good SHA) and an audit trail from container to commit.
  • permissions: packages: write must be granted to the job — the default GITHUB_TOKEN permissions won't push packages otherwise.

Deploying over SSH

The deploy step is deliberately dumb. The server runs docker compose with images pulled from GHCR; deploying means: SSH in, pull the new image, recreate the container.

      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /srv/app
            docker compose pull api
            docker compose up -d api
            docker image prune -f

The secrets are a dedicated deploy keypair (generated for this purpose, not my personal key), the host, and the user — all stored as repository secrets, or environment secrets if you use GitHub Environments and want the production environment to require manual approval. The private key never appears in logs; GitHub masks registered secrets automatically, but don't tempt fate by echo-ing things.

Is SSH-to-a-VPS less glamorous than Kubernetes? Sure. But for a single-node deployment it's the whole story in four lines, and docker compose up -d only recreates containers whose image actually changed. When the day comes that one box isn't enough, the pipeline doesn't change much — the deploy step becomes kubectl set image or an ArgoCD sync, and everything upstream of it stays identical. That's the nice property of splitting build from deploy: the microservices architecture can evolve underneath a stable pipeline.

The full workflow

Here's the complete file, assembled:

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: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: "pnpm" }
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: "pnpm" }
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: "pnpm" }
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

  build-push:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/api:latest
            ghcr.io/${{ github.repository }}/api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-push
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /srv/app
            docker compose pull api
            docker compose up -d api
            docker image prune -f

If you support multiple Node versions — say you publish a library alongside the app — the verification jobs take a matrix instead of a fixed version:

    strategy:
      matrix:
        node: [20, 22]

For an application that deploys to one known runtime, though, a matrix is testing configurations you'll never run. I keep a single version pinned to what's in the Dockerfile.

Protecting main

The pipeline is only as good as the rules that make it unavoidable. In the repository settings, a branch ruleset on main with:

  • Require a pull request before merging — no direct pushes, including from me, especially from me at 11 p.m.
  • Require status checks to pass — add lint, typecheck, and test as required checks. Note that required checks are matched by job name, so renaming a job silently un-requires it; keep the names stable.
  • Require branches to be up to date before merging — this closes the gap where two green PRs, each fine alone, break main when combined. It costs an extra CI run per merge on busy repos; on a small team it's cheap insurance.

With that in place, the invariant holds: nothing reaches main unverified, and everything that reaches main deploys itself. The whole feedback loop — push, checks, merge, image on GHCR, container rotated on the VPS — takes about four minutes, and I haven't done a manual deploy since.

Wrapping up

None of this is clever, and that's the point. A CI/CD pipeline earns its keep by being predictable: PRs verify, main ships, caching keeps the loop tight enough that nobody is tempted to bypass it. The pieces that punch above their weight are the ones that cost one line each — the concurrency group cancelling stale runs, --frozen-lockfile catching drifted lockfiles, SHA-tagged images making rollback a one-word change, required checks making the whole thing mandatory rather than aspirational.

Start with the workflow above, swap in your own commands, and resist the urge to add stages until something actually hurts. The best pipeline is the one nobody thinks about because it has never surprised them.