Dockerizing NestJS
Shipping a NestJS app as a Docker image gives you a reproducible, portable artifact that runs the same on your laptop, in CI, and in production. The trick to doing it well is keeping the final image small and secure: you compile TypeScript and install dependencies in a heavyweight build stage, then copy only the runtime artifacts into a slim image that runs as a non-root user. This page walks through a production-grade multi-stage Dockerfile, layer caching for fast rebuilds, and how to launch the container.
Why multi-stage builds
A naive Dockerfile that runs npm install and nest build in a single stage ends up shipping the entire toolchain — TypeScript, the Nest CLI, dev dependencies, and source maps — inside your production image. That bloats the image, widens the attack surface, and slows deploys.
Multi-stage builds solve this by splitting the work. The first stage (builder) has everything needed to compile. The final stage starts fresh from a clean base and copies in only the compiled dist/ folder and production node_modules. Docker discards the builder stage entirely, so none of the build tooling reaches production.
| Concern | Single stage | Multi-stage |
|---|---|---|
| Image size | Large (dev deps + source) | Small (runtime only) |
| Dev dependencies in prod | Yes | No |
| Build caching | Coarse | Fine-grained per layer |
| Attack surface | Wider | Minimal |
The multi-stage Dockerfile
The structure below uses three stages: deps installs all dependencies (cached aggressively), builder compiles the app, and runner is the lean runtime. Copying package*.json before the source code lets Docker reuse the install layer whenever only application code changes.
# syntax=docker/dockerfile:1
# --- Stage 1: install dependencies (cached) ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# --- Stage 2: build the application ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Prune to production-only dependencies for the runtime stage
RUN npm prune --omit=dev
# --- Stage 3: minimal runtime image ---
FROM node:22-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
# Run as the built-in non-root "node" user
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/package.json ./package.json
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
The node:22-alpine base keeps the runtime image around 150 MB instead of the ~1 GB you get from the full Debian-based image. The deps stage runs npm ci against the lockfile for deterministic installs, and npm prune --omit=dev strips dev dependencies so only what main.js actually requires ships to production.
Use
npm cirather thannpm installin containers. It installs exactly what the lockfile pins, fails ifpackage.jsonandpackage-lock.jsonare out of sync, and is faster because it skips dependency resolution.
Caching npm installs
Because COPY package.json package-lock.json ./ happens before COPY . ., Docker caches the npm ci layer and only re-runs it when your dependency manifest changes. Editing a controller no longer triggers a full reinstall.
For even faster CI builds, mount npm’s cache with BuildKit instead of baking it into a layer:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
Enable BuildKit when building:
DOCKER_BUILDKIT=1 docker build -t my-nest-app .
Listening on the right host
A container’s loopback interface is not reachable from outside the container, so bind Nest to 0.0.0.0, not the default localhost. Read the port from the environment so orchestrators can override it.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT ?? 3000;
await app.listen(port, '0.0.0.0');
console.log(`Application listening on port ${port}`);
}
bootstrap();
Building and running
Add a .dockerignore so local node_modules, build output, and secrets never enter the build context — this speeds up builds and avoids leaking files.
node_modules
dist
.git
.env
npm-debug.log
Dockerfile
.dockerignore
Build and run the image:
docker build -t my-nest-app .
docker run --rm -p 3000:3000 -e PORT=3000 my-nest-app
Output:
[Nest] 1 - 06/14/2026, 9:12:04 AM LOG [NestFactory] Starting Nest application...
[Nest] 1 - 06/14/2026, 9:12:04 AM LOG [InstanceLoader] AppModule dependencies initialized
[Nest] 1 - 06/14/2026, 9:12:04 AM LOG [RoutesResolver] AppController {/}
[Nest] 1 - 06/14/2026, 9:12:04 AM LOG [NestApplication] Nest application successfully started
Application listening on port 3000
Process management: node vs a supervisor
For most containerized deployments, run the app directly with node dist/main.js and let your orchestrator (Kubernetes, ECS, Docker Swarm) handle restarts, scaling, and health. Running pm2 or nodemon inside a container is usually an anti-pattern: it hides crashes from the orchestrator and adds a redundant supervision layer.
If you must keep a single container alive across crashes without an external orchestrator, use pm2-runtime, which is designed for foreground container use and forwards signals correctly:
RUN npm install -g pm2
CMD ["pm2-runtime", "dist/main.js"]
When running
nodeas PID 1, Node does not reap zombie processes. Pass--init(docker run --init) or addtiniso signals likeSIGTERMpropagate cleanly and youronApplicationShutdownhooks fire.
Best Practices
- Always use a multi-stage build so dev dependencies and the TypeScript toolchain never reach production.
- Pin a specific Node version (
node:22-alpine) rather thanlatestfor reproducible builds. - Run as the non-root
nodeuser and useCOPY --chownto avoid permission issues. - Order
COPYandRUNsteps from least- to most-frequently changed to maximize layer cache hits. - Keep a tight
.dockerignoreto shrink the build context and avoid leaking.envfiles. - Prefer
node dist/main.jsand delegate restarts to your orchestrator; reservepm2-runtimefor standalone containers. - Add
--initortinisoSIGTERMreaches the process and graceful shutdown works.