Writing production-quality Dockerfiles
In this series (8 parts)
- How containers work
- Docker fundamentals
- Writing production-quality Dockerfiles
- Docker networking
- Docker volumes and storage
- Docker Compose
- Container image security
- 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 image | Compressed size | Shell | Package manager | Use case |
|---|---|---|---|---|
ubuntu:22.04 | ~28 MB | Yes | apt | Development, debugging |
debian:12-slim | ~27 MB | Yes | apt | General production |
alpine:3.19 | ~3.4 MB | Yes (ash) | apk | Size-sensitive production |
gcr.io/distroless/static | ~2 MB | No | No | Static binaries (Go, Rust) |
gcr.io/distroless/base | ~20 MB | No | No | Binaries 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=0produces 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%.nonroottag on the distroless base sets the default user. TheUSERdirective makes it explicit.- Dependency layer caching from copying
go.modandgo.sumbefore 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=/installputs all pip packages in an isolated directory. Copying that directory into the final stage avoids carrying over build tools.--no-cache-dirprevents pip from caching downloaded packages inside the image.- Pinned dependencies belong in
requirements.txt. Usepip freezeor a lockfile tool likepip-compileto pin exact versions. Unpinned dependencies mean your Tuesday build may differ from your Monday build. python:3.12-slim-bookwormis 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.
- Use multi-stage builds. The builder stage can be 1 GB. The final image should be as small as possible.
- Chain RUN commands. Combine related commands with
&&to avoid intermediate layers that carry deleted files. - Clean up in the same layer.
apt-get install -y curl && apt-get clean && rm -rf /var/lib/apt/lists/*in oneRUN. If you clean in a separateRUN, the previous layer still contains the full package cache. - Use
.dockerignore. A smaller build context means faster builds. - Pick the right base. Alpine or distroless over Ubuntu unless you have a specific reason.
Size comparison for a simple Go HTTP server:
| Approach | Image 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
curlat runtime, do not install it. - Use
COPYoverADD.ADDauto-extracts tarballs and supports URLs, which introduces unexpected behavior.COPYdoes 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.