Dockerfile Best Practices: Build Lean, Secure Containers That Survive Production
Dockerfile best practices for production: multi-stage builds, layer caching, security, and debugging.
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
- ✓Basic Dockerfile syntax (FROM, RUN, COPY, CMD)
- ✓Familiarity with Docker build and run commands
- ✓Understanding of Linux file permissions and process management
Use multi-stage builds to separate build and runtime dependencies. Order layers from least to most frequently changing to maximize cache reuse. Run as a non-root user. Use specific base image tags. Combine RUN commands to reduce layers. Use .dockerignore to exclude unnecessary files.
Think of a Dockerfile like a recipe for a frozen dinner. Each instruction is a step: unpack ingredients, cook, assemble, package. If you change one ingredient, you want to redo only the steps after that — not start from scratch. That's layer caching. And if you can cook the meal in a factory (build stage) and just freeze the final dish (runtime stage), you save tons of space. That's multi-stage builds.
I've seen a 2GB container crash a Kubernetes node because someone COPY'd the entire node_modules folder into production. Don't be that person. Most Dockerfiles I review are bloated, insecure, and rebuild like molasses. The problem isn't Docker — it's how we write the recipe. After this article, you'll be able to write Dockerfiles that build fast, run securely, and ship images under 100MB. No fluff. Just patterns that survived production.
Layer Ordering: Why Your Builds Take Forever
Every RUN, COPY, and ADD creates a layer. Docker caches each layer. If a layer changes, all subsequent layers rebuild. The classic mistake: putting COPY . early, which invalidates the cache for every subsequent RUN. Fix: order layers from least to most frequently changing. Start with OS packages, then language-specific dependencies, then application code. This way, you only rebuild the slow parts when dependencies change, not on every code commit.
COPY . /app before RUN npm install, every code change triggers a full npm install. For a large project, that's 5+ minutes per build. I've seen CI pipelines take 20 minutes because of this.Multi-Stage Builds: Drop the Build Tools
Why ship a compiler to production? Multi-stage builds let you use one image to compile and a second, minimal image to run. The classic use case: Go or Rust binaries where you compile in a full SDK image, then copy the binary to alpine. For interpreted languages, you can still separate dev dependencies from runtime. Example: Python — install all deps in build stage, then copy only the site-packages you need.
pip install --user in build stage and copy only ~/.local/lib/python3.x/site-packages to runtime. This drops dev dependencies and pip itself.Security: Stop Running as Root
By default, containers run as root. If an attacker exploits your app, they have root inside the container. With host PID or volume mounts, they can escape. Always create a non-root user. Use adduser (Debian) or adduser (Alpine). Set USER before CMD. Also: use specific base image tags (not latest), scan images for vulnerabilities, and avoid installing unnecessary packages.
USER 1000 without creating the user first. If the UID doesn't exist, the container will run as root anyway. Always create the user with adduser.Layer Caching: The Art of Not Rebuilding
Docker caches each layer. To maximize cache hits: separate dependency installation from code copy. Use COPY package.json and RUN npm install before COPY .. For apt-get, combine updates and installs in one RUN to avoid stale cache. Use --no-install-recommends to reduce size. For Python, use pip install --no-cache-dir.
apt-get update in one layer and apt-get install in another, the update layer may be cached while the install layer uses stale package lists. Always combine update and install in one RUN.Minimizing Image Size: Every Megabyte Counts
Large images slow down deployments and increase attack surface. Use alpine variants where possible. Remove package manager caches. Combine RUN commands. Use --no-cache for apk. For Python, use pip install --no-cache-dir. For Node, use npm ci --only=production. Use .dockerignore to exclude logs, tests, and CI files.
docker history --no-trunc <image> to see the size of each layer. The largest layers are usually dependency installs. Optimize those first.Using .dockerignore: Stop Leaking Secrets
Without .dockerignore, every file in the build context gets sent to the Docker daemon. This includes .env files, .git, node_modules, and CI configs. This slows builds and leaks secrets. Always create a .dockerignore file. At minimum, exclude node_modules, .git, .env, and any large data directories.
Health Checks: Let Orchestrators Know When You're Dead
Kubernetes and Docker Swarm use health checks to restart unhealthy containers. Without them, your app can be in a broken state but still receive traffic. Add HEALTHCHECK instruction. Use a simple command like curl --fail http://localhost:8080/health || exit 1. Make sure the health endpoint is lightweight and doesn't depend on external services.
ENTRYPOINT vs CMD: Know the Difference
ENTRYPOINT defines the executable. CMD provides default arguments. If you only use CMD, users can override the entire command with docker run myimage bash. If you use ENTRYPOINT, they can only pass arguments. Best practice: use ENTRYPOINT for the binary and CMD for default flags. This makes your image behave like a CLI tool.
When Not to Use Multi-Stage Builds
Multi-stage builds add complexity. For small scripts or single-file apps, a single-stage build with a slim base image is fine. If your build stage is trivial (e.g., just copying files), multi-stage adds no benefit. Also, if you need debugging tools in production (like curl, vim), you might prefer a single stage with those tools. But consider using a debug sidecar instead.
Production Debugging: What to Do When Your Container Fails
Containers fail. Common issues: missing dependencies, wrong permissions, config errors. Use docker logs <container> to see stderr. Use docker exec -it <container> sh to inspect. For images, use docker run --entrypoint sh myimage to override entrypoint. Check layer sizes with docker history. Use docker image inspect to see env vars and cmd.
docker run --entrypoint to debug, you bypass HEALTHCHECK and any init process. Use it only for debugging, not in production scripts.The 4GB Container That Kept Dying
- Always use .dockerignore and multi-stage builds.
- Your image is a delivery artifact, not a development environment.
exec: "npm": executable file not founddocker run --entrypoint sh myimage -c 'which npm'. 2. Verify base image includes Node. 3. Ensure RUN npm install completed successfully.docker exec mycontainer whoami. 2. If root, add USER nonroot. 3. If nonroot, chown /app to that user in Dockerfile.docker history myimage to find large layers. 2. Check for accidental COPY of node_modules or .git. 3. Add .dockerignore. 4. Consider multi-stage build.docker run --entrypoint sh myimage -c 'which npm'docker run --entrypoint sh myimage -c 'ls /usr/local/bin/'RUN which npm to Dockerfile to verify install. Use correct base image.| File | Command / Code | Purpose |
|---|---|---|
| Dockerfile | FROM node:18-alpine | Layer Ordering |
| Dockerfile | FROM golang:1.20 AS builder | Multi-Stage Builds |
| Dockerfile | FROM python:3.11-slim | Layer Caching |
| .dockerignore | .git | Using .dockerignore |
| Dockerfile | FROM alpine:3.18 | ENTRYPOINT vs CMD |
| Dockerfile | FROM nginx:alpine | When Not to Use Multi-Stage Builds |
| debug.sh | docker logs mycontainer | Production Debugging |
Key takeaways
Interview Questions on This Topic
How does Docker layer caching work, and what happens when you COPY . before RUN npm install?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.
That's Docker. Mark it forged?
3 min read · try the examples if you haven't