Building Container Images That Are Small, Fast, and Not Full of Holes
A container image built without thought is commonly a gigabyte, contains a compiler, runs as root, and rebuilds from scratch every time you change a line of code. All four are avoidable with a handful of practices.
Order instructions by how often they change
Layers are cached in sequence. Changing an instruction invalidates it and everything after.
# Bad: any source change reinstalls every dependency
COPY . /app
RUN npm install
# Good: dependencies are cached until the manifest changes
COPY package.json package-lock.json /app/
RUN npm ci
COPY . /app
That reordering is the single largest improvement to build times in most projects, and it costs nothing.
Multi-stage builds
Compilers and headers are needed to build and not to run.
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The build stage is over a gigabyte. The final image is the binary and nothing else. Same result, a fraction of the size and a much smaller attack surface, because there is no shell in the final image for an attacker to use.
The same pattern works for anything compiled or bundled.
Do not run as root
RUN useradd --system --uid 10001 appuser
USER appuser
The container process then runs unprivileged. Combined with rootless containers you get two independent layers: even if something escapes the user, it lands in an unprivileged host account.
Watch file ownership. Anything the application writes must be owned by that UID, which is easiest to arrange with COPY --chown.
Secrets are permanent
# Wrong. The key is in the image forever.
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:private/repo && rm /root/.ssh/id_rsa
Deleting a file in a later layer does not remove it. Every layer is retained and extractable with docker history or by unpacking the image.
Use build secrets, which mount for one instruction and are never committed:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .
For runtime credentials, pass environment variables or mount a file. Infisical or OpenBao manage them properly.
If a secret has been baked into an image, rotate it. Rebuilding does not undo distribution.
Clean up in the same layer
# Wrong, the cache is in the previous layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Right
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Choosing a base
Distroless for compiled languages. No shell, no package manager, minimal surface.
Slim variants of Debian or Ubuntu when you need glibc and some tooling.
Alpine when small matters most. It uses musl rather than glibc, which occasionally breaks prebuilt binaries and has caused DNS and performance surprises. Excellent when it works; check rather than assume.
Pin the tag. FROM python:3.13-slim rather than latest, so a rebuild next month produces the same thing.
Scan what you built
trivy image myapp:latest --severity HIGH,CRITICAL --ignore-unfixed
Trivy or Grype will tell you what your base image dragged in. A slim base with fewer packages has fewer findings for the obvious reason.
Frequently Asked Questions
Why is my container image so large?
Usually a full distribution base image, build tools left in the final image, and package manager caches never cleaned. A multi-stage build that copies only the built artefact into a minimal runtime image typically cuts the size by an order of magnitude.
What is a multi-stage build?
A Dockerfile with several FROM instructions where earlier stages compile or install things and the final stage copies only what it needs. Compilers, headers, and caches stay in the discarded stages and never reach the shipped image.
Why does changing one line invalidate my whole build cache?
Layers are cached in order, and changing any instruction invalidates every layer after it. Copying your entire source before installing dependencies means every code change reinstalls everything. Copy the dependency manifest first, install, then copy the source.
Can I delete a secret in a later layer?
No. Every layer is preserved in the image, so a file added in one layer and removed in the next is still extractable. Use build secrets or runtime environment variables instead, and treat any image built with a baked secret as compromised.
Should containers run as root?
No. Add a USER instruction so the process runs unprivileged. Combined with rootless containers this gives two independent layers, and it costs nothing but a line in the Dockerfile plus getting file ownership right.
Is Alpine always the right base image?
Not always. It is very small and uses musl rather than glibc, which breaks some prebuilt binaries and can affect DNS resolution and performance in specific cases. Distroless or a slim variant of a glibc distribution is often a better default.