Dockerfile Review
Intro
A good Dockerfile is small, cache-friendly, reproducible, and does not
run as root. Most problems come from three mistakes: ordering layers
wrong (busting the cache), forgetting to clean up package manager
state, and using ADD when COPY is what you meant.
Overview
Layer optimization
Every instruction creates a layer; layers are cached in order. Order
instructions from least to most frequently changing so the cache stays
warm. Combine related RUN commands with && to collapse layers.
Keep a .dockerignore file so build context does not balloon with
node_modules, .git, and local caches.
Caching dependencies
Copy dependency manifests and install before copying source code. If the manifests haven't changed, the install layer is reused:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
Pin dependency versions so builds are reproducible. Unpinned dependencies produce "works in CI, fails in prod" surprises.
Security
- Don't run as root. Create a non-root user and
USERinto it after setup. - Never
COPYsecrets into an image. Use BuildKit build secrets or inject via runtime environment variables. - Pin base images by digest for supply-chain integrity:
FROM python:3.12-slim@sha256:.... - Remove package manager caches after installing
(
rm -rf /var/lib/apt/lists/*).
Size reduction
- Start from slim or alpine base images where the ecosystem supports it.
- Use multi-stage builds for compiled languages — the final stage should not contain the compiler.
- Remove build tools after compilation.
- Use
--no-install-recommendswithapt-get.
Correctness
- Prefer
COPYoverADD—ADDhas magic behavior (URL fetch, auto-extraction) that usually surprises you. - Set
WORKDIRinstead ofcdinRUNcommands. - Use the exec form of
CMDandENTRYPOINT:["executable", "arg"], not the shell form.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
COPY . .before installing dependencies. Copying the full source tree before the dependency install step means any source file change invalidates the install cache, forcing a full reinstall on every build. Copy dependency manifests first (COPY requirements.txt .), install, then copy source — this way the install layer is reused unless the manifest changes.- Running the final image as root. A container running as root inside a compromised container has host-level privileges if the container runtime is misconfigured or if a vulnerability allows container escape. Always create a dedicated non-root user and
USERinto it beforeCMD/ENTRYPOINT. This is a singleRUN useraddandUSERinstruction. - Using
ADDwith a local path instead ofCOPY.ADDwith a local file path behaves likeCOPYbut also auto-extracts tarballs and fetches URLs — surprising behavior that causes non-obvious build results. UseCOPYfor all local file copies;ADDis only appropriate for URL fetching or deliberate tarball extraction. - Unpinned base image tags (
FROM python:latest).latestresolves to a different image every time the build runs on a new host or after an upstream push, producing non-reproducible builds. Pin to a specific version tag (python:3.12-slim) and ideally to a digest (@sha256:...) for full supply-chain integrity. - Shell-form
CMDorENTRYPOINT.CMD python app.py(shell form) runs the command inside/bin/sh -c, which means signals from the container runtime (SIGTERMondocker stop) are sent to the shell, not to the Python process. The process does not shut down gracefully. Use exec form:CMD ["python", "app.py"]. apt-get installandrm -rf /var/lib/apt/lists/*in separateRUNinstructions. Docker layers are immutable snapshots. Removing the apt cache in a laterRUNdoes not reclaim the space from the earlier layer — the files exist in the earlier layer's snapshot permanently. Combineapt-get update,apt-get install, and the cleanup into a singleRUNcommand.- Secrets passed via
ARGorENV. Build arguments and environment variables are stored in the image's layer history and visible withdocker historyordocker inspect. Never pass API keys, passwords, or tokens viaARGorENVat build time — use BuildKit's--mount=type=secretfor build-time secrets, and runtime environment variables for run-time secrets.
Full reference
Canonical Python Dockerfile
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim@sha256:... AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim@sha256:...
RUN useradd --create-home --uid 1000 app
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app . .
USER app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
ENTRYPOINT ["python", "-m", "myapp"]
Notable choices: multi-stage build so pip's build deps are not in the
final image, explicit non-root user, COPY --chown so we don't need
a later chown, exec-form entrypoint, environment variables set via
ENV rather than inside the command.
Canonical apt cleanup
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
Always combine update, install, and cleanup into one RUN. A
separate RUN rm -rf /var/lib/apt/lists/* does nothing for image size
because the previous layer already committed those files.
Multi-stage build for Go
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Anti-patterns
COPY . .before installing dependencies — every source change busts the install cache- Running as root in production — add a user and
USERinto it ADDwith a local path — useCOPY; reserveADDfor URL fetches and tar auto-extractionlatestbase image tags — pin to a specific version, ideally by digest- Shell-form
CMD/ENTRYPOINT— breaks signal handling; use the exec form apt-get installwithout--no-install-recommends— pulls in tons of suggested packages- Secrets via
ARGorENV— they persist in image history; use BuildKit secrets (--mount=type=secret) instead - Missing
.dockerignore— sends.git,node_modules, and everything else to the daemon as build context
Review checklist
- Is the base image pinned (ideally by digest)?
- Are dependencies installed before source is copied?
- Is
.dockerignorepresent and sensible? - Does the final stage run as non-root?
- Are package manager caches removed in the same
RUN? - Is
ENTRYPOINT/CMDin exec form? - Are compile-time deps excluded from the final stage?
- Is there any
latest, any bareADD, any secret inARG/ENV?