A Dockerfile from top to bottom

A Dockerfile is the recipe for an image. The Cloud page explains a typical one line by line; this page covers the instructions in more depth, the mistakes people make with them, and how to read what docker build tells you. The examples build the Kestrel meter-api, a small Python service. This is the Dockerfile that was built on the recording machine:

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
USER 10001
CMD ["python", "app.py"]

Each line is one instruction: a keyword (by convention in capitals) followed by its arguments. Lines starting with # are comments, a backslash at the end of a line continues the instruction on the next one, and the builder runs the instructions in order. The ones you will meet most:

Instruction Does Adds a layer
FROM Chooses the base image; starts a build stage Uses its layers
RUN Runs a command during the build Yes
COPY, ADD Copy files from the build context into the image Yes
WORKDIR Sets (and creates) the working directory Configuration only
ENV, ARG Environment variable in the image; build-time variable Configuration only
USER The user for later RUN steps and the container Configuration only
EXPOSE, LABEL Document a port; add metadata Configuration only
CMD, ENTRYPOINT What the container runs when it starts Configuration only
HEALTHCHECK How the engine checks that the application works Configuration only

"Configuration only" instructions change the image's configuration document, not its files; in docker history they show as 0B (see Layers and the writable layer). A first line # syntax=docker/dockerfile:1 is a parser directive: it tells BuildKit to use the latest stable Dockerfile syntax of version 1, so newer features work even with an older Docker installation.

FROM and the base image

A base image is the existing image named in FROM that your image is built on top of. Its layers become the bottom layers of yours, so you inherit its operating system files, its packages, its default user and its vulnerabilities. Choosing it is the most important decision in a Dockerfile.

Official images come in variants, told apart by the tag. For Python 3.13, as an example:

Tag Contains
python:3.13 Full Debian with compilers and many libraries; large, handy for building
python:3.13-slim Minimal Debian plus Python; the usual choice to run
python:3.13-alpine Alpine Linux with the musl C library; smallest, but some Python packages behave differently
python:3.13-slim-trixie Slim, with the Debian release pinned in the tag

FROM scratch starts from nothing at all, for a single statically compiled program. The trade-offs between these, and distroless images, are on Building Good Images. Two rules apply everywhere: name a version in the tag (never rely on latest), and for internal images use the full registry host, as explained on Images, Layers and Registries.

ARG is the only instruction allowed before FROM, which lets a build choose the version:

ARG PYTHON_VERSION=3.13
FROM python:${PYTHON_VERSION}-slim

docker build --build-arg PYTHON_VERSION=3.12 . then builds the same image on Python 3.12.

RUN, COPY, WORKDIR, ENV and ARG

RUN

RUN executes a command in a temporary container during the build and saves the files it changed as a new layer. Each RUN starts fresh: a shell variable, a cd or a background process does not survive into the next instruction. Installing operating system packages has one correct pattern, with update and install in the same step:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

If apt-get update were a separate RUN, its layer would be cached, and months later an apt-get install step that changed would run against a stale package list and fail or install outdated versions. --no-install-recommends keeps optional extras out, and deleting the package lists in the same step keeps them out of the layer (deleting them in a later step would not shrink anything). The apt workflow is the same as on any Debian host.

For longer scripts, BuildKit accepts a heredoc: everything up to the closing marker is one RUN step.

RUN <<EOF
set -eu
groupadd --gid 10001 meter
useradd --uid 10001 --gid 10001 --no-create-home meter
mkdir -p /app/data
chown meter:meter /app/data
EOF

COPY and ADD

COPY <source>… <destination> copies files or directories from the build context into the image. A destination ending in / (or . after a WORKDIR) is a directory. Useful options: --chown=10001:10001 and --chmod=0644 set owner and permissions in the same step, and --from=<stage> copies from another build stage (see multi-stage builds).

ADD does what COPY does and more: it can download a URL and it unpacks local tar archives automatically. That magic is the reason to prefer COPY: a COPY does exactly what it says. Use ADD for automatic unpacking, or to download a remote file; pin a download with ADD --checksum=sha256:… <url> <dest> so the build fails if the file changes. For local files, use COPY.

WORKDIR

WORKDIR /app creates the directory if needed and makes it the current directory for every later instruction and for the running container. Use it instead of RUN cd /app, which changes directory only for that one RUN step.

ENV and ARG

ENV NAME=value sets an environment variable that later build steps see and that is stored in the image, so every container starts with it (and docker run -e can override it). ARG NAME=default declares a variable that exists only during the build and is set with --build-arg. Neither is a place for secrets: both end up readable in the image's configuration or history. How to give a build a password safely is on Building Good Images.

docker build and the build context

docker build -t kestrel-meter-api:1.0 . builds an image and tags it. The final . is not decoration: it is the build context, the set of files (usually a directory) that the client sends to the builder at the start of a build. COPY and ADD can only read from the context; a path such as ../shared/config.yaml outside it does not work. By default the builder looks for a file named Dockerfile in the context; -f names another one. The first build of the meter-api, with BuildKit's plain progress output (the download lines of the base image are shortened):

$ docker build -t kestrel-meter-api:1.0 .
#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 1.3s

#3 [internal] load .dockerignore
#3 transferring context: 2B done
#3 DONE 0.0s

#4 [internal] load build context
#4 transferring context: 1.02kB done
#4 DONE 0.0s

#5 [1/5] FROM docker.io/library/python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c
#5 resolve docker.io/library/python:3.13-slim@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c 0.0s done
…
#5 DONE 1.9s

#6 [2/5] WORKDIR /app
#6 DONE 0.4s

#7 [3/5] COPY requirements.txt .
#7 DONE 0.0s

#8 [4/5] RUN pip install --no-cache-dir -r requirements.txt
#8 2.327 Collecting requests==2.32.3 (from -r requirements.txt (line 1))
…
#8 3.283 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.5s

#9 [5/5] COPY app.py .
#9 DONE 0.0s

#10 exporting to image
#10 exporting layers 0.1s done
#10 writing image sha256:0c2475f1ca408798da73ea4b54716f9ebdc0963249c1954f9848ea1629c4613c done
#10 naming to docker.io/library/kestrel-meter-api:1.0 done
#10 DONE 0.1s

How to read it:

  • Steps #1 to #4 are internal: read the Dockerfile, look up the base image's current digest, read the .dockerignore file and transfer the context (1.02 kB here: two small files).
  • [1/5] to [5/5] are the instructions that produce layers. FROM resolved python:3.13-slim to an exact digest; that digest is what this build used, whatever the tag points to tomorrow.
  • The numbers after #8 are seconds since the step started, followed by the command's own output.
  • The step also printed a pip warning (left out above) about running pip as the root user. It is harmless here (the build step runs as root because USER comes later, and the image is the isolated environment pip asks for), but it is a reminder that build steps run as root unless you say otherwise.

In an interactive terminal the same build shows a compact, self-updating view instead; --progress=plain forces the full log, which is what you want in CI and when something fails. Files you do not want in the context, such as .git, .env or large data files, are excluded with a .dockerignore file; see Building Good Images.

CMD, ENTRYPOINT and exec form

Two instructions decide what a container runs when it starts, and both can be written in two forms. The exec form is a JSON array of the program and its arguments, such as CMD ["python", "app.py"]; the engine starts that program directly, so it becomes the container's main process (PID 1) and receives signals such as SIGTERM. The shell form, CMD python app.py, is wrapped as /bin/sh -c "python app.py": the shell is the main process, and depending on the shell your program may run as its child and never receive the stop signal. That is how a container ends up taking the full 10 seconds to stop and exiting with 137 (see Stopping, signals and PID 1).

  • CMD sets the default command of the container, or, when an ENTRYPOINT exists, its default arguments. Anything you type after the image name in docker run replaces it. Only the last CMD in a Dockerfile counts, and it does nothing during the build.
  • ENTRYPOINT sets the program that always runs when the container starts. Arguments from CMD or from docker run are appended to it. It is replaced only with docker run --entrypoint.

The nightly billing export is a good fit for the combination: the program is fixed, the date is a default you sometimes override.

# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY billing_export/ ./billing_export/
USER 10001:10001
ENTRYPOINT ["python", "-m", "billing_export"]
CMD ["--date", "yesterday"]
$ docker run --rm registry.example.com/kestrel/billing-export:1.0.7
$ docker run --rm registry.example.com/kestrel/billing-export:1.0.7 --date 2026-10-05

The first runs python -m billing_export --date yesterday; the second replaces only the CMD part, so Joost Bakker can rerun the export for 5 October without knowing how the program is started. The complete rules, written as a function:

def final_command(entrypoint=None, cmd=None, run_args=None):
    """The process a container starts. A list = exec form, a string = shell form."""
    if run_args:                           # arguments after the image name replace CMD
        cmd = run_args
    if isinstance(cmd, str):
        cmd = ["/bin/sh", "-c", cmd]
    if isinstance(entrypoint, str):        # shell-form ENTRYPOINT ignores CMD entirely
        return ["/bin/sh", "-c", entrypoint]
    return (entrypoint or []) + (cmd or [])

billing = ["python", "-m", "billing_export"]
cases = [
    ("CMD exec form", None, ["python", "app.py"], None),
    ("CMD shell form", None, "python app.py", None),
    ("ENTRYPOINT + CMD", billing, ["--date", "yesterday"], None),
    ("... with run args", billing, ["--date", "yesterday"], ["--date", "2026-10-05"]),
    ("ENTRYPOINT shell form", "python -m billing_export", ["--date", "yesterday"], None),
]
for label, entrypoint, cmd, run_args in cases:
    print(f"{label:22} {final_command(entrypoint, cmd, run_args)}")
CMD exec form          ['python', 'app.py']
CMD shell form         ['/bin/sh', '-c', 'python app.py']
ENTRYPOINT + CMD       ['python', '-m', 'billing_export', '--date', 'yesterday']
... with run args      ['python', '-m', 'billing_export', '--date', '2026-10-05']
ENTRYPOINT shell form  ['/bin/sh', '-c', 'python -m billing_export']

The last line is the trap in the shell form of ENTRYPOINT: CMD and docker run arguments are silently ignored, and the program does not receive signals.

EXPOSE, USER, LABEL and HEALTHCHECK

EXPOSE 8000 records that the application listens on port 8000. It publishes nothing; a port becomes reachable from outside only with -p at run time. Write it anyway: it documents the image and tools read it.

USER sets the user for all later RUN steps and for the running container. Without it, the application runs as root. The recorded meter-api image has USER 10001:

$ docker exec meter-api id
uid=10001 gid=0(root) groups=0(root)

Not root, but still in group 0 (root), because only a user ID was given. Write USER 10001:10001 to set the group too. A numeric ID works even if no such user exists in the image's /etc/passwd, and Kubernetes can verify that a numeric user is not 0, which it cannot do for a name. Put USER after the steps that need root (installing packages) and before CMD.

LABEL adds metadata as key-value pairs. The OCI defines standard keys, which registries and scanners display:

LABEL org.opencontainers.image.source="https://git.example.com/kestrel/meter-api" \
      org.opencontainers.image.version="2.3.0" \
      org.opencontainers.image.vendor="Kestrel Energy"

HEALTHCHECK tells the engine how to test whether the application inside works, not just whether its process exists. The meter-api has a /health endpoint; because the slim image has no curl, the check uses Python itself:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]

Without options, a check runs every 30 seconds with a 30-second timeout, and three failures in a row mark the container unhealthy. What that status does and does not trigger is explained on Debugging Containers.

Put together, an improved meter-api Dockerfile:

# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
CMD ["python", "app.py"]

Reading build errors

BuildKit reports a failed build with the failing step, a short excerpt of the Dockerfile with the line marked >>>, and the error. Three typical failures, recorded. First, a file that is not in the build context, here because of a typo:

$ docker build -f Dockerfile.typo -t kestrel-bad:typo .
…
#7 [3/5] COPY requirments.txt .
#7 ERROR: failed to calculate checksum of ref a410a5ad-c0ad-43c9-948b-bd11a974d81a::bb4ca9dqfa0btahs2y88rmwv7: "/requirments.txt": not found
------
 > [3/5] COPY requirments.txt .:
------
Dockerfile.typo:3
--------------------
   1 |     FROM python:3.13-slim
   2 |     WORKDIR /app
   3 | >>> COPY requirments.txt .
   4 |     RUN pip install --no-cache-dir -r requirements.txt
   5 |     COPY app.py .
--------------------
ERROR: failed to solve: failed to compute cache key: failed to calculate checksum of ref a410a5ad-c0ad-43c9-948b-bd11a974d81a::bb4ca9dqfa0btahs2y88rmwv7: "/requirments.txt": not found

The essential words are at the end: "/requirments.txt": not found. The path is relative to the build context, so the same error appears when the file exists but lies outside the context, or is excluded by .dockerignore. Second, an instruction the parser does not know:

$ docker build -f Dockerfile.syntax -t kestrel-bad:syntax .
#0 building with "default" instance using docker driver

#1 [internal] load build definition from Dockerfile.syntax
#1 transferring dockerfile: 159B 0.0s done
#1 DONE 0.0s
Dockerfile.syntax:4
--------------------
   2 |     WORKDIR /app
   3 |     COPY app.py .
   4 | >>> RUNN pip install --no-cache-dir requests
   5 |     CMD ["python", "app.py"]
   6 |
--------------------
ERROR: failed to solve: dockerfile parse error on line 4: unknown instruction: RUNN (did you mean RUN?)

A parse error stops the build before any step runs (only #1 appears). Third, a command inside RUN that fails, here a package version that does not exist:

$ docker build -f Dockerfile.runfail -t kestrel-bad:runfail .
…
#8 [4/4] RUN pip install --no-cache-dir requests==99.0.0
#8 2.309 ERROR: Ignored the following yanked versions: 2.32.0, 2.32.1
#8 2.309 ERROR: Could not find a version that satisfies the requirement requests==99.0.0 (from versions: 0.2.0, 0.2.1, … 2.34.1, 2.34.2)
#8 2.309 ERROR: No matching distribution found for requests==99.0.0
#8 ERROR: process "/bin/sh -c pip install --no-cache-dir requests==99.0.0" did not complete successfully: exit code: 1
…
ERROR: failed to solve: process "/bin/sh -c pip install --no-cache-dir requests==99.0.0" did not complete successfully: exit code: 1

The last line says only that a process exited with code 1; the reason is the command's own output a few lines up, prefixed with #8. Note also /bin/sh -c: a RUN in shell form runs through the shell, which is exactly what you want during a build. When the output scrolls by too fast, rebuild with --progress=plain and read from the bottom up.

Checking a Dockerfile

BuildKit runs a set of build checks during every build and prints warnings for things like a shell-form CMD or inconsistent capitalization; docker build --check . runs only the checks, without building. Dedicated linters such as hadolint go further. The idea behind all of them is simple: read the instructions and compare them with known good practice. A small version of such a checker, for a Dockerfile with five classic problems:

dockerfile = """\
FROM python:latest
WORKDIR /app
ADD https://downloads.example.com/tariffs.csv /app/
COPY . .
RUN apt-get update
RUN apt-get install -y \\
    curl
CMD python app.py
"""

def instructions(text):
    """Yields (line number, keyword, arguments); joins lines ending in a backslash."""
    start, parts = None, []
    for number, line in enumerate(text.splitlines(), start=1):
        if not parts and (not line.strip() or line.lstrip().startswith("#")):
            continue
        start = start or number
        parts.append(line.rstrip("\\").strip())
        if not line.endswith("\\"):
            keyword, _, args = " ".join(parts).partition(" ")
            yield start, keyword.upper(), args
            start, parts = None, []

seen = set()
for number, keyword, args in instructions(dockerfile):
    seen.add(keyword)
    if keyword == "FROM" and (":" not in args or args.endswith(":latest")):
        print(f"line {number}: base image without a fixed version: {args}")
    if keyword == "ADD" and "://" in args and "--checksum=" not in args:
        print(f"line {number}: ADD from a URL without --checksum; pin the download")
    if keyword == "RUN" and args == "apt-get update":
        print(f"line {number}: apt-get update in its own RUN; the cached list goes stale")
    if keyword in ("CMD", "ENTRYPOINT") and not args.startswith("["):
        print(f"line {number}: {keyword} in shell form; SIGTERM may not reach the app")
if "USER" not in seen:
    print("no USER instruction: the container runs as root")
line 1: base image without a fixed version: python:latest
line 3: ADD from a URL without --checksum; pin the download
line 5: apt-get update in its own RUN; the cached list goes stale
line 8: CMD in shell form; SIGTERM may not reach the app
no USER instruction: the container runs as root

It does not flag COPY . ., which is correct but slow when it comes before the dependency installation; why, and how to order a Dockerfile for the build cache, is the first subject of Building Good Images.

Dockerfile, Containerfile and other builders

The Dockerfile format is not tied to Docker. A Containerfile is the vendor-neutral name for the same file, with the same syntax, used by Podman and Buildah: podman build looks for a file named Containerfile and falls back to Dockerfile. Cloud build services and CI systems read the format too. Because the result is an OCI image (see the OCI standards), it does not matter which tool built it.

Small differences remain at the edges: some BuildKit features, such as certain RUN --mount options, are not supported by every builder or version. If a Dockerfile must build with several tools, test it with each of them in CI.