Dockerfile Best Practices — 12 Rules for Production-Ready Images
Anyone can write a Dockerfile that works. FROM ubuntu, RUN apt-get install everything, COPY . ., done. It builds. It runs. And it is a 1.8 GB security nightmare that takes 10 minutes to deploy. Here are 12 rules that separate a working Dockerfile from a production-ready one.
Rule 1: Use Specific Base Image Tags
Never use latest. It is a moving target. Your image builds fine today and breaks tomorrow when the base image updates.
# BAD — 'latest' can change any time
FROM node:latest
# BAD — major version can include breaking changes
FROM node:20
# GOOD — pinned to specific version
FROM node:20.11-alpine3.19
Pin to a specific minor or patch version. When you want to upgrade, do it intentionally with a tested change, not as a surprise at 2 AM.
Rule 2: Run as a Non-Root User
By default, containers run as root. If an attacker exploits your application, they have root access inside the container — and potentially to the host via privilege escalation.
FROM node:20-alpine
WORKDIR /app
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root before CMD
USER appuser
CMD ["node", "server.js"]
Rule 3: Use COPY, Not ADD
ADD has magic behavior — it auto-extracts tar files and can download URLs. This is rarely what you want and can introduce unexpected behavior.
# BAD — ADD has implicit behavior
ADD app.tar.gz /app/
ADD https://example.com/config.json /app/
# GOOD — COPY is explicit and predictable
COPY app/ /app/
RUN curl -o /app/config.json https://example.com/config.json
Use COPY for files. If you need to download something, use RUN curl or RUN wget so the intent is clear.
Rule 4: Minimize Layers
Each RUN instruction creates a layer. Combine related commands to reduce the number of layers and keep the image smaller.
# BAD — 3 layers, apt cache stored in first layer
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# GOOD — 1 layer, cache cleaned in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Why does cleaning up in the same layer matter? Because Docker layers are additive. If you install 200 MB of packages in layer 1 and delete them in layer 2, the image is still 200 MB larger. The deletion is just an empty marker in the new layer.
Rule 5: Order Instructions for Caching
Place instructions that change infrequently at the top. Dependencies change less often than application code.
FROM python:3.12-slim
# System dependencies (rarely change)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && rm -rf /var/lib/apt/lists/*
# Python dependencies (change occasionally)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Application code (changes frequently)
COPY . .
CMD ["gunicorn", "app:app"]
Rule 6: Use .dockerignore
Without .dockerignore, your build context includes everything — .git history, node_modules, test fixtures, IDE configs.
# .dockerignore
.git
.gitignore
node_modules/
npm-debug.log
Dockerfile
docker-compose*.yml
.env
.env.*
tests/
coverage/
*.md
.vscode/
.idea/
__pycache__/
*.pyc
# Check the difference
# Without .dockerignore: "Sending build context: 847MB"
# With .dockerignore: "Sending build context: 2.1MB"
Rule 7: Never Store Secrets in Images
Secrets baked into images are extractable by anyone who has access to the image. Even if you delete them in a later layer, they exist in the image history.
# BAD — secret is in the layer history forever
COPY .env /app/.env
RUN source /app/.env && npm run build
RUN rm /app/.env # Too late, it's in a previous layer
# GOOD — use build arguments for build-time secrets
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
npm ci && \
rm .npmrc
# BETTER — use Docker BuildKit secrets (never written to a layer)
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
# Build with BuildKit secret
DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=.npm_token -t myapp .
Rule 8: Use HEALTHCHECK
A running container is not necessarily a healthy container. Your process might be alive but deadlocked, or the app might have crashed while the shell keeps running.
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --only=production
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
Docker, Compose, and orchestrators use healthcheck results to determine whether to route traffic to the container or restart it.
Rule 9: Understand ENTRYPOINT vs CMD
| Instruction | Purpose | Overridable? |
|---|---|---|
ENTRYPOINT | The executable that always runs | Only with --entrypoint |
CMD | Default arguments to ENTRYPOINT | Easily overridden at runtime |
# Pattern 1: CMD only (fully overridable)
CMD ["python", "app.py"]
# docker run myapp python manage.py migrate (replaces entire CMD)
# Pattern 2: ENTRYPOINT + CMD (structured defaults)
ENTRYPOINT ["python"]
CMD ["app.py"]
# docker run myapp manage.py migrate (keeps python, replaces app.py)
# Pattern 3: Shell script entrypoint (for initialization)
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["start"]
Use the exec form (["executable", "arg"]) not the shell form (executable arg) so that signals like SIGTERM reach your process directly.
Rule 10: Pin Versions in apt-get
Unpinned packages mean your build is not reproducible. What builds today might fail or behave differently next month.
# BAD — version could change
RUN apt-get update && apt-get install -y curl
# GOOD — pinned version
RUN apt-get update && apt-get install -y \
curl=7.88.1-10+deb12u5 \
&& rm -rf /var/lib/apt/lists/*
# Find available versions
docker run --rm debian:bookworm-slim apt-cache policy curl
At minimum, use --no-install-recommends to avoid pulling in unnecessary suggested packages.
Rule 11: Clean Up in the Same Layer
Deletions in a new layer do not reduce image size. The data still exists in the previous layer.
# BAD — build tools remain in image (200MB wasted)
RUN apt-get update && apt-get install -y gcc make
RUN make build
RUN apt-get remove -y gcc make # This doesn't reclaim space!
# GOOD — install, use, and clean up in one layer
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc make && \
make build && \
apt-get purge -y gcc make && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
Rule 12: Use Multi-Stage Builds
Multi-stage builds are the most impactful optimization. Build in one stage, copy only the artifacts you need into a minimal final stage.
# Stage 1: Build (large image with all build tools)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
# Stage 2: Runtime (tiny image, no build tools)
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
docker build -t myapp .
docker images myapp
# REPOSITORY TAG SIZE
# myapp latest 8.2MB (vs 300MB+ without multi-stage)
Before and After: Complete Example
Here is a real transformation — the same Node.js app, amateur vs production.
# BEFORE: 1.1 GB, runs as root, no health check, poor caching
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start
# AFTER: 165 MB, non-root, health checked, optimized caching
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./
USER app
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --spider -q http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "dist/server.js"]
Wrapping Up
These 12 rules are not academic theory — they directly impact your build times, image sizes, security posture, and deployment reliability. Start with the low-hanging fruit (.dockerignore, layer ordering, non-root user) and work your way up to multi-stage builds.
In the next post, we will cover Docker Registries — Docker Hub, ECR, ACR, GHCR, and how to choose the right registry for your team, with real setup examples and a comparison table.
