Education › DevOps › Stage 2: Build & ship

Containers with Docker

Images, layers, multi-stage builds, Compose, and small secure base images.

Beginner–Intermediate ~35 min read Module 5 of 17

Containers solved "it works on my machine" by shipping the machine along with the code: your application, its runtime and its libraries in one immutable image that runs the same on a laptop, in CI and in production. Everything later in this track, from CI/CD to Kubernetes, moves images around. This module teaches you to build images that are small, fast to rebuild and safe to run.

After this module you can
  • Explain what a container is in terms of processes, namespaces and images, and how it differs from a virtual machine
  • Write a Dockerfile that uses the layer cache well and produces a small image with a multi-stage build
  • Run, inspect and debug containers: ports, environment, volumes, logs and exec
  • Describe a multi-service development environment with Docker Compose
  • Apply the basic hardening rules: non-root user, minimal base image, pinned versions, no secrets in layers

What a container actually is

A container is not a small virtual machine. It is an ordinary Linux process that the kernel has isolated. Namespaces give it its own view of the process list, network interfaces, hostname and filesystem; cgroups cap how much CPU and memory it may use. There is no guest operating system and no boot, which is why a container starts in milliseconds and a host can run hundreds.

An image is the read-only template: a filesystem plus metadata such as the default command. A container is a running (or stopped) instance of an image with a thin writable layer on top. Delete the container and that layer is gone, so anything worth keeping must live in a volume or an external service. Images are stored in and pulled from a registry.

Virtual machineContainer
IsolationOwn kernel, virtual hardwareShares the host kernel
Start timeTens of secondsWell under a second
SizeGigabytesMegabytes to a few hundred
Boundary strengthStrongWeaker: a kernel bug affects everyone
Note

Because containers share the host kernel, Linux containers need a Linux kernel. Docker Desktop on macOS and Windows quietly runs a small Linux VM for you.

Running and inspecting containers

bash
docker run --rm -it ubuntu:24.04 bash     # interactive shell, removed on exit
docker run -d --name web -p 8080:80 nginx:1.27   # detached; host 8080 -> container 80
docker ps                                  # running containers (-a includes stopped)
docker logs -f web                         # follow stdout/stderr
docker exec -it web sh                     # a shell inside the running container
docker inspect web                         # full JSON: mounts, network, env, state
docker stop web && docker rm web           # SIGTERM, then remove

Three flags carry most real-world configuration. -p HOST:CONTAINER publishes a port. -e NAME=value sets an environment variable, which is how twelve-factor apps receive configuration. -v mounts storage: a named volume (-v pgdata:/var/lib/postgresql/data) is managed by Docker and survives the container, while a bind mount (-v "$PWD":/app) maps a host directory in, which is handy in development.

docker stop sends SIGTERM, waits ten seconds by default, then sends SIGKILL, exactly the sequence from the Linux module. A container that always takes ten seconds to stop is one whose main process is not handling SIGTERM.

Dockerfiles and the layer cache

A Dockerfile is the recipe for an image. Each instruction produces a layer, and layers are cached: if an instruction and everything it depends on are unchanged, Docker reuses the previous result. The moment one layer changes, every layer after it is rebuilt. So the ordering rule is: put what changes least first.

dockerfile
FROM python:3.12-slim

WORKDIR /app

# 1. dependencies change rarely: copy only the manifest, then install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 2. source code changes constantly: copy it last
COPY . .

RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

With that order, editing a source file invalidates only the final COPY, and the slow dependency install is served from cache. Copying everything before pip install would reinstall every dependency on every build.

  • A .dockerignore file keeps .git, node_modules, virtualenvs and local .env files out of the build context, which speeds up builds and prevents secrets from leaking into the image.
  • CMD in exec form (a JSON array) runs your program as PID 1 so it receives signals. The shell form (CMD gunicorn ...) wraps it in /bin/sh -c, and the shell may not forward SIGTERM.
  • ENTRYPOINT fixes the executable and CMD supplies default arguments that docker run IMAGE args can override.
  • EXPOSE is documentation only. Publishing a port still needs -p.
  • Chain related commands in one RUN and clean up in the same instruction; a file deleted in a later layer still takes up space in the earlier one.
bash
docker build -t myapp:1.4.0 .          # build from ./Dockerfile with context .
docker history myapp:1.4.0             # size of each layer
docker images                          # local images and their sizes

Multi-stage builds

Compilers, build tools and dev dependencies are needed to build your app but not to run it. A multi-stage build uses one stage with the full toolchain to produce the artifact, then copies only that artifact into a clean, minimal final image. Everything in the earlier stages is discarded.

dockerfile
# ---- build stage: full Go toolchain ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

# ---- final stage: just the binary ----
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
DOCKER BUILD: ONLY THE FINAL STAGE IS KEPTCOPY, RUN go buildproducesCOPY --from=builddocker pushpull and runSourcecode + lockfileBuild stagegolang:1.23, ~800 MB/out/serverthe artifactFinal stagedistroless, ~10 MBRegistrymyapp:1.4.0ContainerUSER nonroot
A multi-stage build: the toolchain lives in a throwaway build stage, only the compiled artifact is copied into a minimal final image, and that small image is what gets pushed and run.

The build stage is hundreds of megabytes; the final image is the size of the binary plus a few megabytes. A smaller image pulls faster, starts faster on a new node, and above all contains less software that can have vulnerabilities. The same pattern works for Node (build the bundle, serve it from a slim image), Java (build with a JDK, run on a JRE) and Python (build wheels, install them into a slim image).

Base imageCharacter
ubuntu, debianFull distribution. Easy to debug, large, many packages to patch.
*-slimTrimmed Debian. A good default for interpreted languages.
alpineVery small, but uses musl libc, which occasionally breaks native Python or Node modules.
distroless / scratchNo shell or package manager at all. Smallest attack surface, harder to debug.

Compose for multi-container development

Real applications need a database, a cache, perhaps a queue. Docker Compose describes the whole set in one compose.yaml and starts it with one command. Every service joins a private network where it can reach the others by service name, so the app connects to the hostname db, not to localhost.

yaml
services:
  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://app:devpassword@db:5432/app
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10
volumes:
  pgdata:
bash
docker compose up -d --build     # build, create and start everything
docker compose ps                # status, including health
docker compose logs -f app       # follow one service
docker compose exec db psql -U app   # run a command in a service
docker compose down              # stop and remove (add -v to delete volumes too)

Plain depends_on only orders start-up; it does not wait for the database to accept connections. The healthcheck plus condition: service_healthy does. Compose is a development and single-host tool. In production the same images run under an orchestrator, which is the Kubernetes module.

Small and secure by default

  • Do not run as root. By default the process in a container is root, and a container escape then lands as root on the host. Add a USER instruction.
  • Pin the base image. FROM python:latest means tomorrow's build may differ from today's. Pin a specific version, and for full reproducibility pin the digest (image@sha256:...).
  • Never bake secrets in. ENV, ARG and COPY all persist in image layers and show up in docker history. Inject secrets at runtime, or use BuildKit's RUN --mount=type=secret for build-time credentials.
  • Keep it minimal and rebuild often. Fewer packages means fewer CVEs. Rebuilding regularly picks up base-image patches; scanning is covered in the supply chain module.
  • One concern per container. Logs go to stdout and stderr, where docker logs and every orchestrator collect them.
Watch out

Mounting /var/run/docker.sock into a container, or running with --privileged, hands that container control of the host. Treat both as equivalent to giving it root on the machine.

Hands-on practice

Shrink an image and wire it to a database

  1. Take a small web app in any language (a ten-line Flask or Express app is enough). Write a naive Dockerfile with a full base image and COPY . . before the dependency install. Build it and record the size from docker images.
  2. Change one line of source and rebuild. Watch the dependency install run again. Reorder the Dockerfile so dependencies are installed before the source is copied, rebuild twice, and see the install served from cache.
  3. Add a .dockerignore, switch to a slim base, add a non-root USER, and use exec-form CMD. If the language is compiled or has a build step, make it multi-stage. Compare the size and the docker history output.
  4. Run it with -p, confirm docker exec CONTAINER id shows a non-root user, and time docker stop. If it takes ten seconds, make the app handle SIGTERM.
  5. Write a compose.yaml with the app and PostgreSQL, a named volume, and a healthcheck with condition: service_healthy. Prove the data survives docker compose down followed by up.
  6. Run docker compose down -v, bring it back up, and confirm the data is gone. Be sure you can explain why.
Cheat sheet

Containers with Docker — at a glance

Main things to focus on

  • A container is an isolated process sharing the host kernel, not a VM. An image is the immutable template.
  • Layer cache: order instructions from least to most frequently changing. Copy the dependency manifest, install, then copy source.
  • Multi-stage builds: build with the toolchain, ship only the artifact.
  • The container filesystem is disposable. Persistent data belongs in volumes or external services.
  • Run as non-root, pin base image versions, never put secrets in ENV, ARG or COPY.
  • Exec-form CMD so the app is PID 1 and receives SIGTERM.
  • In Compose, services reach each other by service name, and depends_on needs a healthcheck to truly wait.

Run and inspect

docker run --rm -it IMAGE shThrowaway interactive container
docker run -d --name NAME -p 8080:80 IMAGEDetached, host port 8080 to container port 80
docker run -e KEY=value -v VOL:/path IMAGEEnvironment variable and volume
docker ps -aAll containers, including stopped
docker logs -f --tail 100 NAMEFollow the last 100 log lines
docker exec -it NAME shShell in a running container
docker inspect NAMEFull configuration and state as JSON
docker statsLive CPU and memory per container

Build and publish

docker build -t NAME:TAG .Build from the Dockerfile in the current directory
docker build --target STAGE -t NAME:TAG .Stop at a named stage of a multi-stage build
docker history NAME:TAGLayers and their sizes
docker tag NAME:TAG REGISTRY/REPO:TAGAdd a registry-qualified name
docker push REGISTRY/REPO:TAGUpload to a registry (after docker login)
docker system pruneRemove stopped containers, unused networks, dangling images

Dockerfile instructions

FROM image:tag AS nameBase image; AS names a stage
WORKDIR /appSet (and create) the working directory
COPY src destCopy from the build context
COPY --from=build /out/app /appCopy from an earlier stage
RUN commandExecute at build time; creates a layer
ENV KEY=valueDefault environment variable (visible in the image)
USER appuserDrop root for the rest of the build and at runtime
CMD ["prog", "arg"]Default command, exec form
ENTRYPOINT ["prog"]Fixed executable; CMD becomes its default args
HEALTHCHECK CMD curl -f http://localhost:8000/ || exit 1How Docker decides the container is healthy

Compose

docker compose up -d --buildBuild and start all services in the background
docker compose psService status and health
docker compose logs -f SERVICEFollow a service's logs
docker compose exec SERVICE shShell into a running service
docker compose downStop and remove containers and the network
docker compose down -vAlso delete named volumes (data loss)

Common pitfalls

  • COPY . . before installing dependencies, which defeats the cache and reinstalls everything on each build.
  • Storing data in the container's writable layer and losing it when the container is replaced.
  • Using the latest tag, so nobody can say which version is actually running.
  • Passing a secret with ARG or ENV, where it remains readable in the image history.
  • Connecting to localhost from one Compose service to another instead of using the service name.
  • Shell-form CMD, which leaves the app deaf to SIGTERM and makes every stop take ten seconds.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →