Production Grade #
A production-grade Dockerfile isn’t just a file that can produce a runnable image. It’s the foundation of the application’s security, performance, stability, and operational efficiency in real environments — CI/CD pipelines, container registries, orchestrators (Kubernetes, ECS, Nomad), all the way to observability and incident response. A carelessly written Dockerfile might work on a laptop, but in production it becomes a source of preventable incidents.
This article is not tied to a specific programming language. Every language (Go, Java, Rust, Node.js, Python, Ruby, PHP) has its own characteristics and tooling, and you already have separate articles for each. This article’s focus is the universal principles you must understand before touching any language’s Dockerfile: what must be present, why it matters, and what happens if you ignore it.
What Does “Production Grade” Mean? #
A production-grade Dockerfile is one that satisfies five criteria at once.
Secure by default. The image stores no secrets, doesn’t run as root, uses trusted base images, and minimizes the attack surface. Security isn’t an afterthought — it’s a prerequisite.
Cost-efficient. The image is as small as possible, layers as small as possible, and the build cache as optimal as possible. Every extra MB means higher registry, bandwidth, and cold-start costs.
Deterministic. The same build, with the same inputs, must produce the same image. No “it’s different on my machine”, no dependence on uncontrolled latest tags.
Easy to operate. The image has a healthcheck, logs to STDOUT, handles signals correctly, and is configured through environment variables. Operators don’t need to SSH into containers to debug.
Ready to orchestrate. The image runs on Kubernetes or other orchestrators without modification. It’s stateless, immutable, and follows the platform’s operational contract.
Production grade isn’t about writing a Dockerfile that’s “good enough to run”. It’s about writing a Dockerfile that is safe, stable, and cheap to run long-term — not just at the first deployment, but in year two, three, and beyond.
Small Images: A Feature, Not an Optimization #
Image size is often treated as an “optimization” problem to address later. But small images are a fundamental feature affecting many other things.
CI build time. A CI pipeline building a 1 GB image will be slower than one building a 100 MB image. This is a recurring cost on every commit.
Pull time. In Kubernetes or any cluster, new nodes must pull images from the registry. Large images = slow autoscaling = slow incident response.
Registry and bandwidth costs. Large images = higher storage and transfer costs. Small per image, but significant at production scale.
Cold starts. For serverless containers (AWS Fargate, Cloud Run, Lambda containers) or aggressive autoscaling, large images = slow cold starts = poor user experience.
Attack surface. Large images usually carry more packages, libraries, and tools. Every extra package is a potential vulnerability. Small images = fewer CVEs to track.
flowchart LR
A[Large Image] --> B[Slow Pulls]
A --> C[Slow Cold Starts]
A --> D[Wide Attack Surface]
A --> E[Higher Registry Costs]
A --> F[Less Efficient Build Cache]
B & C & D & E & F --> G[Expensive Operations]Important: A large image isn’t just “wasteful” — it also enlarges the blast radius when a vulnerability hits. If the base image you use has a CVE, a small image with tight dependency auditing will be patched faster than a large image still carrying many unused “legacy” packages.
The principle to hold on to: every extra MB is a risk, and every unused dependency is an undiscovered bug.
Multi-Stage Builds: A Mandatory Standard #
Multi-stage builds aren’t an optimization trick — they’re a separation of concerns that’s become the mandatory standard in production Dockerfiles.
The concept: separate the build environment from the runtime environment. The first stage has compilers, package managers, and build tools. The second stage (which becomes the final image) only has the artifacts needed at runtime.
# Stage 1: build with a compiler
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o app
# Stage 2: runtime without a compiler
FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/app /app
CMD ["/app"]
Why is it mandatory? Because without multi-stage, the runtime image carries the compiler, source code, and build cache. This increases image size, widens the attack surface, and makes images slower to deploy.
Rule of thumb: if your Dockerfile only has one stage, that’s a red flag. It’s not always wrong, but there’s almost always room for improvement.
Multi-stage build benefits:
- Much smaller runtime images — compilers and build tools don’t come along.
- No compilers in production — reduces supply-chain attack risk.
- Clear boundaries — the difference between “needed for build” and “needed for runtime” is explicit in the Dockerfile.
- Easier cross-compilation — the build stage can target a different platform than the runtime stage.
Non-Root Users: Default, Not Optional #
By default, containers run as root (UID 0). That’s dangerous. If an attacker manages a container escape, they get root access on the host. The higher the privilege inside the container, the bigger the impact.
// ✗ Anti-pattern: container runs as root
FROM alpine
COPY app /app
CMD ["/app"]
// ✓ Correct: container runs as a non-root user
FROM alpine
RUN adduser -D -u 1001 appuser
COPY --chown=appuser:appuser app /app
USER appuser
CMD ["/app"]
Non-root user benefits:
- Reduces the impact of container escapes — attackers don’t immediately get host root.
- Compliance — many security standards (PCI-DSS, SOC2) require non-root containers.
- Least privilege — containers only have the permissions needed.
- Safer volume mounts — files are mounted with the container user’s permissions, not root’s.
Best practices:
- Create an explicit user with a fixed UID (>= 1000) for consistency.
- Use
--chownonCOPYso files immediately belong to the correct user. - For distroless images, use the built-in
nonroot:nonrootuser. - Avoid random UIDs — they can cause permission problems on shared volumes.
Minimal, Trusted Base Images #
The base image determines your image’s foundation. It determines the security posture, size, and implicit dependencies you’ll carry.
Selection principles:
- Use official images from trusted publishers. Docker Official Images, Distroless, or images from CNCF projects.
- Avoid random images from unclear publishers, especially “all-in-one” images carrying everything.
- Choose the right variant — alpine, slim, or distroless, depending on needs.
- Pin specific versions —
golang:1.22.5-alpine3.19, notgolang:latest.
flowchart TD
A[Need a shell for debugging?] -->|Yes| B[Alpine / Slim]
A -->|No| C[Distroless / Scratch]
B --> D[Trade-off: Size vs Observability]
C --> E[Trade-off: Security vs Debug Convenience]
D --> F[Default for Common Applications]
E --> G[Default for High-Security / Mature Production]Distroless images (gcr.io/distroless/*) are the best choice for production. These images carry only the runtime the application needs (e.g. glibc, CA certs, Java JRE) with no shell, no package manager, no OS utilities. Minimal attack surface, small size, and you’re forced to rely on proper logging/observability.
The distroless trade-off: you can’t docker exec -it container sh for interactive debugging. You must rely on logs, metrics, and tracing. That’s a feature, not a bug — it enforces observability discipline.
Deterministic and Reproducible Builds #
Production Dockerfiles must be deterministic: the same input produces the same output. There’s no room for “but the build worked yesterday”.
Anti-patterns:
# Tags without versions: can change at any time
FROM node:latest
FROM ubuntu:latest
FROM python:3
# Installing without pinned versions
RUN apt-get install -y curl
RUN pip install flask
Best practices:
# Explicit tags with patch versions
FROM node:20.11.1-alpine
FROM ubuntu:24.04
FROM python:3.12.3-slim
# Installing with explicit versions
RUN apt-get install -y curl=7.81.0-1ubuntu1
RUN pip install flask==3.0.3
Why does it matter?
- Security audits — you can inspect a built image and identify CVEs based on exact dependency versions.
- Rollbacks — rebuilding the same version produces the exact same image.
- Compliance — some standards (FedRAMP, ISO 27001) require reproducibility.
- Debugging — production incidents can be reproduced in development environments.
Supporting tools:
pip freeze/pip-compilefor Python.npm ci(notnpm install) for Node.js.go.sumfor Go.Cargo.lockfor Rust.composer.lockfor PHP.Gemfile.lockfor Ruby.
Note: A lock file alone isn’t enough. You must also make sure the base image has an explicit tag. An image built fromFROM node:20and one fromFROM node:20.11.1will differ oncenode:20resolves to a newer patch version.
Intentional Layering #
Each Dockerfile instruction produces one layer. Unintentional layers pile up and bloat the image.
The principle:
- Rarely-changing things on top — base image, OS dependencies.
- Application dependencies in the middle — package manager installs.
- Source code at the bottom — the application code that changes most often.
// ✓ Order that maximizes caching
FROM node:20-alpine # Layer 1: base image (rarely changes)
WORKDIR /app # Layer 2: structure (rarely changes)
RUN apk add --no-cache curl # Layer 3: OS deps (rarely change)
COPY package*.json ./ # Layer 4: dependency file (rarely changes)
RUN npm install # Layer 5: dependencies (rarely change)
COPY . . # Layer 6: source code (often changes)
CMD ["npm", "start"] # Layer 7: entrypoint (rarely changes)
With this order, when you change source code (Layer 6), Docker only needs to rebuild Layer 6 and beyond. Layers 1-5 are reused from cache. Builds become much faster.
The Dockerfile-as-build-graph principle: a Dockerfile isn’t a linear script executed in sequence without caring about output. It’s a dependency graph that can be optimized. Every instruction is a node, and Docker decides which nodes can be skipped from cache.
Configuration via Environment, Not Hardcoding #
Production containers must be stateless and portable across environments. The way to achieve this: configuration via environment variables, not hardcoded in the image.
// ✗ Anti-pattern: hardcoded configuration
ENV DB_HOST=production-db.example.com
ENV LOG_LEVEL=info
// ✓ Correct: default values, overridden at runtime
ENV DB_HOST=localhost
ENV LOG_LEVEL=info
Runtime override:
docker run -e DB_HOST=staging-db -e LOG_LEVEL=debug myapp
The principle: different images for dev, staging, and production are an architecture smell. One image should run in every environment with different configuration.
What belongs in environment variables:
- Database host, port, credentials.
- API endpoints.
- Log levels.
- Feature flags.
- Cache TTLs.
- Listening ports.
What must NOT be in the image:
- Credentials (passwords, API keys, tokens).
- Environment-specific URLs.
- Always-on debug flags.
- Configuration files that differ per environment.
Build-time vs runtime secrets:
- Build-time secrets — use BuildKit secret mounts (
--mount=type=secret). Available during the build, not stored in the image. - Runtime secrets — mounted from the orchestrator (Kubernetes secrets, Docker secrets, Vault) or env variables from a secret manager.
Logging to STDOUT/STDERR #
Production containers must not write logs to local files. Why?
- Log files in containers disappear when the container restarts (immutable filesystem).
- Log files in containers are hard to access from outside.
- Orchestrators already have mechanisms for capturing logs from STDOUT/STDERR.
// ✗ Anti-pattern: logging to a file
CMD ["./app", "--log-file=/var/log/app.log"]
// ✓ Correct: logging to STDOUT/STDERR (default)
CMD ["./app"]
Correctly written applications write logs to STDOUT/STDERR by default. Some frameworks need explicit configuration:
- Java — set logback to
STDOUT. - Python — use
loggingwithStreamHandler(sys.stdout). - Node.js —
console.logandconsole.erroralready go to STDOUT/STDERR. - Go — log to
os.Stdoutandos.Stderr.
Good log formats:
- Plain text for development (human-readable).
- JSON structured logs for production (easy for log aggregators to parse).
Tip: Follow the 12-factor app methodology for logging. Applications shouldn’t manage their own log files — let the orchestrator collect logs from STDOUT/STDERR.
Healthchecks: The Operational Contract #
HEALTHCHECK is how an image communicates its condition to the orchestrator. Without a healthcheck, the orchestrator only knows the container is “running” (the process exists), not “healthy” (ready to serve requests).
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Principles of a good healthcheck:
- Fast — healthchecks can run every 10-30 seconds, and must finish in 1-3 seconds.
- Represents readiness — check that the application is truly ready to serve requests, not just “process alive”.
- Not heavy — don’t query large databases or hit expensive endpoints.
- Deterministic — output must be consistent: healthy or unhealthy, never flaky.
For distroless images (no curl/wget):
- Build a healthcheck checked from inside the application.
- Or use a TCP-only check (check whether the port can be connected).
- Or use orchestrator-native probes (Kubernetes liveness/readiness probes, separate from Docker HEALTHCHECK).
Three conditions to distinguish:
- Liveness — should the container be restarted? Checks whether the process still works.
- Readiness — is the container ready to receive traffic? Checks whether dependencies are ready.
- Startup — has the application finished initializing? Checks whether first-start is complete.
A Dockerfile HEALTHCHECK is usually for simple liveness/readiness. For more granular control, use orchestrator probes.
Graceful Shutdown and Signal Handling #
Production containers must shut down cleanly when receiving SIGTERM (the default signal from orchestrators during scaling down or restarts). Messy shutdowns cause:
- In-flight requests cut off suddenly.
- Database connections left open.
- Half-finished transactions.
- File locks never released.
// ✗ Anti-pattern: shell form swallows signals
CMD npm start
# npm becomes a child of sh; SIGTERM never reaches npm
// ✓ Correct: exec form, signals reach the main process
CMD ["npm", "start"]
Signal handling principles:
- Exec form in
CMD/ENTRYPOINT— make sure the main process is PID 1, so signals arrive directly. - Trap signals in the application — apps must handle SIGTERM properly (close connections, flush logs, exit with code 0).
- Grace period — orchestrators usually give 30 seconds before SIGKILL. Applications must finish shutting down within that time.
Common implementations:
- Node.js — handle
SIGTERMwithprocess.on('SIGTERM', ...). - Go — the default is already correct, but make sure
deferis used for cleanup. - Java — add a JVM shutdown hook.
- Python — handle
SIGTERMwithsignal.signal(signal.SIGTERM, ...). - Rust — handle
SIGTERMwith a signal handler crate.
A good HEALTHCHECK also helps — during shutdown, orchestrators usually send SIGTERM first, wait, then SIGKILL. Applications should stop accepting new requests (readiness fails), then shut down cleanly.
Security Scanning and Vulnerability Awareness #
Production images must be scanned regularly for vulnerabilities. An image that’s never scanned is an image waiting to be exploited.
Common tools:
- Trivy — open source, supports many image formats.
- Snyk — commercial, with a broad CVE database.
- Docker Scout — built into Docker Hub and Docker Desktop.
- Grype — open source from Anchore.
- Clair — open source from Red Hat.
CI/CD integration:
# GitHub Actions example
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
run: trivy image --severity HIGH,CRITICAL myapp:${{ github.sha }}
- name: Fail if critical
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
What gets scanned:
- OS packages — CVEs in
apt,apk,yumpackages. - Application dependencies — CVEs in
pip,npm,gem,composerpackages. - Base images — make sure the base image you chose is also up to date.
The mindset: an image isn’t just a build result, it’s a deployable artifact with its own security posture. It must be tracked, scanned, and patched like any other software artifact.
The Right Image for Orchestrators #
Orchestrators (Kubernetes, ECS, Nomad) have conventions that must be followed for images to run well.
Expected patterns:
- Stateless — no local state, all configuration via env.
- Idempotent start — restarted containers must work immediately, without manual setup.
- Horizontally scalable — N containers must run without conflicts.
- Graceful shutdown — handle SIGTERM properly.
- Log to STDOUT — so the orchestrator can collect logs.
- Healthcheck — so the orchestrator knows when containers are ready.
Anti-patterns:
- Running many processes in one container — orchestrators manage processes, not services. One container = one main process.
- Using a supervisor — traditional process managers don’t fit containers. The orchestrator already is the process manager.
- Treating containers like VMs — installing many services, storing state on the filesystem. That’s the old model, and it must be abandoned.
The principle: containers are application units, not server units. The orchestrator manages their lifecycle. Images must be designed for this model.
Anti-Patterns to Avoid #
Some of the most common production Dockerfile mistakes:
| Anti-Pattern | Impact | Solution |
|---|---|---|
latest base image | Non-deterministic builds | Pin explicit tags |
| Secrets in images | Credential leaks | Inject at runtime |
| Containers running as root | Privilege escalation risk | Non-root user |
| Single build stage | Image carries compilers | Multi-stage builds |
No .dockerignore | Bloated context | Filter with .dockerignore |
| Logging to files | Logs lost on restart | Log to STDOUT |
| Shell form in CMD/ENTRYPOINT | Broken signal handling | Exec form |
| No healthcheck | Blind orchestrator | HEALTHCHECK in the image or probes in the orchestrator |
| Tagging without a strategy | Hard rollbacks | Semantic versioning or commit hashes |
| No scanning | Undetected CVEs | Scan in CI/CD |
What all these anti-patterns share: they work at the start and hurt later. Everything looks “good enough to run” until production, until an incident, until a security audit.
Summary #
- Production grade isn’t about syntax — it’s a way of thinking: secure, efficient, deterministic, easy to operate, and ready to orchestrate.
- Small images are a feature, not an optimization. Every extra MB means cost, time, and attack surface.
- Multi-stage builds are the mandatory standard for separating the build environment from the runtime environment.
- Non-root users must be the default, not optional. Create an explicit user with a fixed UID.
- Base images must be minimal and trusted. Distroless is the best choice for mature production.
- Deterministic builds are mandatory: pin base image versions, lock dependencies, avoid
latest.- Configuration via environment, not hardcoding. Credentials must not be in images.
- Log to STDOUT/STDERR, not files. Let the orchestrator collect logs.
- Healthchecks are the operational contract with the orchestrator. Without one, the orchestrator is blind.
- Signal handling must be correct: exec form in CMD/ENTRYPOINT, handle SIGTERM in the application.
- Scan for vulnerabilities in CI/CD for every image about to be deployed.