Cover illustration for “Dockerizing a Node.js App for Production Kubernetes”

Essay Containers & Clusters

Dockerizing a Node.js App for Production Kubernetes

Ensure Node.js containers handle signals, pass health checks, and fit Kubernetes security policies.

Dockerizing a Node.js app for production Kubernetes is about more than getting a container to start. The build must make a small image that runs without root, receives shutdown signals, and has health checks that show whether the app is working. Each omission has a concrete fix, but the Dockerfile and Kubernetes configuration must agree on how the application starts, serves traffic, and stops.

Why most Node.js Dockerfiles silently fail in production Kubernetes

A successful local run proves only that the application can start. A root-running process can look healthy until a security policy rejects it. A dropped SIGTERM can look like a slow deployment until Kubernetes hard-kills the pod. A bloated image can go unnoticed until autoscaling needs fresh containers quickly.

Two quieter failures make matters worse. Poor cache ordering reinstalls dependencies on every source change, slowing the build that should deliver a fix. Missing health endpoints leave Kubernetes without a useful way to distinguish a serving process from a stuck one. None of these problems needs to produce a build error, because building an image does not test its lifecycle. Every failure has a concrete fix that starts in the build and connects to the running application.

How multi-stage builds eliminate the bloat that slows Kubernetes rollouts

The fix is to split up the work of installing dependencies, compiling the app, and running it in production. These are three responsibilities, although the supplied Dockerfile combines the first two in a builder stage and puts execution in a runtime stage. The builder installs dependencies, runs the TypeScript or bundler step, and strips development dependencies. The runtime receives only compiled output and production node_modules. That boundary keeps build tools and test frameworks out of the application payload.

The reference sequence is straightforward. Install with npm ci, copy the source, run npm run build, and finish the builder with npm prune --omit=dev. Then use COPY --from=builder to transfer the production files into the runtime. An Alpine runtime still has tools from its base image, including npm, so a multi-stage build on its own leaves some tools in place. Distroless makes that further reduction.

A single-stage image for an app on full Debian can be roughly 1 GB, and multi-stage builds can make Docker images roughly 70% smaller. Smaller images take less time to pull during scale-out and have shorter cold starts, which saves time with each rollout. Explicit copies also narrow what crosses the build boundary, but they should not be mistaken for a blanket guarantee that passing secrets through build arguments is safe.

Choosing the right base image for the runtime stage

Start the runtime decision with native-module compatibility. Full Debian works with a wide range of dependencies, but at roughly 1.1–1.3 GB, node:22 is not the first choice for a production runtime. If a native module needs glibc, node:22-slim keeps it while reducing the base to roughly 220–256 MB.

Otherwise, Alpine is the practical starting point. Its base is roughly 58 MB compressed and ~155–160 MB uncompressed, with a typical Express application reaching 170–200 MB. Alpine uses musl in place of glibc. Test bcrypt, sharp, and any C++ addons before committing, because modules that assume glibc can fail or produce cryptic linker errors. A smaller image is not a bargain if its dependencies cannot run.

Once the Dockerfile is stable, distroless is a better fit for places that need tight security, including healthcare and fintech, because gcr.io/distroless/nodejs22 has no shell or package manager for an attacker to use, though this also makes debugging harder. Even without a shell, a compromised application can run malicious code. Pin the Node major version explicitly, start with Alpine, switch to distroless when security needs demand it, and use slim when a native module forces the issue.

Layer cache ordering and BuildKit cache mounts, and why they change build economics

Put COPY package*.json./ before RUN npm ci, and put COPY. . after both. With source copied first, every commit can invalidate the install layer. With manifests copied first, source changes leave that layer reusable, while dependency changes trigger installation. The difference matters most when a slow build delays an urgent rollout.

Inside the Dockerfile, replace npm install with npm ci. It installs exact versions from package-lock.json, rejects a stale lockfile, and does not modify it. BuildKit adds a second cache through RUN --mount=type=cache,target=/root/.npm npm ci. The cache mount preserves npm’s downloaded packages across builds on the same runner, even when dependency changes invalidate the Docker layer. BuildKit cache mounts combined with CI registry caching can add a 30–60% speedup to Node.js builds.

The required companion is .dockerignore. Its minimum exclusions are node_modules, .git, .env, *.log, coverage, dist/tmp, *.test.js, and *.spec.js. Without those exclusions, the build context carries local dependencies, repository data, environment files, and other files the image does not need. Those files increase upload work and can invalidate layers unnecessarily. Good ordering controls when installation runs, while a clean context controls what can disturb the cache.

Why Node.js does not handle signals correctly as PID 1, and how tini fixes it

A rolling update that waits 30 seconds before hard-killing a pod may have a signal problem. Kubernetes sends SIGTERM to PID 1 and waits for the termination grace period before using SIGKILL. With a shell-form command, the shell occupies PID 1 and Node.js runs as its child. The shell does not forward SIGTERM by default, so the application never begins draining its HTTP server. In-flight requests then end abruptly at the hard kill.

Exec-form CMD ["node", "src/index.js"] removes that shell wrapper and lets Node.js receive the signal directly. This is not a claim that Node.js always drops signals as PID 1. The application still needs a process.on('SIGTERM', ...) handler that closes the server and exits after draining.

Tini handles the container side through RUN apk add --no-cache tini and ENTRYPOINT ["/sbin/tini", "--"]. It runs as PID 1, forwards signals, and reaps zombie processes, which matters when the application spawns children. Add a preStop sleep of a few seconds to give the load balancer time to drain endpoints before shutdown begins. Tini delivers the message, but the application and orchestrator still have to finish the handshake.

Poor cache ordering reinstalls dependencies on every source change, slowing the build that should deliver a fix.

Running as a non-root user and what file ownership details break if you do it wrong

Many clusters enforce non-root execution through PodSecurityAdmission or OPA policies. On Alpine, the explicit account pattern is RUN addgroup -g 1001 -S nodejs && adduser -S nodeuser -u 1001 -G nodejs, followed by USER nodeuser after operations that need root. Fixed IDs make permissions reproducible across environments. The official Node.js Alpine image already has the node user with UID 1000, so using USER node instead saves the work of creating another account.

A common mistake is to change the user but leave file ownership as it was. Logs, upload temporary directories, and Unix sockets need writable locations owned by the application user. Apply ownership before switching users, and use the --chown option when copying files from the builder. Otherwise, the security fix becomes a runtime permission error. Distroless requires its supported non-root identity rather than an adduser command, because its runtime has no shell.

Memory limits and NODE_OPTIONS: preventing the OOM kill that looks like an application crash

An OOM kill can look like an application crash when the process grows beyond its container’s memory allowance. Modern Node.js has cgroup awareness, so it can detect Kubernetes limits. Explicit heap sizing still makes the intended budget clear. For a container with a 512 Mi memory limit, the supplied example sets NODE_OPTIONS="--max-old-space-size=384".

That setting caps V8 old-space rather than the whole process, leaving room within the container budget for other memory use. Put it in the Dockerfile when it is a useful default, or in the Deployment’s environment block when workloads need different budgets. Always pair it with Kubernetes resources.requests and resources.limits. Without a pod memory limit, node memory pressure makes termination less predictable and harder to diagnose.

Healthchecks that Kubernetes can actually use to gate traffic and detect hangs

A Dockerfile HEALTHCHECK does not configure Kubernetes probes. Docker and non-Kubernetes runtimes use that instruction, while Kubernetes ignores it and uses its own checks. Keeping it still shows how the image should be checked for health and lets it be used in local development and Compose. The application endpoints connect these environments.

Use three endpoints with separate jobs. The startupProbe calls /health/startup to establish that initialization has finished, including database connection, migrations, and cache warming. A high failureThreshold with a short periodSeconds gives slow starts room to finish. Kubernetes does not evaluate liveness or readiness until startup succeeds.

The readinessProbe calls /health/ready to decide whether the pod can serve traffic now, including whether its database and cache are available. Failure removes the pod from Service endpoints without restarting it. The livenessProbe calls /health/live to test internal process health independently of downstream services. If both use the same endpoint and its result depends on those services, a database outage makes the pod restart over and over.

For event-loop health, use setImmediate to measure callback delay against a threshold. During shutdown, set isShuttingDown when SIGTERM arrives and make readiness return 503 immediately. That stops traffic to the pod while the server drains the connections it already has.

The complete production-grade Dockerfile, annotated decision by decision

The assembled reference starts with the Dockerfile frontend directive for version 1.7 and a builder based on Node 22 Alpine. Its first instruction group copies package*.json and installs with the BuildKit npm cache mount, addressing repeated dependency downloads. Its next group copies source and runs npm run build. The last builder instruction runs npm prune --omit=dev, which strips out build dependencies and leaves the production files.

The runtime starts from a fresh Node 22 Alpine image. Install tini, create the explicit non-root account, and set ownership before selecting USER nodeuser. The runtime uses NODE_ENV=production, the example NODE_OPTIONS heap cap, and only compiled output and production dependencies copied from the builder. Each group carries one policy, namely signal delivery, restricted privileges, a defined memory budget, or a limited runtime payload.

Finish with EXPOSE 3000, which serves only as port documentation. Include a Docker health check wired to the application’s health endpoint, while keeping the Kubernetes probes in the Deployment. Set the tini entrypoint and exec-form CMD ["node", "dist/index.js"] so the compiled application runs beneath the init process. Keep .dockerignore with this reference, because the build’s cache and file-selection guarantees depend on it.

Where the remaining production risk lives after the Dockerfile is correct

A correct Dockerfile does not freeze the security state of its base image. Production images need continuous scanning and automated patch workflows as new CVEs appear. Trivy at CRITICAL,HIGH severity in CI is a starting point. Runtime reachability analysis can reduce the CVE count requiring action by 90% or more, helping teams focus patch work.

The remaining platform work includes Kubernetes upgrades, compatibility management, and autoscaling policy. No Dockerfile can set HPA thresholds or account for burst traffic patterns by itself. Proof of compliance with SOC 2 and HIPAA also needs controls outside the image, including audit logging, network segmentation, and access management.

For small teams, a managed PaaS in the team’s own AWS, GCP, or Azure account can take on cluster management, patching, autoscaling, and compliance controls. That arrangement keeps cloud economics and data residency with the team without assigning every cluster task to application engineers. The division of responsibility is clear. Build a container that behaves correctly, own the cloud account, and let the platform handle the recurring operations that the Dockerfile cannot.

Priya Subramaniam

← Back to Cluster Without Ops