Search…

Writing production-quality Dockerfiles

In this series (8 parts)
  1. How containers work
  2. Docker fundamentals
  3. Writing production-quality Dockerfiles
  4. Docker networking
  5. Docker volumes and storage
  6. Docker Compose
  7. Container image security
  8. Docker in CI/CD pipelines

A Dockerfile is a build recipe. A bad recipe produces bloated, insecure images that rebuild from scratch on every change. A good recipe produces small, reproducible images that rebuild in seconds. The difference comes down to a handful of techniques.

Layer caching and instruction ordering

Every Dockerfile instruction creates a layer. Docker caches layers and reuses them when nothing has changed. The moment one layer invalidates, every layer after it rebuilds too.

This ordering is wrong:

COPY . /app
RUN go mod download
RUN go build -o server .

Every source file change invalidates the COPY . /app layer. That forces go mod download to re-run even though your dependencies did not change. Dependency downloads are slow. You are paying that cost on every build.

Fix it by copying dependency manifests first:

COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .

Now a source file change only invalidates the second COPY. The cached go mod download layer is reused. The same principle applies everywhere: copy package.json before npm install, copy requirements.txt before pip install.

Put instructions that change rarely at the top. Put instructions that change often at the bottom.

Multi-stage builds

A compiler toolchain, test framework, and source code do not belong in a production image. Multi-stage builds let you separate the build environment from the runtime environment.

flowchart LR
  subgraph Stage1["Stage 1: Builder"]
      A[Base: golang:1.22] --> B[Copy source]
      B --> C[Compile binary]
  end
  subgraph Stage2["Stage 2: Runtime"]
      D[Base: distroless] --> E[Copy binary from Stage 1]
      E --> F[Run binary]
  end
  C -->|COPY --from=builder| E

Multi-stage build flow: the builder stage compiles the binary, and only the binary is copied into the minimal runtime image.

The builder stage has everything needed to compile. The final stage has only the binary and its runtime dependencies. Nothing else.

Base image comparison

Your base image determines the starting size, attack surface, and available tooling.

Base imageCompressed sizeShellPackage managerUse case
ubuntu:22.04~28 MBYesaptDevelopment, debugging
debian:12-slim~27 MBYesaptGeneral production
alpine:3.19~3.4 MBYes (ash)apkSize-sensitive production
gcr.io/distroless/static~2 MBNoNoStatic binaries (Go, Rust)
gcr.io/distroless/base~20 MBNoNoBinaries needing glibc

Alpine is small but uses musl libc. Some applications expect glibc and will segfault or behave differently under musl. Test thoroughly if you switch from Debian to Alpine.

Distroless images contain no shell, no package manager, no utilities. An attacker who gains code execution inside a distroless container cannot spawn a shell, install tools, or poke around the filesystem. This is the smallest attack surface you can get.

Non-root users

Containers run as root by default. If an attacker exploits a vulnerability in your application, they get root inside the container. Combined with a container escape, that becomes root on the host.

Always drop privileges with the USER directive:

RUN addgroup --system app && adduser --system --ingroup app app
USER app

For distroless images, use the built-in nonroot user:

USER nonroot:nonroot

Place the USER directive after installing packages and copying files. You need root to run apt-get and write to system directories. Switch to the unprivileged user only for the final CMD or ENTRYPOINT.

.dockerignore

# .dockerignore
.git
.gitignore
.env
.env.*
*.md
LICENSE
docker-compose*.yml
node_modules
__pycache__
*.pyc
dist/
build/
.idea/
.vscode/

Keep it aggressive. If a file is not needed at build time, exclude it.

HEALTHCHECK instruction

A container can be running but not serving traffic. A process might be stuck in a deadlock, waiting on a broken database connection, or caught in an infinite loop. Docker has no way to know unless you tell it.

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]

Orchestrators like Docker Swarm use health checks to restart unhealthy containers automatically. Kubernetes has its own liveness and readiness probes, but HEALTHCHECK still helps during local development and in non-Kubernetes deployments.

Complete Dockerfile: Go binary

This Dockerfile compiles a Go service and packages it into a distroless image. The final image contains one static binary and nothing else.

# Stage 1: Build
FROM golang:1.22-bookworm AS builder

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server ./cmd/server

# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /bin/server /bin/server

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["/bin/server", "healthcheck"]

USER nonroot:nonroot

ENTRYPOINT ["/bin/server"]

Key decisions:

  • CGO_ENABLED=0 produces a fully static binary. No glibc dependency means distroless/static works.
  • -ldflags="-s -w" strips debug symbols and DWARF info, reducing binary size by 20-30%.
  • nonroot tag on the distroless base sets the default user. The USER directive makes it explicit.
  • Dependency layer caching from copying go.mod and go.sum before the full source tree.

Complete Dockerfile: Python FastAPI

Python cannot produce a static binary, so the approach differs. You install dependencies in a builder stage, then copy the installed packages into a slim runtime image.

# Stage 1: Build dependencies
FROM python:3.12-slim-bookworm AS builder

WORKDIR /app

RUN pip install --no-cache-dir --upgrade pip

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Runtime
FROM python:3.12-slim-bookworm

WORKDIR /app

RUN groupadd --system app && useradd --system --gid app app

COPY --from=builder /install /usr/local

COPY . .

RUN chown -R app:app /app

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]

USER app:app

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Key decisions:

  • --prefix=/install puts all pip packages in an isolated directory. Copying that directory into the final stage avoids carrying over build tools.
  • --no-cache-dir prevents pip from caching downloaded packages inside the image.
  • Pinned dependencies belong in requirements.txt. Use pip freeze or a lockfile tool like pip-compile to pin exact versions. Unpinned dependencies mean your Tuesday build may differ from your Monday build.
  • python:3.12-slim-bookworm is used for both stages. Using the same base avoids glibc mismatches between builder and runtime.

Minimizing image size

Every megabyte matters when you deploy hundreds of containers or pull images across slow networks.

  1. Use multi-stage builds. The builder stage can be 1 GB. The final image should be as small as possible.
  2. Chain RUN commands. Combine related commands with && to avoid intermediate layers that carry deleted files.
  3. Clean up in the same layer. apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/* in one RUN. If you clean in a separate RUN, the previous layer still contains the full package cache.
  4. Use .dockerignore. A smaller build context means faster builds.
  5. Pick the right base. Alpine or distroless over Ubuntu unless you have a specific reason.

Size comparison for a simple Go HTTP server:

ApproachImage size
golang:1.22 (no multi-stage)~850 MB
ubuntu:22.04 + compiled binary~78 MB
alpine:3.19 + compiled binary~12 MB
distroless/static + compiled binary~7 MB

The distroless image is over 100x smaller than the naive approach. It also has zero CVEs from OS packages because there are no OS packages.

Security checklist

  • Pin base image digests in CI. FROM golang:1.22@sha256:abc123... ensures the same base across builds even if the tag moves.
  • Run as non-root. Always.
  • Scan images. Trivy, Grype, and Docker Scout find known CVEs. Run them in CI and block deploys on critical findings.
  • Do not store secrets in the image. Not in environment variables, not in copied files, not in build args. Use runtime secret injection via orchestrator secrets.
  • Minimize installed packages. Every package is a potential vulnerability. If you do not need curl at runtime, do not install it.
  • Use COPY over ADD. ADD auto-extracts tarballs and supports URLs, which introduces unexpected behavior. COPY does exactly one thing.

What comes next

You have the techniques for writing production Dockerfiles. The next step is automating builds and deployments: container registries for image distribution, Docker Compose for local multi-container development, and CI/CD pipelines that build, scan, and push images on every commit. At scale, Kubernetes adds orchestration, rolling updates, and self-healing on top of these containers.

Start typing to search across all content
navigate Enter open Esc close