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
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:latestchanges 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
pythonimage 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 . /appcomes before the dependency install, so editing a single line ofapp.pyreinstalls every dependency.- The copy picks up whatever is in the directory:
.git, a local virtualenv,.envfiles with secrets. - The process runs as root, and the shell form of
CMDmeans Python isn't PID 1, sodocker stopwaits 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.
.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:
| Variant | Based on | When to use it |
|---|---|---|
python:3.13 | Full Debian with build tools | As a build stage when packages need compiling. |
python:3.13-slim | Minimal Debian | A sensible default for the final image. |
python:3.13-alpine | Alpine Linux with musl libc | Smallest, 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.
# 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:1asks BuildKit for the current stable Dockerfile frontend, which is what enablesRUN --mount.- The virtualenv lives at
/opt/venvin both stages, so copying that one directory brings every installed package across with its paths intact. --mount=type=cachekeeps 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-dirisn't needed here.- The runtime stage starts again from the slim image. If a package needed
gccto compile, you'd install it in the build stage only, and it wouldn't reach the final image. PYTHONUNBUFFERED=1makes Python write log lines immediately, sodocker logsshows 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.
| Form | PID 1 is | Receives SIGTERM? |
|---|---|---|
CMD ["python", "app.py"] (exec form) | Python | Yes |
CMD python app.py (shell form) | /bin/sh -c | No. 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.
.dockerignoreexcludes.git, local environments, logs and.envfiles.- 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 receivesSIGTERM. - No secrets in
ARG,ENVor copied files; build-time secrets use--mount=type=secret. - A
HEALTHCHECKthat 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
- Hardening Docker Containers: run the finished image with dropped capabilities and a read-only filesystem.
- Docker Compose: Multi-Container Apps: run the image alongside a database.
- Docker's building best practices and the Dockerfile reference cover every instruction in detail.