Smaller, Safer Images
Multi-stage builds, choosing a base image, dropping root and pinning versions — the four changes that turn a 1.2 GB image with a shell in it into something you would ship.
Multi-stage builds, choosing a base image, dropping root and pinning versions — the four changes that turn a 1.2 GB image with a shell in it into something you would ship.
Your image works. It is also 1.2 GB, contains a C compiler, ships the full source of every dependency, and runs your application as root. None of that stops it working — it stops it being something you would want to publish, pull a hundred times a day, or expose to the internet.
By the end of this lesson you will know four changes that consistently take an image from that state to a small, boring one: choosing the base deliberately, building in one stage and shipping from another, dropping root, and pinning what you depend on. They compound, and the first two are usually worth an order of magnitude.
It is easy to treat image size as tidiness. It is not — it is four concrete costs.
Pull time, paid on every deployment, every new CI runner and every autoscaled instance. A 1 GB image on a cold node is a minute of doing nothing before your code starts.
Storage, in your registry and on every host, multiplied by every tag you keep.
Attack surface, which is the serious one. Every package in the image is code that can have a vulnerability. A scanner reports on all of it, and a shell in an image is a shell an attacker has if they get in.
Build and deploy speed, since layers you do not create are layers nobody transfers.
docker images myapp
docker history myapp # which layer is the big one
docker system df # the aggregate costStart with docker history. Optimising the wrong layer is the usual
way people spend an afternoon and save four megabytes.
The base is often most of the size, and the choice is a real trade-off rather than "pick the smallest".
slim is the right default. It is the same distribution and the
same C library as the full image, so anything that works on one
works on the other, at a tenth of the size.
Alpine is smaller and occasionally expensive. It uses musl
rather than glibc, which means Python wheels compiled for glibc do
not apply — so pip install falls back to building from source,
and an image that should take twenty seconds takes six minutes and
needs a compiler. For Go or Rust binaries Alpine is excellent. For
Python and Node with native dependencies, slim usually wins on
both build time and sanity.
Distroless and scratch are for compiled languages. No shell,
no package manager, nothing but your binary and its libraries —
and scratch is literally zero bytes, an empty filesystem you add
one file to. The attack surface is close to zero and so is your
ability to debug inside it.
Now the big one. Building software needs tools that running it does not: compilers, header files, test frameworks, a whole package manager. Deleting them afterwards does not help, because a layer only adds — a file created in one layer and removed in a later one is still in the image.
A multi-stage build uses several FROM instructions. Each
starts a fresh stage, and the final stage is the image; everything
else is discarded except what you explicitly copy forward.
The build stage
A compiler, header files, a package manager, pip's caches, every intermediate file. As big as it needs to be, because none of it is going anywhere.
COPY --from=builder
Names exactly what crosses over — here, the finished virtual environment and nothing else.
The final stage — this is your image
A fresh base plus what you copied. Everything left behind in stage one is discarded when the build ends.
The effect is much larger in a compiled language, where the build tools are enormous and the output is one file:
The build stage is roughly 900 MB. The final image is your binary and almost nothing else — a handful of megabytes, with no shell for anyone to find.
By default the process in a container is root. Not your machine's root exactly, but root inside the container — and the isolation between that and the host is thinner than people assume.
Bad — the application runs as root because nothing said otherwise:
Good — the same image, running as an unprivileged user:
A remote-code-execution bug in your dependencies gives an attacker
whatever your process has. As root they can install packages, read
every mounted secret, write anywhere in the filesystem, and start
looking for a way out of the container. As appuser they get a
process that cannot even modify its own code. The change costs three
lines and removes an entire escalation step.
Two details. USER must come after the RUN lines that need to
install things, since those need root. And anything the application
writes at run time has to be writable by the new user, which is what
the chown handles.
You can also enforce it at run time, which is useful when you do not control the image:
An image is supposed to be reproducible, and a floating tag makes it
the opposite. FROM python:3.12-slim resolves to whatever that tag
points at today.
For everyday development the minor version is the sweet spot: reproducible enough to trust, loose enough to receive security patches. For a production build, pin the digest so that what you deploy is exactly what your tests passed against, and update it deliberately.
The same reasoning applies inside the image — requirements.txt
with exact versions, npm ci rather than npm install, a
go.sum committed.
Scan the image. Docker ships a scanner, and running it is one line:
Most findings will be in the base image, which is precisely the argument for a smaller base: fewer packages, fewer advisories, less noise to triage.
Add a health check so the platform knows the difference between running and working:
A process that is up but wedged looks identical to a healthy one without this. With it, Compose can order start-up correctly and an orchestrator can replace the container.
Take an image you have already built, run docker history on it,
and convert it to multi-stage. Watching a number drop from three
figures to two is the most persuasive argument for any of this.
The next lesson takes that small, hardened image and publishes it, so a colleague or a server can pull what you built. Then the capstone puts every lesson in the course together on one application.
# ---- stage 1: build ----
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- stage 2: run ----
FROM python:3.12-slim
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /bin/server /server
USER nonroot:nonroot
CMD ["/server"]FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home --shell /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
CMD ["python", "app.py"]docker run --user 1000:1000 myapp
docker run --read-only --tmpfs /tmp myapp # immutable filesystem
docker run --cap-drop ALL myapp # no Linux capabilitiesFROM python:3.12-slim # good default
FROM python:3.12.7-slim # exact version
FROM python:3.12-slim@sha256:1e8a1d1d... # exact bytesdocker scout quickview myapp:0.1.0
docker scout cves myapp:0.1.0HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD python -c "import urllib.request; \
urllib.request.urlopen('http://localhost:8000/health')"docker ps # STATUS shows (healthy) or (unhealthy)# Multi-stage: build in one, ship from another
FROM python:3.12-slim AS builder
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY app.py .
# Drop root — after the RUNs that need it
RUN useradd --create-home --shell /bin/false appuser \
&& chown -R appuser:appuser /app
USER appuser
# Say whether it is actually working
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f \
http://localhost:8000/health || exit 1
CMD ["python", "app.py"]# Measuring
docker images myapp # the total
docker history myapp # which layer is to blame
docker system df # aggregate disk cost
# Base image sizes, roughly
# python:3.12 ~1 GB full Debian + build tools
# python:3.12-slim ~130 MB the sensible default
# python:3.12-alpine ~50 MB musl: watch for source builds
# distroless/scratch ~0-50 MB compiled languages, no shell
# Building part of a multi-stage file
docker build --target builder -t myapp-dev .
# Hardening at run time
docker run --user 1000:1000 myapp
docker run --read-only --tmpfs /tmp myapp
docker run --cap-drop ALL myapp
docker run -m 512m --cpus 1.5 myapp
# Scanning
docker scout quickview myapp:0.1.0
docker scout cves myapp:0.1.0