Home DevOps Dockerfile Best Practices: Build Lean, Secure Containers That Survive Production
Intermediate 3 min · July 11, 2026

Dockerfile Best Practices: Build Lean, Secure Containers That Survive Production

Dockerfile best practices for production: multi-stage builds, layer caching, security, and debugging.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Production
production tested
July 11, 2026
last updated
258
articles · all by Naren
Before you start⏱ 30 min
  • Basic Dockerfile syntax (FROM, RUN, COPY, CMD)
  • Familiarity with Docker build and run commands
  • Understanding of Linux file permissions and process management
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

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.

✦ Definition~90s read
What is Dockerfile?

Dockerfile best practices are a set of conventions and patterns for writing Dockerfiles that produce small, secure, and efficient container images. They cover layer ordering, dependency management, security hardening, and build optimization.

Think of a Dockerfile like a recipe for a frozen dinner.
Plain-English First

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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# io.thecodeforge — DevOps tutorial

# BAD: COPY . early invalidates cache
FROM node:18-alpine
WORKDIR /app
COPY . .  # changes on every commit
RUN npm install  # rebuilds every time

# GOOD: order by change frequency
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install  # cached unless deps change
COPY . .  # only app code changes
Output
Build output showing cached layers for package install and re-run for COPY.
Production Trap:
If you use 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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# io.thecodeforge — DevOps tutorial

# Build stage
FROM golang:1.20 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/myapp .

# Runtime stage
FROM alpine:3.18
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/myapp /usr/local/bin/myapp
USER appuser
CMD ["myapp"]
Output
Final image size: ~15MB vs 800MB if using golang:1.20 directly.
Senior Shortcut:
For interpreted languages like Python, use 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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
# io.thecodeforge — DevOps tutorial

FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Output
Container runs as appuser. `whoami` inside container returns 'appuser'.
Never Do This:
Don't use 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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
# io.thecodeforge — DevOps tutorial

FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Output
apt-get layer is cached unless base image changes. pip layer cached unless requirements.txt changes.
The Classic Bug:
If you run 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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
# io.thecodeforge — DevOps tutorial

FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
CMD ["node", "index.js"]
Output
Image size: ~120MB vs 350MB if using node:18 full image and npm install.
Senior Shortcut:
Use 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.

.dockerignoreDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
# io.thecodeforge — DevOps tutorial

.git
node_modules
.env
*.log
test
coverage
.gitignore
Dockerfile
.dockerignore
Output
Build context size drops from 500MB to 10MB.
Production Trap:
I once saw a .env file with production AWS keys baked into an image. The image was pushed to a public registry. The keys were rotated within minutes, but the damage was done. Always .dockerignore .env files.

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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
# io.thecodeforge — DevOps tutorial

FROM node:18-alpine
# ... app setup ...
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
Output
Docker will mark container as unhealthy if health check fails 3 times.
Interview Gold:
What happens if HEALTHCHECK is not defined? The orchestrator only knows the process is alive (PID exists), not that the app is responding. A deadlocked process still has a PID.

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.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
# io.thecodeforge — DevOps tutorial

FROM alpine:3.18
RUN apk add --no-cache curl
ENTRYPOINT ["curl"]
CMD ["--help"]
# Usage: docker run myimage https://example.com
Output
Running `docker run myimage` shows curl help. Running `docker run myimage https://example.com` fetches the URL.
Senior Shortcut:
For app containers, use ENTRYPOINT with a shell script that does setup (like waiting for DB) then exec the app. This keeps the container PID 1 and handles signals properly.

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.

DockerfileDOCKERFILE
1
2
3
4
5
6
# io.thecodeforge — DevOps tutorial

# Simple static file server — single stage is fine
FROM nginx:alpine
COPY static/ /usr/share/nginx/html/
# No need for multi-stage here
Output
Image size ~25MB. Multi-stage would add complexity with no size reduction.
When to Skip:
If your base image is already small (alpine, distroless) and you don't compile anything, multi-stage is overkill. Use it when you need build tools that would bloat the runtime image.

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.

debug.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# io.thecodeforge — DevOps tutorial

# Check logs
docker logs mycontainer

# Interactive shell
docker exec -it mycontainer sh

# Override entrypoint to debug
docker run --entrypoint sh myimage

# See layer sizes
docker history --no-trunc myimage

# Inspect image config
docker image inspect myimage | jq '.[0].Config'
Output
Commands output logs, shell prompt, layer sizes, and JSON config.
Production Trap:
If you use docker run --entrypoint to debug, you bypass HEALTHCHECK and any init process. Use it only for debugging, not in production scripts.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A Node.js microservice crashed with OOMKilled every 2 hours on Kubernetes. Image size was 4.2GB.
Assumption
Memory leak in the application code.
Root cause
The Dockerfile COPY'd the entire project directory including node_modules (already installed on host) and then ran npm install again, doubling dependencies. Also included .git, test fixtures, and CI configs. The image contained 2 copies of node_modules.
Fix
Added .dockerignore excluding node_modules, .git, tests. Switched to multi-stage build: one stage for npm install, second stage COPY --from=build only the production node_modules and app code. Image shrunk to 180MB.
Key lesson
  • Always use .dockerignore and multi-stage builds.
  • Your image is a delivery artifact, not a development environment.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Container exits immediately with exec: "npm": executable file not found
Fix
1. Check if npm is installed in the image: docker run --entrypoint sh myimage -c 'which npm'. 2. Verify base image includes Node. 3. Ensure RUN npm install completed successfully.
Symptom · 02
Permission denied when writing to /app
Fix
1. Check user: docker exec mycontainer whoami. 2. If root, add USER nonroot. 3. If nonroot, chown /app to that user in Dockerfile.
Symptom · 03
Image size unexpectedly large
Fix
1. Run 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.
★ Dockerfile Best Practices Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Container crashes on start: `exec: "npm": executable file not found`
Immediate action
Check if npm is in the image
Commands
docker run --entrypoint sh myimage -c 'which npm'
docker run --entrypoint sh myimage -c 'ls /usr/local/bin/'
Fix now
Add RUN which npm to Dockerfile to verify install. Use correct base image.
Permission denied writing to /app+
Immediate action
Check running user
Commands
docker exec mycontainer whoami
docker exec mycontainer ls -la /app
Fix now
Add USER appuser after creating user and chown /app.
Build takes >10 minutes+
Immediate action
Check cache invalidation
Commands
docker build --no-cache .
docker history myimage
Fix now
Reorder layers: COPY package.json first, then RUN install, then COPY .
Image size >1GB+
Immediate action
Find large layers
Commands
docker history --no-trunc myimage
docker image inspect myimage | jq '.[0].RootFS'
Fix now
Add .dockerignore, use multi-stage build, use slim base images.
AspectSingle-Stage BuildMulti-Stage Build
Image sizeLarge (includes build tools)Small (only runtime deps)
Build complexitySimpleModerate (multiple FROM)
Cache efficiencyGood with proper orderingBetter (separate stages)
SecurityMore attack surfaceLess attack surface
DebuggingEasier (tools available)Harder (no build tools)
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
DockerfileFROM node:18-alpineLayer Ordering
DockerfileFROM golang:1.20 AS builderMulti-Stage Builds
DockerfileFROM python:3.11-slimLayer Caching
.dockerignore.gitUsing .dockerignore
DockerfileFROM alpine:3.18ENTRYPOINT vs CMD
DockerfileFROM nginx:alpineWhen Not to Use Multi-Stage Builds
debug.shdocker logs mycontainerProduction Debugging

Key takeaways

1
Order layers from least to most frequently changing to maximize cache reuse.
2
Always use multi-stage builds to separate build and runtime dependencies.
3
Run as non-root user. Create user with adduser and set USER.
4
Use .dockerignore to exclude secrets and unnecessary files from build context.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Docker layer caching work, and what happens when you COPY . bef...
Q02SENIOR
When would you choose multi-stage builds over a single-stage build in pr...
Q03SENIOR
What happens if you don't define a HEALTHCHECK in your Dockerfile and yo...
Q04JUNIOR
What is the difference between ENTRYPOINT and CMD?
Q05SENIOR
A container runs as root. An attacker exploits a vulnerability in your a...
Q06SENIOR
How would you design a Dockerfile for a microservice that needs to scale...
Q01 of 06SENIOR

How does Docker layer caching work, and what happens when you COPY . before RUN npm install?

ANSWER
Docker caches each layer. If COPY . changes, all subsequent layers rebuild. So RUN npm install runs every time, even if package.json hasn't changed. Fix: copy package.json first, install, then copy rest.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Why is my Docker image so large?
02
What's the difference between COPY and ADD in Dockerfile?
03
How do I run a container as non-root?
04
Should I use `latest` tag for base images?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
July 11, 2026
last updated
258
articles · all by Naren
🔥

That's Docker. Mark it forged?

3 min read · try the examples if you haven't

Previous
Docker CLI Commands Reference
21 / 32 · Docker
Next
Docker Layer Caching Explained