Dockerizing a Node.js API for Production
Learn the production-ready choices that prevent crashes, bloat, and forced kills.

Sponsored by Porter
- Written by
- Ship It Yourself Editors
- Published
- October 16, 2026
- Reading time
- 9 min read
What this covers
- What a "working" Node.js Dockerfile actually ships to production — and what it gets wrong
- Why base image choice sets the ceiling for everything that follows
- How multi-stage builds eliminate the bloat that a base image swap alone cannot fix
- Why layer-caching discipline cuts build times more than any other single optimization
Dockerizing a Node.js API for production means more than getting it to start. A production Dockerfile must control what ships, how it runs, and what happens when it fails or stops. Those guarantees come from deliberate choices about dependencies, permissions, signals, and resources.
What a "working" Node.js Dockerfile actually ships to production — and what it gets wrong
The naive Dockerfile starts with a full Node.js image, copies the whole project, runs npm install, and uses npm to start the application. That is enough to serve a request locally. It also ships development dependencies, runs the process as root, and runs the dependency install again each time the source files change. Without a health check or a reliable signal path, it leaves failure detection and shutdown to chance.
These faults rarely announce themselves during local development. A local run may never encounter an orchestrator, a memory limit, or a shutdown with requests still in flight. Meanwhile, an install can drift from the tested dependency set if the lockfile is stale or absent. The container starts successfully, but that success proves much less than it appears to.
Why base image choice sets the ceiling for everything that follows
The full Debian-based Node.js image has a userland, bash, apt, and build tools. It is friendly to native modules and easy to debug, but weighs around 950 MB. That weight affects image pulls during CI, scale-out, and cold starts. A smaller base means less to download and less software exposed to vulnerabilities, even before the application code is added.
The node:lts-slim image keeps glibc and most debugging paths open at roughly 220 MB, or roughly 240 MB with a typical Express app. The node:lts-alpine image uses musl libc and BusyBox, weighing roughly 130–155 MB before installation and roughly 150–180 MB afterward. Its trap is native compatibility. Modules such as bcrypt, sharp, and other C++ addons that assume glibc can build successfully but crash at runtime unless compiled inside Alpine, so test native addons before committing to it.
Google's distroless Node.js image leaves out the shell and package manager, which makes it harder to debug but gives it a smaller attack surface. It fits a mature runtime stage better than active iteration. Version support matters just as much as distribution choice. As of mid-2026, the recommended production target is Node.js 24, codenamed Krypton, while Node.js 20 reached End of Life on April 30, 2026.
How multi-stage builds eliminate the bloat that a base image swap alone cannot fix
A smaller base still has the tools that the application build adds. A TypeScript build still needs development dependencies and a compiler, even though the running API does not. Multi-stage builds keep those needs apart by using more than one FROM instruction. Each stage starts from its own image, and only the files chosen with COPY --from= go into the final stage.
For the common build-step case, the deps stage copies only the package manifests and runs npm ci, including development dependencies. The builder stage takes those dependencies, copies the source, and runs npm run build. It then runs npm prune --omit=dev before anything crosses into the runtime image. That ordering prevents development dependencies from inflating runtime layers, rather than merely hiding them beneath a later deletion.
The runtime stage starts fresh with Alpine or distroless and receives only node_modules and dist. It sets ownership, switches user, and sets NODE_ENV=production. Multi-stage distroless images can weigh roughly 115–180 MB, so pulls and cold starts are faster and scanners have less software to flag. Explicit cross-stage copies also keep build-only files out of the final image, provided those files are not among the copied output.
Why layer-caching discipline cuts build times more than any other single optimization
Separation controls what ships, but instruction order controls how much work repeats. Docker reuses a cached layer when its instruction and inputs have not changed. When a layer is invalidated, the layers after it must be built again. Putting COPY . . before npm ci therefore makes every source edit a reason to reinstall dependencies.
Copy package.json and package-lock.json first, then run npm ci. Copy the rest of the source afterward and run the build step last. The dependency install can then stay cached while the application code changes.
A BuildKit cache mount adds another useful boundary. The instruction RUN --mount=type=cache,target=/root/.npm npm ci preserves npm's download cache at the daemon level. Even when a changed lockfile invalidates the install layer, cached package tarballs can avoid repeated downloads.
What npm ci protects against that npm install does not
Caching an install is useful only if the install is predictable. With a stale or absent lockfile, npm install may resolve different minor or patch versions and silently update package-lock.json. The resulting image can contain dependencies different from those committed and tested. A transitive dependency change can then produce a regression that is hard to reproduce.
By contrast, npm ci requires an existing lockfile, removes any existing node_modules, and installs the locked dependency set without modifying the lockfile. It also skips the resolution step used by npm install, making it faster for clean installs. Use it for container builds, with development dependencies available where compilation needs them. Keep those dependencies out of production with --omit=dev or the builder-stage pruning described earlier.
Why running as root inside a container is a vulnerability, not just a bad practice
Once the image contents are controlled, the next boundary is the process user. Docker containers run as root unless their configuration changes it. An attacker who exploits application code inherits that user's permissions, so root access inside the container magnifies the damage. In some configurations, that access can also help an attacker escape the container.
The official Node.js Alpine image has a node user with UID 1000, so there is no need to create another account. Use COPY --from=builder --chown=node:node to set ownership when the runtime files are copied in. This also avoids a separate ownership-changing layer. An explicit user gives the running API a narrower set of permissions than the build process needed.
Ownership must be set before the USER instruction. After switching users, the process cannot repair root-owned directories with chown. Missing this order often produces a misleading failure, because the application starts normally and crashes only when it first writes a log or upload. For non-root execution, set up writable paths ahead of time and keep broad permissions from being restored afterward.
The signal handling failure that causes 30-second forced kills instead of graceful shutdowns
Dropped requests, unclean database connections, and connection reset errors can all follow a container stop. The process seems to ignore termination until the grace period runs out, often after 30 seconds. The orchestrator then sends SIGKILL. At that point, application cleanup no longer gets a chance to run.
The signal path explains the failure. An orchestrator sends SIGTERM to PID 1, but a launcher between PID 1 and Node.js may fail to forward it. Starting through CMD ["npm", "start"] adds npm and a shell to that path. The application's shutdown handlers cannot work if the signal never reaches it.
On Alpine, install tini with RUN apk add --no-cache tini, then use ENTRYPOINT ["/sbin/tini", "--"] and CMD ["node", "dist/index.js"]. Tini forwards signals to its child and reaps zombie processes. Docker's --init option or Compose's init setting offers an alternative. Once Node.js receives SIGTERM, the application must call server.close(), drain requests through the close callback, close database pools and other long-lived resources, and then call process.exit(0).
The health check that lets orchestrators detect a hung process, not just a running one
Clean shutdown covers a process being stopped, but not one that remains alive while unable to serve traffic. A blocked event loop or broken database connection pool can leave an API running but unusable. Expose a lightweight /health endpoint that returns HTTP 200 only when the application is genuinely ready. Where database access is part of readiness, the endpoint can check that connection too.
The Dockerfile's HEALTHCHECK can run every 30 seconds, time out after 5 seconds, allow a 30-second startup period, and require three consecutive failures. Use Node.js itself to make the HTTP request so there is no need to install another tool. Alpine's wget can also do this, but adding curl just for this job makes the image heavier and leaves more software exposed to vulnerabilities. The runtime must use the resulting health status to make routing or restart decisions.
Kubernetes uses its own livenessProbe and readinessProbe independently of Docker's health check. Those probes still need configuration. Keeping HEALTHCHECK in the image remains useful for Docker Compose, local testing, and non-Kubernetes runtimes.
The environment variables and memory settings that change Node.js runtime behavior inside a container
A mysterious restart may result from an OOM kill or an application exception. If V8 grows its heap beyond the container's available memory, the runtime can kill the process before garbage collection brings it back under control. Set the heap budget to match the container's memory limit, even when the host-memory ceiling is larger. Memory configuration belongs to the deployment contract, even when the symptom looks like a code defect.
Set --max-old-space-size to roughly 75% of the container's memory limit. A 512 MB container would use about 384 MB, while a 1 GB container would use about 768 MB. The Dockerfile can express the former through NODE_OPTIONS="--max-old-space-size=384". The memory left over provides room outside the old-space heap.
Set ENV NODE_ENV=production in the runtime stage so production behavior is not left to an omitted platform setting. Express uses this setting to cache view templates and generated CSS and produce less verbose errors. Enable --enable-source-maps only when source maps are included. Keep database URLs, API keys, and JWT secrets out of Dockerfile environment instructions and inject them through runtime secret management.
The.dockerignore file that prevents the build context from leaking what the Dockerfile excludes
The build context reaches the Docker daemon before Dockerfile instructions run. Without .dockerignore, local dependencies, repository history, and environment files enter that context even if the runtime stage never needs them. Exclude node_modules, .env, .env.*, .git, .gitignore, and npm-debug.log. Also exclude tests/, coverage/, .nyc_output, .vscode, .idea, and other Dockerfiles or Compose files containing environment-specific configuration.
These exclusions cut transfer size and prevent unrelated files from invalidating cached work. More importantly, they keep a broad COPY. . from capturing secrets. Deleting a copied secret in a later instruction does not remove it from the earlier layer. The safe boundary is to keep it out of the copy in the first place.
How this Dockerfile holds up against the CVE cadence Node.js shipped in 2026
Node.js's July 29, 2026 advisory reported 11 CVEs across patched releases, following postponements for additional testing and validation and infrastructure issues. That cadence makes image maintenance an ongoing part of production operation. A smaller image and a non-root user reduce exposure and potential damage. Neither replaces a patched runtime.
Node.js 20 images have no official patch path for vulnerabilities disclosed after their End of Life. Supported software with an active security patch path also matters for SOC 2, HIPAA, and PCI DSS compliance. Teams that cannot upgrade right away can look into commercial extended support programs. Keeping an unsupported base unchanged is not a substitute for such support.
Add container scanning to CI with a tool such as Trivy from Aqua Security, Grype from Anchore, or Docker Scout. Make the scan a merge gate that checks base packages, application dependencies, and OS libraries and fails if it finds an issue above the chosen severity threshold. Signed images and software bills of materials also help meet what enterprises expect when they buy software or audit it. The goal is evidence about the shipped image beyond its Node.js version.
What happens after the Dockerfile is right — deploying to infrastructure that matches the container's guarantees
The deployment platform must now honor the image's contracts. A short sleep in a preStop hook gives the load balancer time to drain the pod before SIGTERM. A PodDisruptionBudget with minAvailable of at least 2 helps keep too many replicas from being removed during maintenance.
Resource requests and limits must match the memory budget used for V8. Liveness and readiness probes must call the application's /health endpoint. Graceful termination windows must leave enough time for the shutdown path to finish. Without that matching configuration, the Dockerfile describes protections the platform does not actually use.
The remaining work includes cluster management, autoscaling, networking, patch cycles, and compliance configuration. Platform choice matters because those responsibilities remain after the image is built correctly. A container built for production can run without changes from local development through CI to production. The infrastructure's job is to preserve its guarantees at scale.
This article is sponsored by Porter.