← Back to DevBytes

Dockerfile Best Practices: Production Guide

Understanding Dockerfile Production Best Practices

A production-grade Dockerfile is not just a script that assembles an image—it is a carefully engineered blueprint that directly impacts security, performance, reproducibility, and deployment velocity. Production best practices are a set of conventions and techniques that transform a naive Dockerfile into a robust, maintainable artifact suitable for running workloads in staging, production, or any environment where reliability matters.

What Is a Production-Optimized Dockerfile?

At its core, a production-optimized Dockerfile applies deliberate patterns to achieve:

Why Production Practices Matter

Ignoring these practices leads to real consequences. A container running as root with a full Ubuntu desktop image becomes a pivot point for lateral movement if compromised. A Dockerfile that dumps secrets into a layer leaks them forever in the image history. Unordered layers invalidate caches on every minor code change, turning a 2-second build into a 2-minute rebuild. In production, these are not minor inconveniences—they are incidents waiting to happen. Adopting production best practices from the start saves debugging time, reduces vulnerability alerts, and aligns your container workflows with modern DevSecOps standards.

Core Principles in Action

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

1. Pin Your Base Image and Use Official Sources

Always specify an explicit tag—never use :latest in production. Floating tags break reproducibility and can silently introduce breaking changes or vulnerabilities.

# ❌ Avoid
FROM node:latest

# ✅ Production-ready
FROM node:20.11.1-alpine@sha256:a1b2c3d4...

Using the digest (@sha256:...) locks the exact image content, providing the highest level of determinism. Official images from trusted registries (Docker Hub, AWS ECR Public Gallery, Chainguard) are maintained and scanned regularly.

2. Leverage Multi-Stage Builds

Multi-stage builds separate the build environment from the runtime environment. You compile or bundle your application in a heavy "builder" stage, then copy only the resulting artifacts into a lean production image. This keeps compilers, SDKs, and temporary files out of the final artifact.

# ===== Builder Stage =====
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server -ldflags="-w -s" ./cmd/server

# ===== Production Stage =====
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
ENTRYPOINT ["./server"]

In this example, the final image contains only the statically-linked binary and CA certificates—no Go toolchain, no source code, no module cache.

3. Order Instructions for Layer Caching

Docker caches layers based on instruction strings and the contents of copied files. Place instructions that change least frequently first, and those that change most frequently last. This ensures that a code change only rebuilds the layer that copies the code, not the layers that install dependencies.

FROM python:3.12-slim

# 1. Install system dependencies (changes rarely)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev gcc && \
    rm -rf /var/lib/apt/lists/*

# 2. Copy dependency definitions (changes occasionally)
WORKDIR /app
COPY requirements.txt .

# 3. Install Python dependencies (cached unless requirements change)
RUN pip install --no-cache-dir -r requirements.txt

# 4. Copy application code (changes frequently)
COPY src/ ./src/

# 5. Runtime configuration
EXPOSE 8000
ENTRYPOINT ["uvicorn", "src.main:app", "--host", "0.0.0.0"]

If you modify a file inside src/, only layer 4 is rebuilt. Layers 1–3 are reused from cache, dramatically accelerating iterative development and CI pipelines.

4. Run as a Non-Root User

Containers share the host kernel. Running as root inside the container means any breakout or vulnerability has root privileges on the host. Create a dedicated user and switch to it after setup.

FROM alpine:3.20
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
ENTRYPOINT ["./my-app"]

For images where you cannot create a user (like distroless), use the numeric user ID that the base image provides, or set USER 65534 (the nobody user).

5. Use HEALTHCHECK for Orchestrator Awareness

A HEALTHCHECK instruction tells Docker and orchestrators (Kubernetes, Docker Swarm) whether your container is truly ready to serve traffic. Without it, the container may be marked as "running" before the application is initialized.

FROM nginx:1.25-alpine
COPY default.conf /etc/nginx/conf.d/
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
  CMD curl -f http://localhost/health || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The health check endpoint should be lightweight—avoid heavy computation or deep database queries inside it.

6. Prefer COPY Over ADD

ADD has extra capabilities (auto-extracting tarballs, fetching remote URLs) that introduce non-determinism and potential security risks. Unless you specifically need tar extraction, use COPY.

# ✅ Simple, explicit, predictable
COPY ./app.tar.gz /tmp/app.tar.gz

# Only use ADD when you need the tar extraction behavior
ADD ./app.tar.gz /app/

7. Use a .dockerignore File

A .dockerignore file prevents sensitive or unnecessary files from being copied into the build context. This reduces image size and prevents accidental inclusion of credentials, local configuration, or IDE artifacts.

# .dockerignore
.git
.gitignore
*.md
.env
.env.*
*.log
node_modules
Dockerfile
.dockerignore
.vscode
.idea
coverage/
tests/
*.test.js

8. Avoid Embedding Secrets in Layers

Every instruction in a Dockerfile creates a layer that persists in the image history. Never pass secrets as build arguments or hardcode them. Use secret mounts (BuildKit) for temporary access during builds.

# ✅ BuildKit secret mount (no layer leakage)
RUN --mount=type=secret,id=github_token \
    export GITHUB_TOKEN=$(cat /run/secrets/github_token) && \
    go build -o app ./main.go

# Build command:
# docker build --secret id=github_token,src=.github_token -t myapp .

For runtime secrets, use orchestration-level secret management (Kubernetes Secrets, Docker secrets, HashiCorp Vault injection) rather than baking them into the image.

9. Minimize the Number of Layers Without Sacrificing Cacheability

Combining related commands reduces layer count while maintaining readability. Use shell chaining and clean up temporary files in the same layer to prevent them from bloating the final image.

FROM ubuntu:22.04
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl=7.81.* \
      ca-certificates \
      && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

Note the version pin on curl=7.81.*—this prevents unexpected major version upgrades that could break your application.

10. Use Distroless or Minimal Base Images Where Possible

For compiled languages (Go, Rust, Java), consider "distroless" images that contain only your application and essential runtime libraries—no shell, no package manager, no utilities. For interpreted languages, use Alpine or slim Debian variants.

# Go application on distroless
FROM gcr.io/distroless/static-debian12:nonroot AS production
COPY --from=builder --chown=nonroot:nonroot /app/server /app/server
WORKDIR /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]

The attack surface is drastically reduced: an attacker who compromises the application cannot spawn a shell or install tools because none exist in the image.

11. Properly Handle Signals and Process Management

Docker sends SIGTERM to PID 1 when stopping a container. If your application runs as a shell script or spawns child processes, PID 1 may not forward signals correctly. Use the exec form of ENTRYPOINT/CMD and avoid wrapping the main process in a shell.

# ✅ Exec form - PID 1 receives signals directly
ENTRYPOINT ["/app/server"]

# ❌ Shell form - PID 1 is /bin/sh, signals may not propagate
ENTRYPOINT /app/server

For containers that require multiple processes, use a dedicated init system like tini or dumb-init as PID 1 to handle signal forwarding and zombie reaping.

FROM alpine:3.20
RUN apk add --no-cache tini
COPY my-app /app/
ENTRYPOINT ["/sbin/tini", "--", "/app/my-app"]

12. Set Metadata Labels for Traceability

Labels help CI/CD systems, auditing tools, and team members understand image provenance. Use the org.opencontainers.image namespace for standardized metadata.

LABEL org.opencontainers.image.title="payment-service" \
      org.opencontainers.image.version="2.4.1" \
      org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.source="https://github.com/org/repo" \
      org.opencontainers.image.revision="${GIT_SHA}"

Combine these with build arguments (--build-arg BUILD_DATE=...) to inject dynamic values at build time without hardcoding them.

Complete Production Dockerfile Example

Here is a fully worked example that synthesizes the practices above into a single Node.js application Dockerfile suitable for production:

# syntax=docker/dockerfile:1

# ===== Builder Stage =====
FROM node:20.11.1-alpine@sha256:abc123... AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
COPY src/ ./src/
COPY tsconfig.json ./
RUN npm run build

# ===== Production Stage =====
FROM node:20.11.1-alpine@sha256:abc123... AS production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN apk --no-cache add tini curl
WORKDIR /app
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist/
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules/
COPY package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1
ENTRYPOINT ["/sbin/tini", "--", "node", "dist/index.js"]

This Dockerfile demonstrates pinned versions, multi-stage builds, layer caching optimization, non-root user creation, a proper init system (tini), a health check, and minimal final image footprint. The builder stage compiles TypeScript, while the production stage carries only the compiled JavaScript and production dependencies.

Build-Time and CI Integration Tips

Use BuildKit for Advanced Features

Enable BuildKit (the default in recent Docker versions) to unlock secret mounts, cache mounts for package managers, and parallelized multi-stage builds.

# Example: cache mount for npm (persists between builds)
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

Cache mounts prevent re-downloading unchanged packages, further accelerating builds in CI environments.

Scan Images Before Deployment

Integrate vulnerability scanning into your pipeline. Tools like Trivy, Grype, or Docker Scout can scan the final image for known CVEs.

# Example pipeline step
trivy image --severity HIGH,CRITICAL my-app:production
if [ $? -ne 0 ]; then
  echo "Critical vulnerabilities found—blocking deployment"
  exit 1
fi

Keep Build Context Small

The build context is everything sent to the Docker daemon. Use .dockerignore aggressively and avoid running docker build from directories containing gigabytes of unrelated data. The smaller the context, the faster the build starts.

Common Pitfalls to Avoid

Conclusion

Production Dockerfile best practices transform container images from quick-and-dirty prototypes into secure, efficient, and maintainable deployment artifacts. By pinning base images, leveraging multi-stage builds, ordering instructions intelligently, running as non-root users, and integrating health checks, you build containers that are faster to ship, cheaper to store, and safer to run. These practices compound in value: a small, well-structured image reduces vulnerability noise, accelerates CI pipelines, and behaves predictably under orchestration. The investment in learning and applying these patterns pays dividends across the entire software delivery lifecycle—from the first local build to the thousandth production deployment.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles