Dockerfile Best Practices for Production Node.js Apps
A Dockerfile that works in development can be a liability in production. Bloated images, running as root, no health checks, and broken layer caching are problems that compound as you scale. This guide walks through the practices that turn a basic Dockerfile into a production-ready one.
Start with the right base image
Node.js image variants
| Tag | Size | Use case |
|---|---|---|
node:22 |
~1 GB | Development only. Includes build tools, Python, gcc. |
node:22-slim |
~200 MB | Good default for production. Debian-based, minimal. |
node:22-alpine |
~130 MB | Smallest official image. musl libc, can cause compatibility issues. |
gcr.io/distroless/nodejs22 |
~120 MB | No shell, no package manager. Maximum security. |
For most production apps, node:22-slim offers the best balance. Alpine can cause subtle bugs with native modules due to musl vs glibc differences. Distroless is ideal for security-hardened environments but makes debugging harder since you cannot exec into the container.
Multi-stage builds
Multi-stage builds let you separate the build environment from the runtime environment. Your build stage can have all the dev dependencies and build tools. The final image contains only what is needed to run.
# Stage 1: Build
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:22-slim AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The builder stage installs all dependencies (including devDependencies) and runs the build. The production stage installs only production dependencies and copies the built output. The final image does not contain TypeScript source, test files, or build tools.
Layer caching: order matters
Docker caches each layer and reuses it if the input has not changed. The key principle: put things that change rarely at the top, things that change often at the bottom.
# Good: package files first, then source code
COPY package*.json ./
RUN npm ci
COPY . .
With this order, changing a source file does not invalidate the npm ci layer. The dependency install (the slowest step) is cached as long as package.json and package-lock.json have not changed.
# Bad: everything at once
COPY . .
RUN npm ci
This invalidates the dependency cache on every source code change.
The .dockerignore file
Without a .dockerignore, Docker sends your entire project directory to the daemon as build context. This includes node_modules, .git, test coverage, and any secrets sitting in the repo.
# .dockerignore
node_modules
.git
.gitignore
*.md
.env*
.vscode
coverage
dist
.nyc_output
Dockerfile
docker-compose*.yml
This reduces build context size (faster builds), prevents secrets from leaking into images, and avoids overwriting the container's node_modules with your local ones.
Run as non-root
By default, Docker runs everything as root. If an attacker exploits a vulnerability in your app, they have root access inside the container. The official Node.js images include a node user for exactly this purpose:
# Create app directory with correct ownership
WORKDIR /app
COPY --chown=node:node . .
# Switch to non-root user
USER node
CMD ["node", "server.js"]
The USER node directive must come after all COPY and RUN commands that need root permissions (like npm ci). If you need to install system packages, do that before switching users.
Health checks
A health check tells the orchestrator (Docker Swarm, Kubernetes, ECS) whether your container is actually healthy, not just running.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "const http = require('http'); const req = http.request({hostname: 'localhost', port: 3000, path: '/health', timeout: 2000}, (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }); req.on('error', () => process.exit(1)); req.end();"
Or more readably, create a healthcheck.js file:
// healthcheck.js
const http = require('http');
const req = http.request(
{ hostname: 'localhost', port: 3000, path: '/health', timeout: 2000 },
(res) => process.exit(res.statusCode === 200 ? 0 : 1)
);
req.on('error', () => process.exit(1));
req.end();
COPY healthcheck.js .
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "healthcheck.js"]
In Kubernetes, use livenessProbe and readinessProbe instead of Docker's HEALTHCHECK.
Handle signals correctly
When Docker stops a container, it sends SIGTERM. Node.js does not handle this by default -- it just dies, potentially dropping in-flight requests.
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
Use CMD ["node", "server.js"] (exec form), not CMD node server.js (shell form). The shell form wraps your process in /bin/sh, which does not forward signals to the child process.
Pin your dependencies
Use npm ci instead of npm install in Dockerfiles. npm ci installs exactly what is in package-lock.json, is faster, and fails if the lockfile is out of sync with package.json. This ensures reproducible builds.
# Good
COPY package*.json ./
RUN npm ci --omit=dev
# Bad
COPY package*.json ./
RUN npm install --production
Also pin your base image to a specific digest or at least a minor version:
# Pinned to minor version
FROM node:22.12-slim
# Pinned to digest (most reproducible)
FROM node:22-slim@sha256:abc123...
Using node:latest or even node:22 in production means your image can change unexpectedly when a new patch is released.
Reduce attack surface
Remove unnecessary files
After installing dependencies, clean up:
RUN npm ci --omit=dev && \
npm cache clean --force
Do not install unnecessary system packages
Every installed package is a potential vulnerability. If you do not need curl, wget, or git at runtime, do not install them. Multi-stage builds naturally solve this because dev tools stay in the builder stage.
Scan your images
# Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest
# Trivy
trivy image myapp:latest
# Snyk
snyk container test myapp:latest
Run these in CI. Block deployments if critical vulnerabilities are found.
Environment variables
Never bake secrets into images. Use runtime environment variables:
# Bad: secret in the image layer
ENV DATABASE_URL=postgres://user:password@host/db
# Good: set at runtime
# docker run -e DATABASE_URL=... myapp
For build-time configuration that is not secret (like NODE_ENV):
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
A complete production Dockerfile
Putting it all together:
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
&& rm -rf /var/lib/apt/lists/*
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 ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "healthcheck.js"]
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
The dumb-init package acts as PID 1, properly forwarding signals and reaping zombie processes. This is a lightweight alternative to using --init in the Docker run command.
Try our Docker Generator to generate production-ready Dockerfiles for your Node.js apps -- answer a few questions and get a best-practice Dockerfile instantly, right in your browser.