Dockerising a Nest Service
A multi-stage Dockerfile that caches the dependency install, ships only dist and production node_modules, and keeps Node as PID 1.
CMD ["node", "dist/main.js"] # exec form: Node is PID 1 and receives SIGTERM itselfA Nest image has two jobs: compile the code, then carry as little as possible. A multi-stage Dockerfile does both. The first stage installs every dependency and runs nest build. The final stage copies only the compiled output and production dependencies, so the TypeScript compiler never reaches production.
Caching the install
Docker caches each instruction and invalidates every later one as soon as a copied file changes. So copy package.json and package-lock.json on their own, run npm ci, and copy src/ only after that. Editing a controller then costs a recompile instead of a reinstall. Copying the whole project up front throws the install cache away on every commit.
Signals
Whatever you start becomes PID 1 inside the container, and PID 1 is where Docker and Kubernetes deliver SIGTERM. Use CMD ["node", "dist/main.js"] so Node receives it directly. CMD ["npm", "run", "start:prod"] puts npm and a shell in between; if the signal does not reach Node, your shutdown hooks never run and the container is killed when the grace period expires.
Details that bite
Write a .dockerignore containing node_modules, dist and .env, or your host's platform-specific node_modules is uploaded into the build context and may be copied over the ones you installed. Run as the image's existing node user rather than root. NestJS 12 needs Node 20.19+ or 22.12+, so node:24-slim and node:24-alpine are safe bases.
Example
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// Close idle keep-alive sockets on shutdown instead of waiting for the
// client to hang up, which otherwise stalls the container for the whole
// grace period.
forceCloseConnections: true,
});
// Turns SIGTERM from `docker stop` into Nest's teardown sequence. Without
// it, the process is simply killed.
app.enableShutdownHooks();
// Bind every interface explicitly. A server bound to 127.0.0.1 inside a
// container is unreachable from the host and from other containers, and the
// only symptom is a connection reset.
await app.listen(Number(process.env.PORT ?? 3000), '0.0.0.0');
}
await bootstrap();When to use it
- Get a Nest image rebuilding in seconds after a code change instead of reinstalling node_modules each time.
- Find out why `docker stop` takes the full ten seconds and kills in-flight requests.
- Ship an image that passes a scanner because it has no compiler, no sources and no root user.
- Reproduce a production-only failure locally by running the same image with the same env vars.
More examples
The Dockerfile
The shape to copy. Note that the runtime stage installs dependencies again rather than copying node_modules from the build stage: that is what guarantees no devDependency survives, and it keeps the two trees from sharing a cache. If your build needs native modules, add the toolchain to the build stage only. Reach for a second COPY --from=build line if you also ship generated files, such as a Prisma client or a compiled GraphQL schema.
# Dockerfile
# ---------- build stage ----------
FROM node:24-slim AS build
WORKDIR /app
# Manifests only. This layer is reused until a dependency actually changes.
COPY package.json package-lock.json ./
RUN npm ci
# Sources last. Editing src/ invalidates nothing above this line.
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build
# ---------- runtime stage ----------
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# package.json carries "type": "module"; without it Node rejects the ESM output.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# The node:* images already ship an unprivileged "node" user.
USER node
EXPOSE 3000
# Exec form, so node is PID 1 and gets SIGTERM directly.
CMD ["node", "dist/main.js"].dockerignore
Everything the build context does not need. node_modules and dist are the important two: without them listed, Docker uploads your host's compiled artefacts, and a COPY of the project directory can drop macOS or Windows binaries into a Linux image. Excluding .env also stops a developer's local credentials being baked into a published layer, where they stay readable forever.
node_modules
dist
coverage
.git
.github
.env
.env.*
*.log
Dockerfile
.dockerignore
README.mdBuild and run locally
--init inserts a tiny init process as PID 1 that reaps zombie processes and forwards signals to your command; it is worth adding whenever the app spawns child processes. Stopping the container by hand and watching the logs is the only reliable way to confirm that SIGTERM reaches Node, which is the difference between a graceful rollout and dropped requests.
docker build -t myorg/api:1.4.0 .
docker run --rm -p 3000:3000 \
-e PORT=3000 \
-e DATABASE_URL="postgres://app:[email protected]:5432/app" \
--init \
myorg/api:1.4.0
# Then, in another terminal, check the teardown actually happens:
# docker stop <container>
# The logs should show your shutdown hooks before the container exits.
Discussion