Docker & Containers — Level by Level›02 · Images & Dockerfiles

Lesson 02 of 13 · Level 1 — Containers & images

Images & Dockerfiles

Write Dockerfiles that build fast (good cache use), produce small images (multi-stage) and run safely (non-root). Understand layers, CMD vs ENTRYPOINT, and the .dockerignore file that keeps secrets out of your image.

Beginner → Practitioner
Key wordsDockerfileimage layersbuild cacheCMDENTRYPOINT.dockerignoremulti-stage buildUSERbuildx
Image = read-only layers (shared, cached) CMD ["python", "app.py"] metadata only COPY . . changes on every commit COPY requirements.txt + pip install changes rarely FROM python:3.12-slim base OS + Python Container = image + one writable layer writable layer gone when the container is removed the same image layers shared by every container started from this image keep data in a volume instead docker run
Each Dockerfile instruction adds a cached, read-only layer; a container adds one writable layer on top.

Images are layers

An image is a stack of read-only layers plus some metadata (which command to run, which user, which ports). Every RUN, COPY and ADD in a Dockerfile creates a new layer holding only what changed.

Two things follow from that:

  1. Layers are shared and cached. Ten images built on python:3.12-slim store that base only once, and a rebuild reuses every layer whose inputs didn't change.
  2. Deleting in a later layer doesn't remove anything. If one layer adds a 500 MB file and the next deletes it, the image is still 500 MB bigger, and the file can still be extracted from the earlier layer. That matters for secrets.

A stack of transparent sheets on an overhead projector. The bottom sheet has the map, the next adds roads, the next adds your house. To change your house you only redraw the top sheet. Drawing a white box over something on a higher sheet hides it, but it's still there on the sheet underneath.

A first Dockerfile

A small Python API, built with the cache in mind:

FROM python:3.12-slim

WORKDIR /app

# 1. Dependencies first: this layer is reused until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 2. Then the code, which changes on every commit
COPY . .

# 3. Don't run as root
RUN useradd --uid 10001 --no-create-home appuser
USER 10001

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
$ docker build -t shop/api:1.0 .
$ docker run -d --name api -p 8000:8000 shop/api:1.0
$ docker history shop/api:1.0      # one line per layer, with its size

Change one line of app.py and rebuild: the pip install step says CACHED and the build takes seconds.

.dockerignore: what never goes in

docker build . sends the whole folder (the build context) to the builder. A .dockerignore file next to the Dockerfile keeps things out:

.git
.env
*.pem
node_modules
__pycache__
dist/
Dockerfile*
docker-compose*.yml

This makes builds faster and, more importantly, stops .env files, keys and Git history from being baked into an image that you then push to a registry.

CMD vs ENTRYPOINT

Purpose Replaced by
CMD The default command, or default arguments Anything after the image name in docker run
ENTRYPOINT The fixed program Only --entrypoint

Used together, ENTRYPOINT is the program and CMD its default arguments:

ENTRYPOINT ["/usr/local/bin/backup"]
CMD ["--target", "/data"]

docker run backup-img --target /mnt runs /usr/local/bin/backup --target /mnt.

Always use the exec form (JSON array). The shell form CMD gunicorn app:app runs under /bin/sh -c, so the shell becomes PID 1, SIGTERM from docker stop doesn't reach your app, and every stop waits 10 seconds and ends in SIGKILL.

Multi-stage builds: small final images

Compile in one stage, ship from another. A Go service:

# Stage 1: build
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

# Stage 2: run
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

The Go toolchain, source code and module cache stay in the builder stage. The final image holds a single static binary on a minimal base with no shell and no package manager: typically a few tens of MB instead of close to a gigabyte, and far fewer packages for a scanner to flag.

Base image Good for Trade-off
debian:12-slim, ubuntu:24.04 Most apps, easy debugging Larger, more packages to patch
alpine:3.20 Small images musl libc; some Python/Node native modules behave differently
distroless, scratch Static binaries, Java/Python runtimes No shell: debug with nsenter or kubectl debug

Pin what you build on

FROM python:3.12-slim moves when upstream publishes a new patch. For repeatable builds, pin a specific tag, or the digest (FROM python:3.12-slim@sha256:...) and let a bot (Renovate, Dependabot) raise updates as pull requests.

Try it: shrink an image

  1. Build the Go example above with a single stage (FROM golang:1.23, build, CMD ["/out/server"]) and note the size with docker images.
  2. Build the two-stage version and compare.
  3. Run docker history on both and find the biggest layers.
  4. Add .env with a fake secret to the folder, build without a .dockerignore, then run docker run --rm --entrypoint cat <single-stage-image> /src/.env. Now add the .dockerignore and try again.

Going deeper: BuildKit, cache mounts and build secrets

Modern Docker builds with BuildKit (the docker build default since Engine 23.0; docker buildx exposes all its features). Two features are worth knowing:

# syntax=docker/dockerfile:1
# Keep pip's download cache between builds without putting it in a layer
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt

# Use a secret during the build without it ever being written to a layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
$ docker build --secret id=npmrc,src=$HOME/.npmrc -t web:1 .

Never pass secrets as ARG or ENV: both are recorded in the image metadata and visible to anyone who runs docker history or docker image inspect.

Recap

  • Images are read-only layers. Order instructions from least to most frequently changed so the cache does its job.
  • .dockerignore keeps .git, .env and keys out of the build context and the image.
  • ENTRYPOINT = the program, CMD = default arguments. Use the exec form so signals reach your app.
  • Multi-stage builds ship only the runtime and the built artefact. Run as a non-root USER.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.