Skip to content
NestJS ns deployment 4 min read

CI/CD Pipelines

A continuous integration and continuous deployment (CI/CD) pipeline turns every push into a repeatable, auditable path to production. For a NestJS service that path usually means: lint the code, run the test suite, build a Docker image, run database migrations, and promote the image through staging to production with a clear rollback escape hatch. Automating these steps removes human error, gives you fast feedback on broken builds, and makes deployments boring — which is exactly what you want.

Pipeline stages at a glance

A solid NestJS pipeline separates concerns into stages that fail fast. Cheap checks (lint, unit tests) run first so you never waste minutes building an image for code that does not compile.

StagePurposeTypical command
LintCatch style and static errorsnpm run lint
TestUnit and e2e correctnessnpm run test / npm run test:e2e
BuildCompile and produce a Docker imagedocker build
MigrateApply DB schema changesnpm run migration:run
DeployPromote image to an environmentkubectl set image / platform CLI

Preparing the project scripts

The pipeline only orchestrates commands you already have. Make sure package.json exposes everything CI needs so the workflow stays declarative.

{
  "scripts": {
    "lint": "eslint \"{src,test}/**/*.ts\" --max-warnings=0",
    "test": "jest --ci --coverage",
    "test:e2e": "jest --config ./test/jest-e2e.json --ci",
    "build": "nest build",
    "migration:run": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts"
  }
}

Pin --max-warnings=0 so lint warnings fail the build. A warning ignored on every PR is a warning that never gets fixed.

A GitHub Actions workflow

The workflow below runs on pull requests and pushes to main. The test job gates everything; build-and-push only runs after tests pass and only on main, so feature branches get fast feedback without publishing images.

# .github/workflows/ci-cd.yml
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: app_test
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-retries 5
    env:
      DATABASE_URL: postgres://postgres:test@localhost:5432/app_test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run migration:run
      - run: npm run test
      - run: npm run test:e2e

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    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/build-push-action@v6
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest

Tagging the image with the immutable ${{ github.sha }} is what makes promotion and rollback possible — latest is convenient but never deploy it to production, because you can never reproduce exactly which commit it pointed to.

Environment promotion

Promotion means deploying the same tested image to progressively more important environments. You build once, then move that artifact from staging to production. GitHub Environments add required reviewers and protection rules so a human approves the production step.

  deploy-staging:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4
      - run: |
          kubectl set image deployment/api \
            api=ghcr.io/${{ github.repository }}:${{ github.sha }} \
            --namespace=staging
        env:
          KUBECONFIG: ${{ secrets.KUBECONFIG_STAGING }}

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production   # requires manual approval
    steps:
      - uses: actions/checkout@v4
      - run: |
          kubectl set image deployment/api \
            api=ghcr.io/${{ github.repository }}:${{ github.sha }} \
            --namespace=production
          kubectl rollout status deployment/api --namespace=production --timeout=120s
        env:
          KUBECONFIG: ${{ secrets.KUBECONFIG_PROD }}

The rollout status call blocks until the new pods are healthy. Pair it with a NestJS health endpoint so Kubernetes only marks pods ready once the app and its dependencies are live.

Output:

deployment.apps/api image updated
Waiting for deployment "api" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "api" rollout to finish: 2 of 3 updated replicas are available...
deployment "api" successfully rolled out

Rollbacks

Because each deploy uses an immutable image tag, rolling back is just redeploying the previous commit’s image. Kubernetes also keeps revision history, so a one-liner reverts instantly.

# Roll back to the previous deployment revision
kubectl rollout undo deployment/api --namespace=production

# Or redeploy a specific known-good commit
kubectl set image deployment/api \
  api=ghcr.io/acme/api:1f0d1b2 --namespace=production

Run migrations in a forward-compatible way (additive columns, no destructive drops in the same release). If a deploy can be rolled back but its migration cannot, your rollback will break against the new schema.

Best practices

  • Run lint and unit tests before building images so cheap checks fail fast.
  • Build the Docker image once and promote that exact artifact; never rebuild per environment.
  • Tag images with the immutable commit SHA, never rely on latest in production.
  • Gate the production environment behind required reviewers using GitHub Environments.
  • Use a spun-up postgres service container so migrations and e2e tests run against a real database.
  • Keep migrations backward compatible within a release so rollbacks stay safe.
  • Wait on kubectl rollout status (or your platform’s equivalent) so a failed deploy fails the pipeline instead of silently degrading.
Last updated June 14, 2026
Was this helpful?