Writing Dockerfiles
Describing an image in a Dockerfile: FROM, RUN, COPY, WORKDIR, ENV, EXPOSE, the difference between CMD and ENTRYPOINT, and building with docker build.
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
#1to#4are internal: read the Dockerfile, look up the base image's current digest, read the.dockerignorefile and transfer the context (1.02 kB here: two small files). -
[1/5]to[5/5]are the instructions that produce layers.FROMresolvedpython:3.13-slimto an exact digest; that digest is what this build used, whatever the tag points to tomorrow. -
The numbers after
#8are 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
USERcomes 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).
-
CMDsets the default command of the container, or, when anENTRYPOINTexists, its default arguments. Anything you type after the image name indocker runreplaces it. Only the lastCMDin a Dockerfile counts, and it does nothing during the build. -
ENTRYPOINTsets the program that always runs when the container starts. Arguments fromCMDor fromdocker runare appended to it. It is replaced only withdocker 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.