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:

  1. base image and operating system packages;
  2. dependency manifests (requirements.txt, package.json and its lock file) and their installation;
  3. your own code;
  4. configuration-only instructions such as CMD.

Three options control the cache when you need to:

  • --pull checks 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-cache runs 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
Two stages. The build stage, based on python:3.13-slim, copies build.py and runs it to produce dist/index.html. The final stage, based on nginx:alpine, copies only the dist folder from the build stage and exposes port 80. The build stage is discarded; the final stage is the 49.7 MB image that ships, against 144 MB for the single-stage version. FROM python:3.13-slim AS build Python, pip, Debian files COPY build.py . RUN python build.py FROM nginx:alpine nginx, Alpine files COPY --from=build dist/ EXPOSE 80 dist/ discarded after the build the image you ship: 49.7 MB single-stage version: 144 MB
Only the last stage becomes the image. Everything else is a workshop that is cleared away.

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 FROM lines 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.3 in requirements.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.