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.
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:
npm ci --omit=devin the production stage: only runtime dependencies get reinstalled. TypeScript, ESLint and other dev tools stay in the build stage, which is discarded.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, notlatest). - Use a
.dockerignoreto excludenode_modules,.git, tests. - Pass secrets via environment variables, never inside the image.
- Listen for shutdown signals (
SIGTERM) for graceful stops: NestJS handles this natively viaapp.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
- Docker — multi-stage builds
- Docker — build best practices
- NestJS — official documentation
- Node.js — official Docker image
Want to industrialize your deployments? Let's talk or estimate your DevOps engagement.
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.