All posts
6 min read

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.

dockerinfrastructuredeploymentintermediate

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-slim tracks 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 start without brackets) runs your command under /bin/sh, which may not forward signals to Node.
  • npm start as 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=... or COPY .env in 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 RUN and never stored in a layer:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .
  • ARG is 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 whoami should print node, not root.
  • Check shutdown: start the container, send a request, and docker stop it — 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 ci with a cache mount
  • Slim, pinned base image
  • .dockerignore excludes .env, .git, node_modules
  • USER node; files owned by it
  • Exec-form CMD running node directly; SIGTERM handled
  • 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