Software Studio
Back to Articles

Docker for Production: Beyond the Basics

DockerDevOpsNode.js

Every developer knows how to write a Dockerfile. Few write Dockerfiles that are production-ready. The gap between a working container and a production container is security, performance, and operational maturity.

Multi-stage builds are the foundation. Your build stage includes compilers, dev dependencies, and build tools. Your runtime stage includes only what's needed to run. For a Node.js app, the build stage runs npm ci and npm run build. The runtime stage copies only node_modules (production) and the build output. This typically reduces image size by 60-80%.

Base image selection matters more than people think. Alpine images are smaller but use musl instead of glibc, which causes subtle compatibility issues with some Node.js native modules. I use node:20-slim (Debian-based) for production and node:20-alpine only when I've verified all dependencies work correctly.

Run as a non-root user. Always. Create a dedicated user in your Dockerfile and switch to it before the CMD instruction. This limits the blast radius if your application is compromised. Cloud Run and most Kubernetes configurations enforce this, but your Dockerfile should be explicit about it.

Layer ordering is a performance optimization that pays off daily. Put instructions that change infrequently (installing system packages, copying package.json) before instructions that change often (copying source code). Docker caches layers, so a well-ordered Dockerfile rebuilds in seconds instead of minutes.

Health checks belong in the Dockerfile, not just the orchestrator configuration. A HEALTHCHECK instruction tells Docker how to verify your container is actually serving traffic, not just running. For web services, a simple HTTP check against a /health endpoint is sufficient.

Environment variables should not contain secrets at build time. Use multi-stage builds to separate build-time configuration (NODE_ENV, API URLs) from runtime secrets (database passwords, API keys). Build-time variables use ARG. Runtime configuration uses ENV. Secrets should be injected at runtime by your orchestrator.

One often-missed optimization:dockerignore. Without it, Docker sends your entire project directory (including node_modules.git, and test fixtures) to the build context. A proper .dockerignore reduces build context transfer from gigabytes to megabytes.

Finally, pin your base image versions. FROM node:20 will silently change when a new patch version is released. FROM node:20.11.1-slim is reproducible. I accept minor version updates but pin patch versions, updating them intentionally during maintenance windows.