Dockerfile Best Practices
Write production-ready Dockerfiles: layer caching, multi-stage builds, .dockerignore, security hardening, slim base images, and patterns that keep builds fast and images small.
What you'll learn
- ✓How Docker layer caching works and how to maximize cache hits
- ✓Multi-stage builds for smaller production images
- ✓Security practices: non-root users, secrets, scanning
- ✓Choosing the right base image
- ✓Patterns for Node.js, Python, and Go applications
Prerequisites
- •Basic Docker knowledge (docker build, docker run)
- •Familiarity with at least one programming language build process
- •Command line comfort
A Dockerfile that works is easy. A Dockerfile that builds fast, produces small images, and does not leak secrets is harder. This guide covers the practices that separate a weekend project Dockerfile from one you can trust in production.
Layer caching fundamentals
Every instruction in a Dockerfile creates a layer. Docker caches layers and reuses them when nothing has changed. When a layer changes, every layer after it is rebuilt.
# Bad: copying everything before installing dependencies
# Any file change invalidates the npm install cache
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
# Good: copy only package files first, then install
# Source code changes don't trigger npm install
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
The order matters. Put instructions that change least frequently at the top:
- Base image (almost never changes)
- System dependencies (change rarely)
- Language dependencies (change occasionally)
- Application code (changes frequently)
Multi-stage builds
Multi-stage builds let you use multiple FROM statements. Each stage can use a different base image. Only the final stage ends up in the output image.
Node.js example
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
# Only copy what's needed to run
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]
Python example
# Stage 1: Build
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt -o requirements.txt --without-hashes
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY . .
# Stage 2: Production
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --from=builder /app .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Go example (dramatic size reduction)
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Stage 2: Production (scratch = empty image)
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
The Go binary is statically linked, so it runs on scratch (an empty image). The final image is just the binary plus TLS certificates, typically 10-20 MB versus 300+ MB for the full Go image.
.dockerignore
A .dockerignore file excludes files from the build context. Without it, Docker sends everything in the current directory to the daemon, including node_modules, .git, test files, and secrets.
# .dockerignore
.git
.gitignore
node_modules
npm-debug.log
Dockerfile
docker-compose*.yml
.dockerignore
.env
.env.*
*.md
tests/
coverage/
.vscode/
.idea/
__pycache__/
*.pyc
.pytest_cache/
dist/
build/
Benefits:
- Faster builds (smaller build context to transfer)
- Better layer caching (irrelevant file changes do not invalidate
COPY .) - Security (
.envfiles and secrets are not copied into the image)
Security practices
Run as non-root
By default, containers run as root. If an attacker breaks out of your application, they have root access inside the container.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# Create a non-root user and switch to it
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN chown -R appuser:appgroup /app
USER appuser
CMD ["node", "index.js"]
For Python images:
RUN useradd --create-home --shell /bin/bash appuser
USER appuser
Handle secrets properly
Never put secrets in a Dockerfile. They persist in the image layers, visible to anyone who pulls the image.
# NEVER do this
ENV DATABASE_URL=postgres://user:password@db:5432/mydb
RUN echo "secret_key=abc123" > /app/config
# Instead, use build secrets (Docker BuildKit)
# syntax=docker/dockerfile:1
FROM python:3.12-slim
RUN --mount=type=secret,id=db_url \
DB_URL=$(cat /run/secrets/db_url) && \
python setup_db.py --url "$DB_URL"
Build with:
docker build --secret id=db_url,src=./db_url.txt .
Pin versions
# Bad: unpredictable
FROM python:latest
# Better: pin major.minor
FROM python:3.12-slim
# Best for production: pin the digest
FROM python:3.12-slim@sha256:abc123...
Scan images
# Built-in scanning
docker scout cve myimage:latest
# Or use trivy
trivy image myimage:latest
Choosing base images
| Base image | Size | Use case |
|---|---|---|
ubuntu:24.04 | ~78 MB | When you need apt and glibc |
debian:bookworm-slim | ~75 MB | Slimmer Debian |
alpine:3.20 | ~7 MB | Minimal, uses musl libc |
distroless | ~2-20 MB | No shell, no package manager |
scratch | 0 MB | Static binaries only |
Alpine is popular for small images but uses musl libc instead of glibc. Some libraries (numpy, pandas, certain Node native modules) may not work or need extra build steps.
# Alpine: small but may need build dependencies
FROM python:3.12-alpine
RUN apk add --no-cache gcc musl-dev
RUN pip install numpy # needs compilation
# Slim: larger but fewer compatibility issues
FROM python:3.12-slim
RUN pip install numpy # pre-built wheel works
Reduce layer count
Combine related RUN commands to reduce layers and intermediate files.
# Bad: 4 layers, apt cache persists
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN rm -rf /var/lib/apt/lists/*
# Good: 1 layer, clean up in the same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git && \
rm -rf /var/lib/apt/lists/*
The rm -rf /var/lib/apt/lists/* only saves space if it runs in the same RUN instruction as apt-get update. Each layer captures a snapshot; deleting files in a later layer does not shrink earlier layers.
Health checks
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
Health checks let Docker (and orchestrators like Kubernetes) know when your application is actually ready to serve traffic, not just that the process is running.
ENTRYPOINT vs CMD
# CMD alone: easily overridden
CMD ["python", "app.py"]
# docker run myimage python test.py -> runs test.py
# ENTRYPOINT + CMD: ENTRYPOINT is the executable, CMD provides defaults
ENTRYPOINT ["python"]
CMD ["app.py"]
# docker run myimage -> runs python app.py
# docker run myimage test.py -> runs python test.py
Use ENTRYPOINT when your container is meant to behave like a specific executable. Use CMD when you want easy override.
Production checklist
- Use multi-stage builds to separate build and runtime
- Pin base image versions (at least major.minor)
- Add a
.dockerignorefile - Copy dependency files before source code for layer caching
- Run as a non-root user
- Never embed secrets in the image
- Use
--no-cache-dirfor pip,npm cifor Node - Clean up package manager caches in the same
RUNlayer - Add a
HEALTHCHECK - Scan the image for vulnerabilities before deploying
A good Dockerfile is small, fast to build, secure, and reproducible. These practices compound: multi-stage plus alpine plus layer caching can turn a 1.2 GB image that takes 5 minutes to build into a 50 MB image that builds in 30 seconds.
Related articles
- Docker Docker Init: Generate Dockerfiles Automatically
Learn how to use docker init to scaffold Dockerfiles, Compose files, and .dockerignore for your projects in seconds. Supports Node.js, Python, Go, Rust, and more.
- Docker Docker BuildKit Advanced Features You Should Be Using
Master BuildKit's cache mounts, secret mounts, SSH forwarding, and parallel builds to speed up Docker image creation and keep secrets out of layers.
- Docker Docker Compose Patterns for Production
Production-ready Docker Compose patterns: override files, healthchecks, resource limits, logging, and secrets management.
- Docker Docker Layer Caching Strategies to Speed Up Builds
Master Docker layer caching: instruction ordering, cache mounts, BuildKit inline cache, and CI registry caching for faster builds.