Building Good Images
Making images small, fast to build and safe to run: layer caching, .dockerignore, multi-stage builds, minimal base images, pinning versions and running as a non-root user.
What a good image is
Any Dockerfile that builds produces an image that runs. A good image also has these properties, and each one has a practical payoff for the people who operate it:
| Property | Why operations cares | Section |
|---|---|---|
| Fast to rebuild | Fixes reach production in minutes, CI costs less | Build cache |
| Contains only what it needs | Less to download, fewer packages with vulnerabilities, no leaked files | .dockerignore, multi-stage, base image |
| Reproducible and current | You know what runs, and it gets patched | Pinning and updating |
| Safe by default | A compromise of the app is not root on the host | Non-root and secrets |
| Runs on every platform you use | The same tag works on amd64 servers and arm64 nodes | Multi-platform builds |
This page assumes you can read a Dockerfile; the instructions are explained on Writing Dockerfiles.
BuildKit
BuildKit is the build engine behind
docker build: it reads the Dockerfile, works out which
steps depend on which, runs independent steps in parallel, skips
stages the final image does not need, and keeps the build cache. It
has been the default builder for Linux images since Docker Engine
23.0, reached through the Buildx component (docker build
and docker buildx build are the same thing on a current
installation). The older "legacy" builder is deprecated for Linux
images; Windows containers still use it.
Several features on this page exist only in BuildKit: secret and
cache mounts (RUN --mount=…), heredocs, build checks
and multi-platform builds. If a tutorial tells you to set
DOCKER_BUILDKIT=1, it predates 2023; you need nothing.
The build cache
The build cache is BuildKit's
store of the results of earlier build steps. Before running a step,
BuildKit computes a key from the step's instruction, the result of
the previous step and, for COPY and ADD, a
checksum of the files copied. If a result with that key exists, the
step is skipped and shown as CACHED. Because the key
includes the previous step, one change invalidates that step
and every step after it, the rule the
Cloud page introduces.
The meter-api Dockerfile copies requirements.txt,
installs the dependencies and only then copies app.py.
Rebuilt after a one-line change to app.py:
$ docker build -t kestrel-meter-api:1.1 .
#0 building with "default" instance using docker driver #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 211B done #1 DONE 0.0s #2 [internal] load metadata for docker.io/library/python:3.13-slim #2 DONE 0.2s #3 [internal] load .dockerignore #3 transferring context: 2B done #3 DONE 0.0s #4 [1/5] FROM docker.io/library/python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c #4 DONE 0.0s #5 [internal] load build context #5 transferring context: 1.04kB done #5 DONE 0.0s #6 [3/5] COPY requirements.txt . #6 CACHED #7 [2/5] WORKDIR /app #7 CACHED #8 [4/5] RUN pip install --no-cache-dir -r requirements.txt #8 CACHED #9 [5/5] COPY app.py . #9 DONE 0.0s #10 exporting to image #10 exporting layers 0.0s done #10 writing image sha256:6f23f9446e059e65dfb19b44e9c77e8acd5904b0f44bb61363125628213075bb done #10 naming to docker.io/library/kestrel-meter-api:1.1 done #10 DONE 0.0s
Steps 2 to 4, including the slow pip install, came from
the cache; only COPY app.py ran, in 0.0 seconds.
(BuildKit prints cached steps in whatever order it finishes checking
them, which is why [3/5] appears before
[2/5].) The same change with a careless order, where
COPY . . comes first, filtered to the step lines:
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8000
USER 10001
CMD ["python", "app.py"]
$ docker build -f Dockerfile.badorder -t kestrel-meter-api:badorder . 2>&1 | grep -E "^#[0-9]+ \[|CACHED|DONE [0-9.]+s$|Successfully"
#1 [internal] load build definition from Dockerfile.badorder #1 DONE 0.0s #2 [internal] load metadata for docker.io/library/python:3.13-slim #2 DONE 0.2s #3 [internal] load .dockerignore #3 DONE 0.0s #4 [1/4] FROM docker.io/library/python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c #4 DONE 0.0s #5 [internal] load build context #5 DONE 0.0s #6 [2/4] WORKDIR /app #6 CACHED #7 [3/4] COPY . . #7 DONE 0.0s #8 [4/4] RUN pip install --no-cache-dir -r requirements.txt #8 3.336 Successfully installed certifi-2026.7.22 charset-normalizer-3.5.2 idna-3.20 requests-2.32.3 urllib3-2.8.0 #8 DONE 3.6s #9 DONE 0.1s
COPY . . copies app.py too, so its
checksum changed, and every later step had to run again: pip
downloaded and installed everything (3.6 seconds here; minutes for a
real application with many dependencies, on every commit). The rule
behind both builds, as a simulation:
import hashlib
def build(steps, files, cache, show=True):
"""Marks each step CACHED or RUN, like BuildKit. A step's cache key covers the
previous step's key, the instruction and the content of the files it copies."""
key = "python:3.13-slim"
for instruction, inputs in steps:
content = "".join(files[name] for name in inputs)
key = hashlib.sha256((key + instruction + content).encode()).hexdigest()
if show:
print(f" {'CACHED' if key in cache else 'RUN':7} {instruction}")
cache.add(key)
good = [("WORKDIR /app", []),
("COPY requirements.txt .", ["requirements.txt"]),
("RUN pip install -r requirements.txt", []),
("COPY app.py .", ["app.py"])]
bad = [("WORKDIR /app", []),
("COPY . .", ["requirements.txt", "app.py"]),
("RUN pip install -r requirements.txt", [])]
for name, steps in [("good order", good), ("bad order", bad)]:
cache = set()
files = {"requirements.txt": "requests==2.32.3\n", "app.py": "READINGS = 2\n"}
build(steps, files, cache, show=False) # the first build fills the cache
files["app.py"] = "READINGS = 3\n" # then one line of code changes
print(f"{name}, rebuild after editing app.py:")
build(steps, files, cache)
good order, rebuild after editing app.py: CACHED WORKDIR /app CACHED COPY requirements.txt . CACHED RUN pip install -r requirements.txt RUN COPY app.py . bad order, rebuild after editing app.py: CACHED WORKDIR /app RUN COPY . . RUN RUN pip install -r requirements.txt
Order a Dockerfile from what changes least to what changes most:
- base image and operating system packages;
-
dependency manifests (
requirements.txt,package.jsonand its lock file) and their installation; - your own code;
- configuration-only instructions such as
CMD.
Three options control the cache when you need to:
-
--pullchecks the registry for a newer version of the base image tag instead of using the copy already on the machine. Use it in CI, so security updates of the base arrive. -
--no-cacheruns every step again, for example for a clean release build or when a step downloads something "latest" that the cache cannot know has changed. -
In CI, runners often start empty; exporting and importing the
cache (
--cache-to/--cache-from, or the cache support of your CI system, see Caching and artifacts) keeps the benefit.
.dockerignore
A .dockerignore
file in the root of the build context lists files and directories
that are not sent to the builder, one pattern per line, much like
.gitignore. What is not in the context cannot be copied into the image by
accident, and a smaller context makes every build start faster. The
recording put a typical project folder to the test: the code, a fake
.env file, a .git directory and an 81 MB
data dump, with a Dockerfile that does COPY . ..
$ ls -la
$ docker build -t kestrel-ignore:without . 2>&1 | sed -n "/load build context/,/DONE/p"
$ docker run --rm --name ignore-check kestrel-ignore:without sh -c "ls -la /app; cat /app/.env"
total 79184 drwxr-xr-x 3 amara amara 4096 Oct 6 14:21 . drwxr-xr-x 7 amara amara 4096 Oct 6 14:21 .. -rw-r--r-- 1 amara amara 966 Oct 6 14:21 app.py -rw-r--r-- 1 amara amara 69 Oct 6 14:21 Dockerfile -rw-r--r-- 1 amara amara 43 Oct 6 14:21 .env drwxr-xr-x 2 amara amara 4096 Oct 6 14:21 .git -rw-r--r-- 1 amara amara 81052632 Oct 6 14:21 meter-dump.csv -rw-r--r-- 1 amara amara 17 Oct 6 14:21 requirements.txt #5 [internal] load build context #5 transferring context: 86.07MB 1.1s done #5 DONE 1.2s total 79188 drwxr-xr-x 1 root root 4096 Oct 6 12:21 . drwxr-xr-x 1 root root 4096 Oct 6 12:21 .. -rw-r--r-- 1 root root 43 Oct 6 12:21 .env drwxr-xr-x 2 root root 4096 Oct 6 12:21 .git -rw-r--r-- 1 root root 69 Oct 6 12:21 Dockerfile -rw-r--r-- 1 root root 966 Oct 6 12:21 app.py -rw-r--r-- 1 root root 81052632 Oct 6 12:21 meter-dump.csv -rw-r--r-- 1 root root 17 Oct 6 12:21 requirements.txt DB_PASSWORD=example-only-not-a-real-secret
Everything went in: 86 MB of context, the Git history, the data dump
and the password file, readable by anyone who can pull the image.
(The times in the listings differ because the host shows local time
and the container UTC.) Then a .dockerignore and the
same build again:
$ cat .dockerignore
$ docker build -t kestrel-ignore:with . 2>&1 | sed -n "/load build context/,/DONE/p"
$ docker run --rm --name ignore-check kestrel-ignore:with ls -la /app
$ docker images kestrel-ignore
.git .env *.csv __pycache__/ Dockerfile .dockerignore #5 [internal] load build context #5 transferring context: 63B done #5 DONE 0.0s total 16 drwxr-xr-x 1 root root 4096 Oct 6 12:21 . drwxr-xr-x 1 root root 4096 Oct 6 12:21 .. -rw-r--r-- 1 root root 966 Oct 6 12:21 app.py -rw-r--r-- 1 root root 17 Oct 6 12:21 requirements.txt REPOSITORY TAG IMAGE ID CREATED SIZE kestrel-ignore with 6fa4d5243c73 Less than a second ago 143MB kestrel-ignore without 7486118f1190 1 second ago 230MB
Only the two files the application needs, and an image 87 MB
smaller. (The context transfer shows just 63 bytes partly because
BuildKit sends only what changed since the previous build of the
same folder.) Patterns follow Go's file-matching rules:
* matches within one path segment,
** matches any number of directories, and a line
starting with ! makes an exception to an earlier
pattern. A simplified matcher shows which files a context would
send:
from fnmatch import fnmatch
context = ["app.py", "requirements.txt", ".env", ".git/config", ".git/HEAD",
"meter-dump.csv", "__pycache__/app.cpython-313.pyc", "tests/test_app.py",
"Dockerfile", ".dockerignore"]
dockerignore = """\
.git
.env
*.csv
__pycache__
tests
Dockerfile
.dockerignore
"""
patterns = [p for p in dockerignore.splitlines() if p and not p.startswith("#")]
def ignored(path):
# A pattern matches the path itself or one of its parent directories.
parts = path.split("/")
prefixes = ["/".join(parts[:n]) for n in range(1, len(parts) + 1)]
return any(fnmatch(prefix, pattern) for pattern in patterns for prefix in prefixes)
for path in context:
print(f"{'skip' if ignored(path) else 'SEND'} {path}")
SEND app.py SEND requirements.txt skip .env skip .git/config skip .git/HEAD skip meter-dump.csv skip __pycache__/app.cpython-313.pyc skip tests/test_app.py skip Dockerfile skip .dockerignore
Python's fnmatch lets * cross a
/ and knows no ! exceptions, so treat this
as a way to reason about a context, not as an exact copy of the
builder's rules. Two habits make .dockerignore
robust: start it from the same list as
what never belongs in Git
(.env, keys, dumps), and prefer copying named files or
directories (COPY app/ ./app/) over
COPY . ., so a new file in the folder does not end up
in the image by default.
Multi-stage builds
A multi-stage build is a
Dockerfile with more than one FROM. Each
FROM starts a new stage with its own base image;
COPY --from=<stage> copies files from an earlier
stage, and only the last stage becomes the image. Compilers, build
tools, source code and intermediate files stay behind in the earlier
stages. The Kestrel portal is a static site generated by a Python
script and served by nginx:
# Stage 1: build the static site
FROM python:3.13-slim AS build
WORKDIR /src
COPY build.py .
RUN python build.py
# Stage 2: serve only the result
FROM nginx:alpine
COPY --from=build /src/dist/ /usr/share/nginx/html/
EXPOSE 80
$ docker build -t kestrel-portal:multi .
… #5 [build 1/4] FROM docker.io/library/python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c #5 CACHED … #7 [build 2/4] WORKDIR /src #7 DONE 0.0s #8 [build 3/4] COPY build.py . #8 DONE 0.0s #9 [stage-1 1/2] FROM docker.io/library/nginx:alpine #9 DONE 0.1s #10 [build 4/4] RUN python build.py #10 0.319 built dist/index.html #10 DONE 0.3s #11 [stage-1 2/2] COPY --from=build /src/dist/ /usr/share/nginx/html/ #11 DONE 0.0s …
The steps are labeled by stage: build (the name given
with AS) and stage-1 (an unnamed stage
gets a number). Note that BuildKit started the nginx stage while the
build stage was still running. For comparison, a single-stage
version serves the same file with Python's own web server from the
python:3.13-slim image:
$ docker images kestrel-portal
REPOSITORY TAG IMAGE ID CREATED SIZE kestrel-portal single c994d3c16c4d 1 second ago 144MB kestrel-portal multi 83f482f8038d 1 second ago 49.7MB
From 144 MB to 49.7 MB, and the shipped image contains no Python at
all. docker history confirms it: on top of the nginx
base layers there is one layer of 125 bytes, the generated page.
$ docker history kestrel-portal:multi
IMAGE CREATED CREATED BY SIZE COMMENT
83f482f8038d 1 second ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 1 second ago COPY /src/dist/ /usr/share/nginx/html/ # bui… 125B buildkit.dockerfile.v0
<missing> 17 months ago RUN /bin/sh -c set -x && apkArch="$(cat … 37.4MB buildkit.dockerfile.v0
…
<missing> 19 months ago ADD alpine-minirootfs-3.21.3-aarch64.tar.gz … 8.17MB buildkit.dockerfile.v0
The same pattern fits Python services that need a compiler to build some of their dependencies: build wheels (pre-built packages) in a full image, install them in a slim one. A BuildKit bind mount reads the wheels from the build stage without copying them into a layer:
# syntax=docker/dockerfile:1
FROM python:3.13 AS build
WORKDIR /src
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.13-slim
WORKDIR /app
RUN --mount=type=bind,from=build,source=/wheels,target=/wheels \
pip install --no-cache-dir --no-index /wheels/*
COPY app.py .
USER 10001:10001
CMD ["python", "app.py"]
Choosing a base image
The base image decides most of an image's size and most of its vulnerability reports, because every package in it ships with your application. From large to small:
| Kind | Example | Shell and package manager | Good for |
|---|---|---|---|
| Full distribution | python:3.13 |
Yes | Build stages |
| Slim distribution | python:3.13-slim |
Yes | Most services; easy to debug |
| Alpine | python:3.13-alpine |
Yes (apk) |
Small images, if your software works with musl |
| Distroless | Google's distroless images, Chainguard images | No | Production services with a small attack surface |
| Empty | scratch |
No, nothing at all | A single statically linked program |
A distroless image contains
only the application's runtime and the files it needs (such as CA
certificates, time zone data and a minimal C library), without a
shell, package manager or the usual command-line tools. Fewer
programs means fewer vulnerabilities to patch and fewer tools for an
attacker who gets in. The price is paid when debugging: there is no
sh to docker exec into, so you need the
techniques on
Debugging Containers.
Alpine deserves a warning of its own. It uses musl instead of the GNU C library (glibc), which most Linux software is built and tested against. Pre-built Python packages made for glibc do not install on it; many projects also publish builds for musl, but where they do not, pip compiles from source (slow, and you need a compiler in the build). Small differences in name resolution and memory behavior occasionally surface in production. Alpine is a fine choice when your stack supports it, not an automatic upgrade from slim.
Vendors also offer hardened base images, rebuilt quickly when a fix appears and shipped with a software bill of materials and provenance. Docker announced in December 2025 that its catalog of Docker Hardened Images became free to use under the Apache 2.0 license; Chainguard and others offer similar images. As with any supplier, check the terms that apply to you at the time.
Pinning and updating
A tag in FROM moves (see
Tags and digests), so FROM python:3.13-slim builds on a different base
next month. That is two-sided: you get security fixes, but a build
is not exactly reproducible. Pinning the digest makes it exact:
FROM python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c
With both tag and digest, the digest wins; the tag is there for people to read. A pinned digest never changes by itself, which is the point and also the danger: it never receives a fix either. Pin and update deliberately:
-
Let a bot propose the updates.
Dependabot
and Renovate understand
FROMlines with digests and open a pull request when the tag moves, so the change is reviewed, built and tested like any other. -
Rebuild on a schedule even when your code does not change, for
example weekly with
--pull, so operating system fixes in the base arrive. - Scan the result, in CI and in the registry, and act on what the scanner reports (see Container Security); generate a software bill of materials so you can answer "do we run the vulnerable library?" in minutes.
-
Pin your application's own dependencies too (
requests==2.32.3inrequirements.txt, a lock file for npm), or two builds of the same commit can differ.
Non-root users and build secrets
Run as a non-root user
Without a USER instruction the application runs as root
(user ID 0). Inside a default Docker container that is the same user
ID 0 the host kernel knows (see
Linux namespaces), with a reduced but real set of privileges. If the application is
compromised, the attacker starts as root, one kernel flaw or
misconfiguration away from the host. Give the service its own
numeric user and group, after the steps that need root:
RUN groupadd --gid 10001 meter \
&& useradd --uid 10001 --gid 10001 --no-create-home meter
USER 10001:10001
Things to watch when the service is not root: ports below 1024
(under Docker Engine it can still bind them, because Docker sets
net.ipv4.ip_unprivileged_port_start=0 in the container,
but Kubernetes and host networking do not, so let it listen on 8000
or 8080 and publish that), and files it must write need to be owned
by its user (COPY --chown=10001:10001 or a
chown in the same RUN that creates the
directory). More layers of defense, such as rootless mode and
dropped capabilities, are on
Container Security.
Secrets during the build
Some builds need a secret: a token for Kestrel's private package
index, or a key to clone a private repository. Docker's
documentation is blunt about the obvious solutions: "Build arguments
and environment variables are inappropriate for passing secrets to
your build, because they persist in the final image." The same is
true of a file you COPY in and delete later (see the
.env example above). A secret mount
makes the secret available to one RUN step only,
without writing it to any layer:
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=secret,id=pip_index_url,env=PIP_INDEX_URL \
pip install --no-cache-dir -r requirements.txt
$ docker build --secret id=pip_index_url,src=$HOME/.config/kestrel/pip-index-url -t registry.example.com/kestrel/meter-api:2.3.1 .
During that one step the variable PIP_INDEX_URL holds
the URL with its token; after the step it is gone, and neither
docker history nor the image configuration shows it.
Without env=, the secret appears as a file at
/run/secrets/pip_index_url for the duration of the
step. In CI, the secret comes from the CI system's secret store, not
from a file in the repository.
Multi-platform builds
Kestrel runs its services on amd64 servers, while some developers and test nodes use arm64. One tag should serve both, as a multi-platform image. BuildKit builds all variants in one command and pushes them with an index:
$ docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.example.com/kestrel/meter-api:2.3.1 --push .
For a platform the build machine cannot run natively, BuildKit
either uses emulation (QEMU), which works for everything but is slow
for heavy compile steps, or hands that platform to a remote builder
node with the right processor. Interpreted languages such as Python
are cheap to build this way; compiled code is often better
cross-compiled in a build stage. With the classic image store, as on
the Docker 28 recording machine, the default builder cannot build
for several platforms at once: create a separate builder first
(docker buildx create --driver docker-container --use)
and send the result to a registry with --push. The
containerd image store, the default on fresh Docker 29
installations, supports multi-platform builds directly and can keep
the result locally.