Putting a Node.js app in Docker is easy. Running that container safely is the real work. A production image needs a repeatable build, a non-root process, a clear health signal, correct shutdown behavior, and logs that still make sense after the container is replaced.

Make the build reproducible

Pin the Node major version and use a lockfile in the image build. npm install can resolve a different dependency tree from one build to the next. Prefer npm ci for a committed lockfile, and make the build fail when the lockfile and package manifest disagree.

Keep development dependencies out of the final runtime image when the application does not need them. A multi-stage build can install everything in a builder stage, compile the application, and copy only the runtime output and production dependencies into the final stage.

Do not run as root

The container should use a dedicated user. A web application is easier to contain when a dependency bug cannot write anywhere it wants inside the image. This is not a replacement for host security, but it reduces the impact of a process-level mistake.

Also make the filesystem read-only where practical. If the application needs temporary files, provide one explicit writable temporary directory instead of assuming the whole container should be writable.

Health is not readiness

A process can be alive while it cannot serve useful traffic. A liveness check answers whether the process should be restarted. A readiness check answers whether the instance should receive traffic.

Keep the endpoint cheap. A health route that performs a full database query every second can become its own load source. Instead, expose a lightweight process check and monitor dependency failures separately. If the service cannot work without a database connection, make readiness reflect that dependency deliberately.

Handle termination correctly

When a deployment replaces a container, the process receives a termination signal. The server should stop accepting new work, finish safe in-flight requests, close database pools, and exit before the platform deadline.

const server = app.listen(port);

process.on('SIGTERM', async () => {
  server.close(async () => {
    await db.end();
    process.exit(0);
  });
});

The exact shutdown API depends on the framework, but the promise is the same: no new work, bounded drain time, and a visible failure if cleanup cannot finish. A shutdown timeout of 2 hours is a signal that the process has no real shutdown contract.

Treat configuration as external

Do not bake secrets into an image layer. Pass configuration at runtime and fail fast when required values are missing. Log the names of enabled features, not secret values. For debugging, print the Node version, build identifier, and environment name so an old image is not mistaken for a new deployment.

Final checklist

  • Use npm ci and a lockfile.
  • Build in stages and keep runtime images small.
  • Run as a non-root user.
  • Separate liveness from readiness.
  • Test SIGTERM locally before relying on the platform.
  • Keep secrets out of the image and logs.
  • Set resource limits and observe memory before production.

Docker is useful when it makes deployment behavior repeatable. The container itself is not the reliability strategy; the checks around it are.

Verify the image before release

Build the image in CI and run that same image in a short smoke test. Confirm the expected port is open, the health endpoint returns the expected status, and the process can receive SIGTERM without leaving the test hanging. I tested this flow with a deliberately slow cleanup path and found that graceful shutdown needs a hard deadline, not an assumption that every dependency will close quickly.

Scan the final image rather than only the source repository. The final layer can contain a package, debug file, or copied environment file that never appeared in the application diff. Keep the image label, source revision, and build timestamp available to operators, but never put credentials into labels or logs.