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.
| Stage | Purpose | Typical command |
|---|---|---|
| Lint | Catch style and static errors | npm run lint |
| Test | Unit and e2e correctness | npm run test / npm run test:e2e |
| Build | Compile and produce a Docker image | docker build |
| Migrate | Apply DB schema changes | npm run migration:run |
| Deploy | Promote image to an environment | kubectl 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=0so 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
latestin production. - Gate the production environment behind required reviewers using GitHub Environments.
- Use a spun-up
postgresservice 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.