Skip to content
Codeloom
Docker

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.

·7 min read · By Codeloom
Intermediate 13 min read

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:

  1. Base image (almost never changes)
  2. System dependencies (change rarely)
  3. Language dependencies (change occasionally)
  4. 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 (.env files 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 imageSizeUse case
ubuntu:24.04~78 MBWhen you need apt and glibc
debian:bookworm-slim~75 MBSlimmer Debian
alpine:3.20~7 MBMinimal, uses musl libc
distroless~2-20 MBNo shell, no package manager
scratch0 MBStatic 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

  1. Use multi-stage builds to separate build and runtime
  2. Pin base image versions (at least major.minor)
  3. Add a .dockerignore file
  4. Copy dependency files before source code for layer caching
  5. Run as a non-root user
  6. Never embed secrets in the image
  7. Use --no-cache-dir for pip, npm ci for Node
  8. Clean up package manager caches in the same RUN layer
  9. Add a HEALTHCHECK
  10. 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.