☁ Cloud & DevOps

A well-written Dockerfile: small, secure, reproducible images

How to write an effective Dockerfile: base image, layer order, multi-stage builds, a non-root user and .dockerignore, with an annotated example.

A Dockerfile takes ten minutes to write, but the difference between one written in a hurry and one written well shows for months: one-gigabyte images instead of hundred-megabyte ones, builds that take five minutes after every change, containers running as administrator for no reason. The good practices are few and apply to almost every language.

Choosing the base image

The first line, FROM, determines much of the result. There are three typical options:

  • full images (for example python:3.12): convenient, they contain compilers and tools, but they are large;
  • slim images (python:3.12-slim): based on a trimmed-down Debian, a good compromise for most applications;
  • minimal or distroless images: they contain little more than the runtime, are small and offer a smaller attack surface, but have no shell and no diagnostic tools.

Alpine-based images are very small, but they use the musl C library instead of glibc. With some languages, Python in particular, this can force you to compile packages that would otherwise arrive prebuilt, slowing builds down. They should be chosen deliberately, not out of habit.

Always specify an exact version. The latest tag changes over time, and a build that works today can break next month without you touching anything.

Instruction order and the cache

Docker reuses a layer if the instruction and the files it depends on have not changed. As soon as one layer changes, all the following ones are rebuilt. So the rule is: what changes rarely goes at the top, what changes often goes at the bottom.

The most common mistake is copying all the code and then installing dependencies. That way every change to a source file invalidates the cache and forces all libraries to be reinstalled. The right order is to copy only the dependency file first, install the dependencies, and only then copy the rest of the code.

Multi-stage builds

Many applications need tools to be built, but not to run. A Go or Rust project needs the compiler; a JavaScript frontend needs Node to generate static files. Carrying these tools into production is both wasteful and risky.

A multi-stage build solves the problem: you define several stages in the same Dockerfile, and the final image copies from the build stage only what it needs.

# Stage 1: build
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: runtime
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /install /usr/local
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

In this example dependencies are installed into a separate folder in the first stage, then copied into the second. With compiled languages the gain is even larger: the final image can contain a single executable.

Don't run as root

By default the process inside a container runs as the root user. If an attacker exploits a vulnerability in the application, they end up with full privileges inside the container, and one step closer to the host. Creating a dedicated user and switching to it with the USER instruction costs two lines and greatly reduces the possible damage.

As a consequence, the application must listen on a port above 1024 and write only to folders owned by that user.

The .dockerignore file

When you start a build, Docker sends the whole content of the folder, called the context, to the engine. Without a .dockerignore file, the context ends up including the .git folder, local dependencies, temporary files and, in the worst case, .env files with real passwords. A minimal example to start from:

.git
node_modules
__pycache__
*.log
.env

Builds get faster and you avoid baking secrets into the image, where anyone able to pull it could read them.

Other useful precautions

  • Combine related commands: installing packages and cleaning the same package manager's cache in the same RUN keeps temporary files out of a layer.
  • Use the JSON form for CMD and ENTRYPOINT: this way the process correctly receives stop signals and the container shuts down cleanly.
  • Don't put secrets in ENV or ARG: they remain visible in the image history. Pass them at runtime.
  • Add a HEALTHCHECK when the orchestrator does not provide its own, to know whether the application actually responds and not just whether the process is alive.
  • Tag images with the version or commit identifier, not just latest, so you always know what is running.

Checking the result

After the build, check the size with docker images and the layers with docker history: an unexpectedly large layer usually points to a file copied by mistake or a cache that was not cleaned. A vulnerability scanner, such as Trivy or the tools built into registries, flags base libraries that need updating. Rebuilding the image periodically, even without code changes, pulls in the base image's security fixes.

In short

  • Start from a small base image with a pinned version, never from latest.
  • Order instructions from stable to frequently changing to take advantage of the cache.
  • Use multi-stage builds to keep compilers and tools out of the final image.
  • Run the application as a non-root user and keep secrets out of the image.
  • Check size and vulnerabilities, and rebuild periodically to apply updates.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. With degrees in computer science and psychology, he combines technical analysis with an understanding of the human factor.

Read also