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.
# Slow: any source edit invalidates the dependency step
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o /out/app .# 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.
# 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 buildnames a stage andCOPY --from=buildtakes 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 buildstops at a named stage, which is handy for debugging. - Small base.
scratchis 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 setsCGO_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.
USERsets 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 thatUSERdoes not set a home directory. Read theUSERsection of the reference for the details. EXPOSEonly documents. It declares the port the app listens on. It does not publish it. Publishing needs-pat run time orports: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 . ..
.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:
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 :9090You 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 --digestsdocker 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.
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_onalone is not readiness. Compose orders start and stop, but it only waits until a container is running, not ready.condition: service_healthymakeswebwait untildb‘s healthcheck passes.- Healthcheck.
CMD-SHELLruns the test through a shell.$$is Compose’s escape for a literal dollar sign, so the container’s shell, not Compose, expandsPOSTGRES_USERandPOSTGRES_DB.start_periodgives the database time to initialise. - Named volume.
db-datais declared at the top level and mounted indb. It outlives the containers. The mount target depends on the image: the officialpostgresimage documents/var/lib/postgresqlfor PostgreSQL 18 and later, and/var/lib/postgresql/datafor 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 goneThose 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.gitor.envinto an image layer. - Secrets in
ARG,ENVorCOPY. Build arguments and environment variables persist in the final image. Use build secret mounts (docker build --secretandRUN --mount=type=secret) instead. - Running as root by default. Add
USERwith a non-root user or numeric ID. - Shell-form
CMDorENTRYPOINT. Signals may not reach your process, sodocker stopis 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 -vdeletes named volumes. - Assuming
depends_onmeans ready. Add a healthcheck andcondition: service_healthy.
Cheat sheet
| Goal | Command |
|---|---|
| Build and tag an image | docker build -t name:tag . |
| Build up to one stage | docker build --target build -t name:dev . |
| Run and clean up | docker run --rm -p 8080:8080 name:tag |
| List images with digests | docker image ls --digests |
| Add a reference | docker tag name:tag host:5000/ns/name:tag |
| Upload / download | docker push REF and docker pull REF |
| Validate a Compose file | docker compose config -q |
| Start and wait for health | docker compose up -d --wait |
| Status and logs | docker 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
.dockerignoreand 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
ENTRYPOINTorCMD. - 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
- Dockerfile reference
- Build cache
- Building best practices
- Multi-stage builds
- Build context and .dockerignore
- Base images and scratch
- Build secrets
- What is an image?
- docker image tag
- docker image push
- docker image pull
- docker image ls
- docker container run
- docker container stop
- Volumes
- Compose application model
- Compose services reference
- Compose volumes reference
- Compose interpolation
- Control startup order in Compose
- docker compose config
- docker compose down
- docker compose up
- Official postgres image (Docker Hub)
- cgo and CGO_ENABLED (Go)
Keep going
benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.