Skip to content
NestJS ns deployment 5 min read

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.

ConcernSingle stageMulti-stage
Image sizeLarge (dev deps + source)Small (runtime only)
Dev dependencies in prodYesNo
Build cachingCoarseFine-grained per layer
Attack surfaceWiderMinimal

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 ci rather than npm install in containers. It installs exactly what the lockfile pins, fails if package.json and package-lock.json are 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 node as PID 1, Node does not reap zombie processes. Pass --init (docker run --init) or add tini so signals like SIGTERM propagate cleanly and your onApplicationShutdown hooks 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 than latest for reproducible builds.
  • Run as the non-root node user and use COPY --chown to avoid permission issues.
  • Order COPY and RUN steps from least- to most-frequently changed to maximize layer cache hits.
  • Keep a tight .dockerignore to shrink the build context and avoid leaking .env files.
  • Prefer node dist/main.js and delegate restarts to your orchestrator; reserve pm2-runtime for standalone containers.
  • Add --init or tini so SIGTERM reaches the process and graceful shutdown works.
Last updated June 14, 2026
Was this helpful?