Back to blog
DevOps· · updated on

Dockerizing a NestJS Application: The Complete Guide

From multi-stage Dockerfile to secure production: the complete method I use to containerize a NestJS API — compose, healthchecks, CI/CD and reverse proxy.

LJBy · Full Stack Freelance Developer

In my freelance engagements, I systematically containerize applications. Here is the method I apply for a NestJS API, with an optimized build and reliable deployment. This approach follows Docker's official multi-stage build recommendations and the NestJS documentation.

Why containerize a NestJS API?

Before the how, the why. Dockerizing a NestJS API brings three concrete benefits:

  • Environmental parity: the image running in production is exactly the one tested locally. No more "but it worked on my machine".
  • Reproducible deployments: same Node version, same dependencies, same configuration variables — on any server.
  • Isolation: each API runs in its own container with its own resources. A leaky neighbouring application doesn't impact yours.

For an SMB managing several applications, it's the move from a "tinkered-together" server to infrastructure documented as code.

The multi-stage Dockerfile

The idea: separate the build from the runtime to drastically reduce the final image size.

# Stage 1 — build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2 — production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist

# Security: don't run as root
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Two details make all the difference compared to a naive Dockerfile:

  1. npm ci --omit=dev in the production stage: only runtime dependencies get reinstalled. TypeScript, ESLint and other dev tools stay in the build stage, which is discarded.
  2. USER node: the process doesn't run as root inside the container.

Result: an image that goes from ~1.2 GB down to ~180 MB.

The .dockerignore, forgotten yet essential

Without a .dockerignore, the COPY . . includes node_modules, .git, tests and local files in the build context — bloated images and slow builds:

node_modules
dist
.git
.gitignore
.env*
*.md
test
coverage
.dockerignore
Dockerfile

Security bonus: if your .env (with your secrets) never enters the build context, it can't end up in an image layer.

Orchestrating with docker-compose

In production as in development, a docker-compose.yml describes the whole stack. Example with PostgreSQL:

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://app:secret@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

The key point: depends_on with condition: service_healthy. Without a healthcheck on the database, the API starts before PostgreSQL and crashes on connection — a classic.

CI/CD: build and push automatically

With GitHub Actions, every tag pushes the image to your registry:

name: Build & Push
on:
  push:
    tags: ['v*']

jobs:
  docker:
    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.ref_name }}

On the server, a simple docker pull && docker compose up -d (often automated via watchtower or a webhook) is enough to deploy.

Best practices

  • Always pin versions (node:22-alpine, not latest).
  • Use a .dockerignore to exclude node_modules, .git, tests.
  • Pass secrets via environment variables, never inside the image.
  • Listen for shutdown signals (SIGTERM) for graceful stops: NestJS handles this natively via app.enableShutdownHooks().
  • Combine with CI/CD (GitHub Actions) to build and push the image automatically.

Going further

For production, I usually add a Caddy reverse proxy (automatic HTTPS via Let's Encrypt) in front of the container, and a docker-compose.yml to orchestrate the API + the PostgreSQL database.

Official sources

Want to industrialize your deployments? Let's talk or estimate your DevOps engagement.

DockerNestJSNode.jsDevOpsCI/CD

Frequently asked questions

How big is an optimized NestJS Docker image?

With a multi-stage build and production-only dependencies (`npm ci --omit=dev`), a NestJS image drops from ~1.2 GB to 180-250 MB. Using node:22-alpine as the base and pruning devDependencies explains most of the gain.

Should the Node container run as root?

No. By default containers run as root, which widens the attack surface. Create a dedicated user in the Dockerfile (`USER node`): if compromised, the attacker doesn't get root privileges inside the container.

How do you pass secrets (database, JWT) to a container?

Always via environment variables (non-versioned .env file, Docker secrets, or your CI/CD platform's variables). Never inside the image: anything copied into a layer is recoverable by anyone with access to the image.

Can you dockerize a NestJS API using Prisma or TypeORM?

Yes, with no fundamental difference. The only subtlety: client generation (prisma generate) must happen in the build stage, and migrations run at startup or via a dedicated job — not on every container creation.

A similar project?