Writing a Production Dockerfile for a Node.js App
The Dockerfile an AI tool writes usually works — and ships a 1.5 GB image running as root that ignores shutdown signals and leaks build secrets into its layers. A line-by-line production Dockerfile: multi-stage builds, layer caching, non-root users, signal handling, secrets, and health checks.
Ask an AI tool for a Dockerfile and you'll get something like this:
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]
It works. It also produces an image well over a gigabyte, reinstalls every dependency when any file changes, ships your development tools and source into production, runs as root, and — depending on your setup — may drop in-flight requests on every deploy. Here's how to write one properly, and why each change matters.
The production version
For a typical Node app with a build step (TypeScript, a framework build):
# syntax=docker/dockerfile:1
# ---- deps: install all dependencies (cached unless lockfile changes)
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# ---- build: compile the app
FROM deps AS build
COPY . .
RUN npm run build \
&& npm prune --omit=dev
# ---- runtime: only what's needed to run
FROM node:24-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/server.js"]
And a .dockerignore next to it:
node_modules
.git
.env*
dist
coverage
*.log
Dockerfile
.dockerignore
Now, why each piece.
Multi-stage builds: build tools stay behind
Each FROM starts a new stage. The deps and build stages have everything needed to compile — dev dependencies, TypeScript, build tooling, your full source. The final runtime stage copies in only the output: production node_modules, the compiled dist, and package.json.
Everything else — compilers, test frameworks, source files, and anything that touched the build — is left in the discarded stages. The result is smaller, faster to pull, and has far less attack surface.
Layer caching: copy the lockfile first
Docker caches each instruction as a layer and reuses it if the inputs haven't changed. Copying package.json and the lockfile before the rest of the source means the expensive npm ci layer is reused on every build where dependencies didn't change — which is most builds. Copying everything first (COPY . . then install) invalidates the install on every code edit.
The --mount=type=cache line keeps npm's download cache between builds too, so even when dependencies change, unchanged packages aren't re-downloaded.
npm ci, not npm install: it installs exactly what the lockfile says, and fails if package.json and the lockfile disagree. (What Are npm and package.json?)
Base image: slim, and pinned
node:24-slim(Debian-based, minimal) is a good default: much smaller than the full image, with glibc, so native modules generally work.- Alpine images are smaller still, but use musl libc, which occasionally causes problems with native modules and some tools. Choose it deliberately, not by default.
- Distroless images contain only the runtime — no shell or package manager — for the smallest attack surface, at the cost of debuggability.
- Pin the version.
node:24-slimtracks the latest 24.x; for fully reproducible builds, pin to a specific version or an image digest (node:24-slim@sha256:...) and update deliberately. (How to Update Your App's Dependencies Safely.)
.dockerignore: don't send what you don't need
Without it, COPY . . sends your local node_modules, .git history, and — critically — .env files into the image. Anyone who can pull the image can read them. The .dockerignore above prevents that, and speeds up builds by shrinking the build context.
Run as a non-root user
Containers run as root by default. If an attacker compromises your app, root inside the container makes escalation and damage easier. The official Node images include a node user; USER node switches to it, and --chown=node:node gives it ownership of the app files.
The app then can't write to system directories — which is how it should be. If it needs a writable directory (for a cache, say), create it and chown it explicitly, or mount a volume. (Docker Volumes vs Bind Mounts.) For a deeper layer of hardening, see Hardening Containers With seccomp, AppArmor, and User Namespaces.
Signals: why CMD ["node", ...] and not npm start
When a platform stops a container during a deploy, it sends SIGTERM to process 1, waits a grace period, then kills it. Your app should use that time to finish in-flight requests and close connections. (Zero-Downtime Deploys for a Small App.)
Two things break this:
- Shell form (
CMD npm startwithout brackets) runs your command under/bin/sh, which may not forward signals to Node. npm startas PID 1 — npm is an extra layer between the signal and your app, and historically hasn't reliably forwarded signals.
Use exec form with node directly: CMD ["node", "dist/server.js"]. Then make sure the app actually handles SIGTERM:
process.on('SIGTERM', () => {
server.close(() => process.exit(0)) // stop accepting, finish in-flight
})
If your app spawns child processes, running with an init process (docker run --init, or tini in the image) also reaps zombie processes and forwards signals correctly.
Secrets: never in the image
Anything in a layer is in the image forever, even if a later instruction deletes it. So:
- Runtime secrets (database URL, API keys) come from environment variables at run time, set by your platform — never
ENV SECRET=...orCOPY .envin the Dockerfile. (What Is an Environment Variable?) - Build-time secrets (a token for a private npm registry) use BuildKit secret mounts, which are available during one
RUNand never stored in a layer:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .
ARGis not secret — build args are visible in the image history.
Health checks
HEALTHCHECK lets Docker (and platforms that honour it) know whether the app is actually working, not just running. Point it at a cheap endpoint that verifies the app can reach its database. Many orchestrators use their own probes instead of Docker's HEALTHCHECK, but the endpoint is the same either way.
Framework-specific: Next.js
Next.js's standalone output fits this pattern well: the build stage runs next build with output: 'standalone', and the runtime stage copies .next/standalone, .next/static, and public, then runs node server.js with HOSTNAME=0.0.0.0. (Self-Hosting Next.js Without Vercel.)
Scan and verify
- Scan images for known vulnerabilities with tools like Docker Scout or Trivy, ideally in CI. (Set Up CI With GitHub Actions.)
- Check the size:
docker image ls. A typical Node API should land in the low hundreds of megabytes, not gigabytes. - Check the user:
docker run --rm your-image whoamishould printnode, notroot. - Check shutdown: start the container, send a request, and
docker stopit — the request should complete, and the container should exit well before the timeout.
The checklist
- Multi-stage build; runtime stage has only production deps and build output
- Lockfile copied before source;
npm ciwith a cache mount - Slim, pinned base image
-
.dockerignoreexcludes.env,.git,node_modules -
USER node; files owned by it - Exec-form
CMDrunningnodedirectly;SIGTERMhandled - No secrets in
ENV,ARG, or copied files; BuildKit secrets for build-time - Health check endpoint
- Image scanned in CI
EasySpawn runs each workspace as an isolated container as a non-root user, with CPU, memory, and process limits enforced and project data on persistent storage — the same principles, applied for you. See how it works or join the waitlist.
Related: What Is Docker? · How Container CPU and Memory Limits Actually Work · Docker Compose for Local Development · What Is Node.js?
Keep reading
Secrets Management Beyond .env Files
.env files are fine on a laptop and fragile everywhere else. Where secrets should live in production and CI, secret managers vs platform env vars, OIDC to remove long-lived CI credentials, rotation, least privilege, keeping secrets out of logs and AI agent context, and a practical maturity path.
Reverse Proxies Explained: Nginx, Caddy, and Traefik in Front of Your App
A reverse proxy sits between the internet and your app, handling TLS, routing, compression, and more. What reverse proxies do, how Nginx, Caddy, and Traefik differ, forwarded headers and trusting the real client IP, WebSockets and streaming, timeouts and body limits, and common 502/504 causes.