DockerKubernetesSecurity Oct 2024 8 min read

Dockerfile Best Practices for Production

Most Dockerfiles work in development but create security risks and performance problems in production. Here are the practices that actually matter when your images run in a real cluster.

Why most Dockerfiles in production are wrong

After reviewing Dockerfiles across dozens of services, the same mistakes appear repeatedly — not because engineers don't care, but because the defaults Docker gives you are optimised for getting started, not for running in production. Here are the practices that actually matter once your images are running in a real cluster.

Use specific base image tags — never latest

# Bad — rebuilds silently break when upstream changes
FROM node:latest
FROM python:3

# Good — pinned, reproducible, auditable
FROM node:20.11-alpine3.19
FROM python:3.12.2-slim-bookworm

latest is not a version. It changes without warning. One upstream update to the base image and your build breaks in CI at 3am on a Friday. Pin to a specific digest or at minimum a patch version.

Multi-stage builds — keep images small

The build environment and the runtime environment should be separate. Your final image doesn't need a compiler, test dependencies, or build tools.

# Stage 1: build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/server ./cmd/server

# Stage 2: runtime — scratch or distroless
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

This drops a typical Go image from 800MB to under 20MB. Smaller images mean faster pulls, less attack surface, and lower ECR storage costs at scale.

Layer caching — order matters

Docker caches layers. A cache miss on one layer invalidates everything below it. Structure your Dockerfile so the things that change least are at the top.

# Bad — copies everything first, cache busted on every code change
COPY . .
RUN npm install

# Good — dependencies cached separately from source code
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ ./src/

The rule: copy dependency manifests first, install dependencies, then copy source code. Your npm install or pip install layer will be cached until package.json changes — not on every single code edit.

Never run as root

# Create a non-root user and use it
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser

# For distroless images, use the nonroot variant
FROM gcr.io/distroless/static-debian12:nonroot

If your container is compromised, a root process has write access to mounted volumes, host path mounts, and depending on your security context — the host itself. There's almost never a reason for an application container to run as root.

Use .dockerignore religiously

# .dockerignore
.git
.gitignore
node_modules
*.md
.env
.env.*
coverage/
.DS_Store
Dockerfile
docker-compose*.yml
tests/

Without .dockerignore, your build context includes your entire .git directory, node_modules, and any .env files sitting in the project root. This slows builds and risks leaking secrets into image layers.

Set explicit HEALTHCHECK instructions

HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3   CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

Without a HEALTHCHECK, Docker and Kubernetes consider a container healthy the moment the process starts — even if it hasn't finished initialising. A proper health endpoint prevents traffic from reaching a container that isn't ready.

Quick audit: Run docker scout cves your-image:tag to scan for known CVEs in your current images. If you're on ECR, enable Enhanced Scanning — it runs automatically on every push.

The Dockerfile review checklist

← Back to all articles