Skip to content
ExponaBLOG
NESTJS · DAY 080 / 100

CI/CD for NestJS: a practical guide for developers

From push to production on every merge.

Expona Admin8 min read

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.

typescript
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.

typescript
  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.

typescript
  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.

typescript
  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.

typescript
  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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles