Guide: Containers

Docker Essentials

Images, layers, registries and Compose, in the order you meet them: what an image is, why build order matters, how to write a Dockerfile you can trust in production, how images travel, and how to run two services together.

Images, containers and layers

A container image is a standardized package with the files, binaries, libraries and configuration needed to run a container. A container is an isolated process started from that image. The image is the recipe and the filesystem, the container is the running thing.

Two properties matter every day. Images are immutable: you never edit one, you build a new one or add changes on top. Images are also composed of layers: each layer is a set of filesystem changes that add, remove or modify files. A running container gets its own writable layer, and when the container is destroyed that layer is destroyed with it. Anything you want to keep must live in a volume (see the Compose section).

Mental model. Image = read-only, layered, reusable. Container = one disposable run of an image. Advice, not from the docs: if you find yourself fixing a running container by hand, change the Dockerfile instead.

How layers and the build cache work

Each instruction in a Dockerfile creates a corresponding layer. When Docker builds, it checks for every instruction whether it can reuse the cached result. If a layer changes, every layer after it must be rebuilt too, even if those later steps would produce the same result. So the order of instructions decides how fast your builds are.

The rule is simple: put what changes rarely and costs a lot near the top, put what changes often near the bottom, and copy files just before the command that needs them.

Bad order
# Slow: any source edit invalidates the dependency step
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o /out/app .
Good order
# Fast: dependency files first, sources later
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

In the good order, editing main.go only invalidates the last two steps. The download step stays cached until go.mod changes. The same idea applies to package.json, requirements.txt and any other dependency manifest. If a project has a lock file such as go.sum, copy it together with the manifest.

A good Dockerfile

This example builds a small Go program in one stage and ships only the binary in a second stage. The program itself (a tiny HTTP server with an -addr flag) is not shown, and the Go version tag is an example: use the version your project targets.

Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

FROM scratch
COPY --from=build /out/app /app
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["/app"]
CMD ["-addr", ":8080"]

What each choice buys you

  • Multi-stage build. FROM ... AS build names a stage and COPY --from=build takes files from it. The compiler and sources stay in the first stage, so the final image only contains what you copy in. docker build --target build stops at a named stage, which is handy for debugging.
  • Small base. scratch is a reserved, minimal (explicitly empty) image that you can only reference in a Dockerfile. It suits small, simple programs: anything your program needs at runtime (for example dynamically linked C libraries or CA certificates) must be copied in or absent by design. The example sets CGO_ENABLED=0, which disables Go’s cgo, so the program does not rely on C libraries. For anything larger, start from a small general-purpose base image instead.
  • Non-root user. USER sets the user for later instructions and for the container at run time. The reference says to use numeric IDs when the user does not exist in the base image, which is the case on an empty image, and that USER does not set a home directory. Read the USER section of the reference for the details.
  • EXPOSE only documents. It declares the port the app listens on. It does not publish it. Publishing needs -p at run time or ports: in Compose.

Keep the build context small

The build context is the set of files your build can access. A .dockerignore file in the root of the context keeps files out of it: one pattern per line, with # for comments (the build context page has the full syntax). This speeds up builds and keeps things like .git or local secrets from being copied by COPY . ..

.dockerignore
.git
*.md
.env
out/

Pin tags or digests?

A tag is mutable: a publisher can point alpine:3.21 at a new image tomorrow. A digest is a content-addressable identifier, so the same digest is always the same image. Docker’s guidance shows both together:

Schematic, not a real digest
FROM golang:1.26@sha256:<digest>

The trade-off, as the docs put it, is that pinning by digest avoids surprises but you opt out of automatic security fixes, and updating the digest by hand is tedious. The build best practices page suggests automating the updates (for example with Dependabot). Which policy to adopt is your call: the docs describe the trade-off, they do not mandate one.

CMD versus ENTRYPOINT

ENTRYPOINT is the executable the container runs, and CMD supplies its default arguments. Arguments you put after the image name in docker run replace CMD but not ENTRYPOINT, and with only an ENTRYPOINT they are appended to it. Use --entrypoint to override the executable itself. The exec form is a JSON array, as above. The shell form runs the command under /bin/sh -c, so the shell sits between Docker and your program and signals may not reach it. docker stop sends SIGTERM to the container’s main process and, after a grace period, SIGKILL. Prefer the exec form for the program you want to stop cleanly.

$ docker run --rm -p 8080:8080 myapp:1.0
# same image, different default argument (replaces CMD)
$ docker run --rm -p 9090:9090 myapp:1.0 -addr :9090

You cannot run these here without a Docker daemon. When you do, check that the second command serves on port 9090 while the first serves on 8080.

Registries: tag, push, pull, digest

A registry stores images. An image reference has the form [HOST[:PORT]/]NAMESPACE/REPOSITORY[:TAG]. Without a host, Docker uses Docker Hub. Without a tag, it uses latest. docker tag creates one more reference to an existing image without duplicating its data, and docker push uploads it, using credentials from docker login.

# registry.example.com:5000 is a placeholder host
$ docker build -t myapp:1.0 .
$ docker tag myapp:1.0 registry.example.com:5000/team/myapp:1.0
$ docker login registry.example.com:5000
$ docker push registry.example.com:5000/team/myapp:1.0
$ docker pull registry.example.com:5000/team/myapp:1.0
$ docker image ls --digests

docker image ls --digests adds a DIGEST column to the default output. To run exactly one image forever, pull or reference it as NAME@sha256:<digest>. Pulling a tag again can fetch a newer image (useful for patches, risky for reproducibility). Pulling a digest never changes.

Suggested practice (the author’s advice, not a Docker rule). Publish a version tag (1.0.3) for humans and deploy by digest where you need reproducibility. Avoid deploying a bare latest: you cannot tell later what it was.

Compose: two services, one command

Compose describes a multi-container app in a file named compose.yaml (the preferred default name). This one runs the image from the previous Dockerfile plus a PostgreSQL database with a named volume and a healthcheck.

compose.yaml
services:
  web:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy
    environment:
      DB_HOST: db
      DB_NAME: appdb
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  db-data:

Points to notice:

  • depends_on alone is not readiness. Compose orders start and stop, but it only waits until a container is running, not ready. condition: service_healthy makes web wait until db‘s healthcheck passes.
  • Healthcheck. CMD-SHELL runs the test through a shell. $$ is Compose’s escape for a literal dollar sign, so the container’s shell, not Compose, expands POSTGRES_USER and POSTGRES_DB. start_period gives the database time to initialise.
  • Named volume. db-data is declared at the top level and mounted in db. It outlives the containers. The mount target depends on the image: the official postgres image documents /var/lib/postgresql for PostgreSQL 18 and later, and /var/lib/postgresql/data for 17 and below. Check the image documentation for the tag you use.
  • Required variable. ${DB_PASSWORD:?set DB_PASSWORD} stops with an error if the variable is empty or unset, so the password is not stored in the file.

Validate before you start anything. docker compose config resolves variables and prints the normalized model, and it needs no daemon:

$ docker compose config -q
error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable DB_PASSWORD is missing a value: set DB_PASSWORD
$ DB_PASSWORD=example docker compose config -q
$ echo $?
0
$ DB_PASSWORD=example docker compose config --services
db
web
$ DB_PASSWORD=example docker compose config --volumes
db-data

-q only validates and prints nothing. Without it, you get the full expanded configuration, where short forms such as db-data:/var/lib/postgresql appear as explicit type: volume entries.

$ export DB_PASSWORD=change-me
$ docker compose up -d --wait
$ docker compose ps
$ docker compose logs -f web
$ docker compose down
# removes containers and networks, keeps the named volume
$ docker compose down -v
# also removes the named volumes: the database data is gone

Those last commands need a daemon, so they were not run for this guide. --wait waits for the services to be running or healthy (and implies detached mode), and web should only start once db is healthy.

Common mistakes

  • Copying everything first. COPY . . before installing dependencies busts the cache on every edit.
  • No .dockerignore. A large context slows every build and can pull .git or .env into an image layer.
  • Secrets in ARG, ENV or COPY. Build arguments and environment variables persist in the final image. Use build secret mounts (docker build --secret and RUN --mount=type=secret) instead.
  • Running as root by default. Add USER with a non-root user or numeric ID.
  • Shell-form CMD or ENTRYPOINT. Signals may not reach your process, so docker stop is slow or unclean. Use the exec form.
  • Trusting EXPOSE. It does not publish a port.
  • Deploying latest. It is just a tag, and it moves.
  • Storing data in the container. The writable layer is destroyed with the container. Use a volume, and remember that docker compose down -v deletes named volumes.
  • Assuming depends_on means ready. Add a healthcheck and condition: service_healthy.

Cheat sheet

GoalCommand
Build and tag an imagedocker build -t name:tag .
Build up to one stagedocker build --target build -t name:dev .
Run and clean updocker run --rm -p 8080:8080 name:tag
List images with digestsdocker image ls --digests
Add a referencedocker tag name:tag host:5000/ns/name:tag
Upload / downloaddocker push REF and docker pull REF
Validate a Compose filedocker compose config -q
Start and wait for healthdocker compose up -d --wait
Status and logsdocker compose ps, docker compose logs -f SERVICE
Stop (keep data / remove data)docker compose down / docker compose down -v

Before you ship an image

  • Dependency manifests are copied and installed before the sources.
  • There is a .dockerignore and it excludes VCS data and local secrets.
  • Multi-stage build, and the final stage contains only what runs.
  • The base image tag or digest is chosen deliberately, and there is a process to update it.
  • The container runs as a non-root user and uses exec-form ENTRYPOINT or CMD.
  • No secrets in build args, environment variables or copied files.
  • Compose services that need a ready dependency use a healthcheck and service_healthy.

Official documentation

Keep going

benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.

Scroll to Top