Skip to content
Docker workshop
A hands-on workshop · 12 chapters · 4 h 25 min

Docker, from first container to production

Twelve chapters that explain how Docker really works, each with a broken Dockerfile, Compose file or release script to fix. Real builds, real containers, a real Postgres and a real registry. Every number on this site was measured.

Measured in the labbefore, after

# chapter 09: stopping the API

$ docker stop api

took 10.6 s, exit code 137

took 1.9 s, exit code 0

# chapter 08: the image you ship

$ docker image ls api

1,138.5 MB, gcc inside, runs as root

149.8 MB, no compiler, uid 10001

# chapter 03: rebuild after a one-line change

pip install ran again: 6.2 s

every layer cached: 0.4 s

The path

From one container to a production release

Each chapter explains one idea in depth, then hands you an exercise that gets it wrong in a way real teams do. You fix it, and a checker proves the fix with real containers. The red line on each card is what the exercise prints before you start.

Level 1Foundations

4 chapters, 85 minutes

What a container is, what goes into an image, the build cache and networking.

  1. 0120 min

    Containers are processes

    A container is not a small virtual machine. It is one process, with walls around it, and it lives exactly as long as that process does.

    dockerlab-01 Exited (0) 3 seconds ago
  2. 0220 min

    The build context and .dockerignore

    COPY . . copies whatever the build context holds, and the build context is the whole folder unless you say otherwise. That is how secrets and gigabytes end up in images.

    API_KEY=sk_dockerlab_6e50aac08080d1c62dc6a9b9
  3. 0325 min

    Layers and the build cache

    The build cache reuses a layer only if everything before it is unchanged. Put what changes rarely first and what changes on every commit last.

    ran 6.2 s RUN pip install --no-cache-dir -r requirements.txt
  4. 0420 min

    Ports and networks

    Inside a container, 127.0.0.1 is the container's own loopback. A server that listens there is working perfectly and can never be reached from outside.

    RemoteDisconnected: Remote end closed connection without response

Level 2Services and data

3 chapters, 65 minutes

Volumes and ownership, Compose with a real Postgres, and secrets at build time.

  1. 0520 min

    Volumes and file ownership

    A container's own files disappear with it. Data belongs in a volume, and the user your app runs as must be allowed to write there.

    sqlite3.OperationalError: unable to open database file
  2. 0625 min

    Compose and start-up order

    depends_on waits for a container to start, not for the service in it to be ready. Readiness has to be checked, and checked the same way your app connects.

    api Exited (1) 7 seconds ago
  3. 0720 min

    Secrets at build time

    Build arguments are written into the image's history. A secret the build needs must be mounted for one step and never become part of the image.

    ARG PACKAGE_TOKEN=pkg_dockerlab_99b7b8ab9421e672b516dab8

Level 3Production

5 chapters, 115 minutes

Small images, graceful shutdown, health checks, cgroup limits and digests.

  1. 0825 min

    Multi-stage builds

    The tools you need to build an app are not the tools you need to run it. Build in one stage, run in another, and copy across only the result.

    image size: 1,138.5 MB
  2. 0920 min

    PID 1 and graceful shutdown

    docker stop sends SIGTERM to PID 1 and nothing else. If PID 1 is a shell that does not pass it on, your server is killed ten seconds later, mid-request.

    took 10.6 s, exit code 137
  3. 1020 min

    Health checks

    A health check is a program Docker runs inside your container, with only the tools your image has. It must pass when the service works and fail when it does not.

    exit code 1: /bin/sh: 1: curl: not found
  4. 1125 min

    CPU and memory limits

    --cpus 1 does not hide the host's other CPUs from your program. It sets a quota, and a program that sizes itself by counting CPUs will start too many workers and run out of memory.

    container: exited, exit code 1, OOMKilled=true
  5. 1225 min

    Tags, digests and registries

    A tag is a name that the next push can move. A digest is the hash of one exact image. Deploy and roll back by digest.

    the running version is 1.5.0
Try it: chapter 03

The build cache, one step at a time

BuildKit reuses a layer only when the step and everything before it are unchanged. Build once, then change a line of code and build again. In the Dockerfile as shipped,COPY . .comes before the install, so every commit reinstalls every dependency.

Switch to the fixed Dockerfile and do the same. Then change requirements.txt and watch the install run again, which is exactly when it should.

Dockerfile

pip install measured at 6.2 s in chapter 03

  1. ...FROM python:3.13.16-slim-trixie
  2. ...WORKDIR /app
  3. ...COPY . .
  4. ...RUN pip install --no-cache-dir -r requirements.txt
  5. ...CMD ["uvicorn", "app.main:app", ...]

Nothing built yet. Build once to fill the cache.

Builds
0
pip install runs
0
Seconds in pip install
0.0
Measured, not quoted

Run the experiments on your own machine

uv run python -m dock try measures real Docker behaviour where you run it. These are the numbers from the machine the workshop was written on: Docker Desktop with 4 CPUs.

Start a process38 mspython -c pass, on the host
Start a container541 msdocker run --rm python:slim true
A file deleted in a later layer100.0 MBstill shipped with the image
The same small server on three base images
Base image, same serverImage size
python:3.13.16-trixie1,112.0 MB
python:3.13.16-slim-trixie118.3 MB
python:3.13.16-alpine51.9 MB
Quick start

You need Docker, uv and git

Docker Desktop on macOS or Windows, or Docker Engine on Linux. uv installs Python for you, and the lab itself uses only Python's standard library. Everything the lab creates is named dockerlab, and dock clean removes it all.

shell
git clone https://github.com/jars-demo/docker-demo.git
cd docker-demo/lab
uv sync
uv run python -m dock run 1