A Dockerfile that starts with FROM node, copies the repository, and runs npm install will probably work. It can also produce a huge image, invalidate its cache on every source edit, include development secrets, and run the application as root.
Separate dependency, build, and runtime concerns
A multi-stage build makes each responsibility visible. Copy lockfiles before source code so dependency installation remains cached when only application files change. Use npm ci for a reproducible dependency graph.
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./
USER node
CMD ["node", "dist/server.js"]
The exact stages vary for workspaces and native dependencies, but the runtime stage should contain only what the process needs. A smaller image reduces transfer time and attack surface; it does not excuse keeping the base image patched.
Treat the build context as input
Docker sends the build context to the builder before running instructions. A .dockerignore should exclude Git history, local environment files, logs, test output, editor state, and dependency directories. This protects cache performance and reduces the chance of copying credentials into an intermediate layer.
Run as a real process
Node should receive termination signals and have time to stop accepting traffic, finish in-flight requests, and close connections. Use exec-form CMD, handle SIGTERM, and align the application’s shutdown timeout with the orchestrator’s grace period.
Pin what you can reproduce
Pin the major Node runtime intentionally and use the lockfile for packages. For stricter supply-chain control, pin the base image digest through automation that also refreshes it. Reproducibility is useful only if security updates still have a path into production.
A production image is an artifact, not a development environment. Build it once, scan it, promote the same digest, inject configuration at runtime, and keep the final stage boring enough to audit in a minute.