Docker Best Practices for Production Containers

Photo by Unsplash

Photo by Unsplash
Docker containerization transformed how we build and ship software, but writing a production-ready Dockerfile requires more than a simple 'COPY . .' and 'RUN npm install'. In production, image size, startup time, security surface, and observability all matter. This guide covers the Docker best practices that separate a throwaway dev container from a hardened, optimized production image — including multi-stage builds, non-root users, layer caching, health checks, and secrets handling.
Multi-stage builds let you use a heavyweight build environment (Node, Go, Maven) to compile your application, then copy only the compiled artifact into a minimal runtime image. A Next.js app that would weigh 1.2GB in a single-stage build can be reduced to under 150MB with a proper multi-stage Dockerfile. Smaller images transfer faster, start faster, and expose a smaller attack surface.
Each FROM instruction starts a new build stage. You name stages with AS and reference them in COPY --from= instructions. The final stage becomes the actual image that is pushed to the registry — all intermediate stages exist only during the build and are discarded. This means your final image has no build toolchain, no dev dependencies, and no source code.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Production image
FROM node:20-alpine AS runner
WORKDIR /app
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy only built artifacts
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Drop privileges
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV NODE_ENV production
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]Always prefer Alpine-based images (node:20-alpine, python:3.12-alpine) for the runtime stage — they are typically 5-10x smaller than Debian/Ubuntu equivalents. For the build stage, use whatever is needed. For Go applications, the runtime image can be 'scratch' (literally empty) since Go compiles to a single static binary. Always pin to a specific digest or at minimum a patch version tag rather than ':latest' to ensure reproducible builds.
Run 'docker image inspect <image>' to see the full list of layers and their sizes. Use 'docker scout cves <image>' or 'trivy image <image>' to scan for known CVEs before pushing to production. Most CI pipelines should gate on a 'no CRITICAL vulnerabilities' policy.
By default Docker containers run as root (UID 0). If an attacker exploits a vulnerability in your application code, they gain root access inside the container and potentially on the host if a container escape is possible. Creating a dedicated system user with a fixed UID and switching to it with the USER instruction is one of the highest-value security changes you can make.
When you COPY files into the image and then switch to a non-root user, ensure that user has read access to the application files. Use 'COPY --chown=nextjs:nodejs' to set ownership in the same step as the copy, avoiding an extra RUN chown layer that would double the layer size. The combined COPY --chown approach is also more readable and produces a leaner image.
Start containers with '--read-only' and mount specific writable volumes only where the application needs to write (uploads, temp files, logs). A read-only root filesystem prevents an attacker who gains code execution from modifying application binaries or dropping persistent malware. Pair this with '--no-new-privileges' to prevent privilege escalation via setuid binaries.
Even if you use a multi-stage build and the secret is only in an intermediate layer, secrets baked into intermediate layers can be extracted with 'docker save' and layer inspection tools. Use Docker BuildKit's '--secret' flag to mount secrets as a temporary file during build, or pass secrets at runtime via environment variables from a secrets manager (Vault, AWS SSM). Scan your images with 'trufflesecurity/trufflehog' before pushing.
Docker rebuilds every layer below the first changed line in your Dockerfile. Optimizing layer order for cache hit rate is the fastest way to speed up development and CI build times. The golden rule: copy files that change rarely first, copy files that change often last.
For a Node.js application: copy package.json and package-lock.json first, run npm ci, then copy the rest of the source code. This way the expensive npm install layer is only invalidated when dependencies change, not every time you edit a source file. The same principle applies to Go (go.mod before source), Python (requirements.txt before code), and Ruby (Gemfile before code).
A missing or incomplete .dockerignore file sends your entire working directory — including node_modules, .git, test data, and local secrets — as the build context to the Docker daemon. This slows the build and risks leaking sensitive files into the image. Your .dockerignore should at minimum exclude: node_modules, .git, .env*, *.log, and any local override files.
A HEALTHCHECK instruction tells Docker and orchestrators like Kubernetes when a container is truly ready to serve traffic, not just started. Combined with proper signal handling for graceful shutdown, health checks prevent your orchestrator from routing traffic to a container that is starting up or draining connections.
The HEALTHCHECK instruction specifies a command, an interval, a timeout, and a retries count. Using 'wget -qO-' or 'curl -sf' against your application's /health or /readyz endpoint is idiomatic. Keep the health endpoint lightweight — it should check that the application is ready (database connection alive, caches warmed) but return in milliseconds, not seconds.
In Kubernetes, the HEALTHCHECK instruction is ignored in favor of liveness and readiness probes defined in the Pod spec. However, keep HEALTHCHECK in your Dockerfile for compatibility with Docker Compose and bare Docker Swarm deployments. The two approaches use the same endpoint but different configuration locations.
Tags are mutable by default in Docker registries — ':latest' can point to a different image tomorrow than today. Production deployments should always reference an immutable identifier: the full image digest (sha256:...) or a tag that includes the git commit SHA. This makes deployments reproducible and auditable.
A sensible tagging strategy publishes three tags per release: the full semantic version (1.4.2), the minor version (1.4), and the SHA (sha-abc1234). CI pipelines use the SHA tag for deployment to ensure exact reproducibility. Human operators and Dependabot use the semver tags. The 'latest' tag is useful for local development but should never be used in production manifests.
Essential Docker terminology for production work: multi-stage build, Alpine base image, layer caching, health check, build context, and least privilege.