Docker 📖 10 min read

Dockerfile Best Practices

Most Dockerfiles work. Far fewer rebuild in seconds, produce an image a fraction of the size, run as an ordinary user and stop cleanly when asked. This guide takes a typical first Dockerfile for a Python service and fixes it one problem at a time, explaining the build cache, multi-stage builds, base image choices and secret handling along the way. The same ideas apply to any language.

Jump to: the build cache, multi-stage builds, running as non-root or the checklist.

Before you start

You need Docker Engine 23.0 or later, where BuildKit is the default builder for docker build. docker buildx version confirms the build plugin is installed. If you installed Docker from Docker's own repository as in the Docker Installation Guide, it already is.

docker version --format '{{.Server.Version}}'
docker buildx version

The examples build a small Python web service, because the problems they show are the same in every language: dependencies that reinstall on every build, compilers left in the final image, and a process running as root. The ideas carry over directly to Node.js, Go or Java.

The project looks like this:

myapp/
├── app.py
├── requirements.txt
├── Dockerfile
└── .dockerignore

The Dockerfile most people start with

Dockerfile (before)
FROM python:latest
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD python app.py

It works, and it has five problems:

  • python:latest changes underneath you. Today's build and next month's build can use different Python versions with nothing in the file to say so.
  • The full python image is built for compiling things. It's several hundred megabytes larger than the slim variant and carries a matching number of packages that security scanners will report.
  • COPY . /app comes before the dependency install, so editing a single line of app.py reinstalls every dependency.
  • The copy picks up whatever is in the directory: .git, a local virtualenv, .env files with secrets.
  • The process runs as root, and the shell form of CMD means Python isn't PID 1, so docker stop waits ten seconds and then kills it.

How the build cache works

Each instruction in a Dockerfile produces a layer. When you rebuild, BuildKit reuses a cached layer if the instruction and its inputs haven't changed. For COPY and ADD, the inputs are the contents of the files being copied. The first instruction whose inputs differ is rebuilt, and so is every instruction after it.

That gives the single most useful rule: copy files in order of how rarely they change. The dependency list changes occasionally; the source code changes constantly. Copy the dependency list, install, and only then copy the source:

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

Now a code change rebuilds only the final COPY. The install layer comes from cache, and a rebuild that took a minute takes a few seconds.

.dockerignore

Before the build starts, Docker sends the build context (the directory you pass to docker build) to the builder. A .dockerignore file keeps things out of it. That speeds up builds, stops COPY . . invalidating the cache whenever a log file changes, and keeps secrets out of the image.

.dockerignore
.git
.gitignore
.venv
__pycache__/
*.pyc
.env
*.log
Dockerfile
.dockerignore
node_modules
dist/

Patterns follow Go's filepath.Match rules with a few extras: ** matches any number of directories, and a line starting with ! re-includes something an earlier line excluded. If a build is mysteriously slow, check the first line of the build output, which reports how much context was transferred.

Choose and pin a base image

Most official language images come in three flavours:

VariantBased onWhen to use it
python:3.13Full Debian with build toolsAs a build stage when packages need compiling.
python:3.13-slimMinimal DebianA sensible default for the final image.
python:3.13-alpineAlpine Linux with musl libcSmallest, but prebuilt wheels compiled for glibc don't install, so some packages build from source or behave differently.

Pin at least the minor version (3.13-slim, not slim), and name the Debian release if you want to control when it changes, as in 3.13-slim-bookworm. For fully repeatable builds, pin the digest as well:

docker buildx imagetools inspect python:3.13-slim    # prints the manifest list digest
FROM python:3.13-slim@sha256:<digest from the command above>

A digest never changes, which also means it never picks up security fixes. If you pin digests, update them on a schedule, by hand or with a tool such as Renovate or Dependabot.

Multi-stage builds

A multi-stage Dockerfile uses one stage to build and a second, clean stage to run. Only what you explicitly copy across ends up in the final image, so compilers, headers and caches stay behind.

Dockerfile (after)
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.13-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"
RUN groupadd --system --gid 10001 app \
 && useradd --system --uid 10001 --gid app --no-create-home app
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY --chown=app:app . .
USER app
EXPOSE 8000
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)"]
CMD ["python", "app.py"]

What each part is doing:

  • # syntax=docker/dockerfile:1 asks BuildKit for the current stable Dockerfile frontend, which is what enables RUN --mount.
  • The virtualenv lives at /opt/venv in both stages, so copying that one directory brings every installed package across with its paths intact.
  • --mount=type=cache keeps pip's download cache between builds without writing it into a layer. Rebuilding after a dependency change downloads only what's new. Because the cache never reaches the image, --no-cache-dir isn't needed here.
  • The runtime stage starts again from the slim image. If a package needed gcc to compile, you'd install it in the build stage only, and it wouldn't reach the final image.
  • PYTHONUNBUFFERED=1 makes Python write log lines immediately, so docker logs shows them as they happen.

Build it, then compare sizes with the original:

docker build -t myapp:1.0 .
docker image ls myapp
docker history myapp:1.0      # one line per layer, with its size

docker history is the tool for finding out why an image is large. Look for a big layer you didn't expect, often a cache directory or a stray copy of the build context.

Run as a non-root user

Containers share the host's kernel. A process running as root inside a container is root on the host as far as the kernel is concerned, held back only by namespaces, capabilities and seccomp. If an attacker gets code execution in your app, a non-root user takes away a large set of easy next steps.

The runtime stage above creates a system user with a fixed UID, 10001, and switches to it with USER. A fixed number matters when the container writes to a bind mount: you can give that directory to UID 10001 on the host with chown 10001:10001 and know it will match.

Two consequences to plan for. A non-root user can't bind to ports below 1024, which is why the app listens on 8000 and the reverse proxy or -p 80:8000 takes care of the low port. And any directory the app writes to must be owned by that user, which is what COPY --chown=app:app does for the app directory.

CMD, ENTRYPOINT and signals

docker stop sends SIGTERM to PID 1 in the container, waits ten seconds, and then sends SIGKILL. Whether your app shuts down cleanly depends on which process is PID 1.

FormPID 1 isReceives SIGTERM?
CMD ["python", "app.py"] (exec form)PythonYes
CMD python app.py (shell form)/bin/sh -cNo. The shell doesn't forward it, so the app is killed after ten seconds.

Use the exec form. If you need a wrapper script to prepare something before the app starts, end the script with exec "$@" so the app replaces the shell as PID 1. If your app starts child processes and doesn't reap them, add --init to docker run (or init: true in Compose), which puts Docker's small init process in front of it.

ENTRYPOINT sets the fixed command and CMD sets default arguments that users can override. For an application image, a plain CMD is usually all you need.

Keep secrets out of layers

Every layer is stored in the image and can be read by anyone who can pull it. A RUN rm secret.txt later in the file doesn't help, because the earlier layer still contains the file. ARG and ENV values are also visible in docker history and docker inspect.

For credentials needed only during the build, such as a token for a private package index, use a secret mount. The secret is available to that one RUN instruction and is never written to a layer:

RUN --mount=type=secret,id=pip_index_url \
    PIP_INDEX_URL="$(cat /run/secrets/pip_index_url)" pip install -r requirements.txt
docker build --secret id=pip_index_url,src=./pip-index-url.txt -t myapp:1.0 .

Runtime secrets such as database passwords belong in the container's environment or a mounted file at run time, not in the image.

HEALTHCHECK

The HEALTHCHECK line makes Docker run a command inside the container every 30 seconds and mark the container healthy or unhealthy. Compose can wait for that state before starting dependent services, as the Docker Compose guide shows. The example uses Python's standard library because the slim image has no curl; installing curl just for a healthcheck adds packages you then have to keep patched.

A healthcheck only reports. Docker Engine doesn't restart an unhealthy container on its own, so pair it with your own monitoring or an orchestrator that acts on it.

Checklist

  • Base image tag pinned to at least a minor version, with a plan for updating it.
  • .dockerignore excludes .git, local environments, logs and .env files.
  • Dependency files copied and installed before the source code.
  • Build tools only in a build stage; the final stage copies artifacts across.
  • A fixed non-root UID set with USER.
  • Exec-form CMD, so the app receives SIGTERM.
  • No secrets in ARG, ENV or copied files; build-time secrets use --mount=type=secret.
  • A HEALTHCHECK that uses tools already in the image.

Troubleshooting

Every build reinstalls all dependencies

Something copied before the install step changes on every build. Check that the install step copies only the dependency files, and that .dockerignore excludes files that change constantly, such as logs or .git.

the --mount option requires BuildKit

You're using the legacy builder. On Docker Engine 23.0 and later, BuildKit is the default unless DOCKER_BUILDKIT=0 is set in your environment. Unset it, or use docker buildx build.

PermissionError or EACCES after adding USER

The app is writing somewhere the new user doesn't own. Find the path in the error, create it in the Dockerfile and give it to the user with chown before the USER line, or mount a volume there.

exec format error when the container starts

The image was built for a different CPU architecture, typically on an ARM laptop and run on an x86 server, or the reverse. Build for the target with docker buildx build --platform linux/amd64, or for both with --platform linux/amd64,linux/arm64 and --push to a registry.

Next steps