After 80 days of building with NestJS, your application is solid—but shipping it manually every time is a bottleneck. CI/CD for NestJS eliminates that friction: automated testing, linting, Docker builds, and deployments run on every push, catching bugs before they reach production. In this guide, I'll walk you through a battle-tested pipeline that treats quality gates as first-class citizens and keeps your team moving fast.
Why CI/CD Matters for NestJS Teams
When I first started deploying NestJS services at scale, we were running tests locally and pushing to staging manually. It worked until it didn't—a teammate skipped linting, a database migration didn't run, and we had a midnight incident. CI/CD removes that human element. Every single commit follows the same immutable path: lint, type-check, test, build, migrate, deploy. No shortcuts, no exceptions.
For a platform handling real users—whether it's e-commerce transactions, CRM workflows, or ride-hailing requests—this repeatability is non-negotiable. You're trading a few minutes of pipeline time for confidence that your code meets standards before it touches production.
Pipeline Stages: The Complete Flow
A robust CI/CD for NestJS pipeline follows a structured sequence. Think of it as a series of gates, each one progressively more expensive to run but progressively more valuable.
Stage 1: Install and Lint (Fail Fast)
The pipeline starts with the cheapest operations. Install dependencies, then run linters and type-checkers. If any of these fail, the entire pipeline stops immediately—no point running tests on code with syntax errors or ESLint violations.
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
jobs:
lint-and-type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run type-check
In this example, npm ci (clean install) ensures reproducible dependency resolution. Linting catches style issues, unused imports, and potential bugs. Type-checking with TypeScript happens next—it's fast, deterministic, and catches entire classes of errors before any tests run.
Why first? A linting failure or type error takes seconds to fix and seconds to report. Running tests on code that won't even compile is wasteful.
Stage 2: Unit and E2E Tests with Service Containers
Once code passes static checks, spin up test services and run the full suite. This is where you verify logic and integration points.
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:unit
- run: npm run test:e2e
GitHub Actions starts Postgres and Redis as containers. The health-cmd ensures they're ready before tests run. Your NestJS tests connect via DATABASE_URL=postgresql://testuser:testpass@localhost:5432/testdb and REDIS_URL=redis://localhost:6379 environment variables.
This approach mirrors your production environment—if a query works here, it works there. I've caught N+1 query problems, race conditions in job queues, and caching bugs through this layer.
Stage 3: Build Docker Image Tagged by SHA
Only after all tests pass do you build the Docker image. Tag it by commit SHA for traceability—you always know which exact source code is running in production.
build:
runs-on: ubuntu-latest
needs: [lint-and-type-check, test]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The needs: [lint-and-type-check, test] ensures this only runs after previous stages pass. By tagging with github.sha, you create an immutable artifact. If something breaks in production, you instantly know which commit caused it and can roll back.
Stage 4: Run Migrations Before Traffic Switch
Database migrations are typically the most fragile part of a deployment. Run them in a separate, sequential step before switching traffic to the new code.
migrate:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run Prisma migrations
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
run: npx prisma migrate deploy
This runs only on the main branch. The migration happens before your new container is live, so if a migration fails, the deployment stops—no half-deployed state. Prisma's migrate deploy is idempotent: running it twice is safe because it checks which migrations have already been applied.
Stage 5: Deploy and Smoke Test
Finally, deploy the image and run smoke tests to verify the service is responding correctly.
deploy:
runs-on: ubuntu-latest
needs: [build, migrate]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
deploy@$DEPLOY_HOST \
"docker pull ghcr.io/${{ github.repository }}:${{ github.sha }} && \
docker-compose up -d && \
sleep 5"
- name: Smoke test
run: |
for i in {1..10}; do
if curl -f https://your-app.com/health; then
echo "Service is healthy"
exit 0
fi
echo "Attempt $i failed, retrying..."
sleep 2
done
exit 1
After deployment, the smoke test polls the health endpoint. If it fails 10 times, the entire deployment is flagged as failed and you can manually investigate. This catches obvious issues—missing environment variables, misconfigured services, database connection problems—before users do.
Fail Fast: Why Order Matters
The stages aren't arbitrary. Linting and type-checking happen first because they're instantaneous and catch 30% of bugs without touching a filesystem or database. Unit tests run next—they're fast and isolated. E2E tests with real services come after, because they're slower but more realistic. Docker builds only after all checks pass, because building takes time and disk space.
I've seen teams run tests before linting and waste 10 minutes on a test run only to fail on an ESLint error. Or worse, build Docker images for code that won't even type-check. Fail fast means putting cheap, high-signal checks first.
Service Containers for E2E Testing
When I was shipping an e-commerce platform, we were mocking Postgres and Redis during CI but hitting real services in staging. We caught a critical bug in staging where a cached value was stale in production conditions—but it wasn't reproducible locally because we weren't caching. GitHub Actions service containers solved this: we run the exact same Postgres and Redis versions in CI that we run in production.
The health checks are crucial. Without them, your tests might start before Postgres is accepting connections, causing flaky test failures. With them, you pay a small startup cost upfront and get reliable, repeatable test runs.
Migrations Before Code
Here's a deployment pattern that saved me from a production outage: migrations run as a separate, sequential stage before the new code is live. This means:
- Forward compatibility: New code runs against the migrated schema. If there's a mismatch, you catch it in the migrate stage, not after traffic switches.
- No rollback surprises: If you need to rollback, the schema is already updated and the old code can handle it (because you tested against the new schema).
- Idempotency: Prisma's migrate deploy checks which migrations have run, so re-running it is safe.
The critical piece: if: github.ref == 'refs/heads/main' ensures migrations only run on your main branch, not on every PR. On PRs, you can validate migrations locally or in a staging environment.
Common Mistakes
Running tests before installing dependencies: Seems obvious, but I've seen workflows that skip npm ci and assume dependencies are cached. Caches fail. Always install explicitly.
Not using health checks on services: A Postgres container that's started but not yet accepting connections will cause random test failures. The health check prevents this.
Building Docker images before running migrations: If the migration fails, you've already built an image that won't work. Keep build and migration as separate stages so failures stop the pipeline early.
Deploying to production on every branch: Only deploy on main (or develop, if you have that stage). PRs should run the full test suite but not touch production. Use the if: condition to gate deployment steps.
Not tagging images by commit SHA: If you tag images only as latest, you can't trace which commit broke production. Commit SHA tags are immutable and traceable.
Skipping smoke tests: A smoke test takes 30 seconds and catches 20% of deployment issues. It's a net win.
Key Takeaways
- Pipeline stages are a hierarchy: Lint and type-check first (seconds), then unit tests (minutes), then e2e tests (minutes), then build (minutes), then deploy (seconds to minutes).
- Service containers (Postgres, Redis) in CI ensure test conditions match production. Use health checks to prevent flaky tests.
- Migrations run in their own stage before traffic switches, guaranteeing the schema is ready before new code is live.
- Tag Docker images by commit SHA for traceability. You always know which source code is in production.
- Fail fast with cheap checks first. Static analysis before tests, tests before builds, builds before deploys.
- Smoke tests catch deployment issues before users do. A simple health check endpoint is a powerful safety net.






Comments
Loading comments…