Docker Learning Path
In short: six milestones, each a short hands-on session. Every milestone lists what to build, a command that proves it works, and a complete solution script that cleans up after itself.
For each milestone: read the listed cheat sheet
sections, work in a new empty folder, and run the Check commands. Open the
solution only after you've tried. The deeper "why" is in the
complete guide. You need
Docker running: docker version must show a "Server" section.
The plan
| Milestone | You learn | You prove it with |
|---|---|---|
| 1 | Running and inspecting containers | A web server you started, read, and removed |
| 2 | Writing a Dockerfile | Your own app answering on a port you chose |
| 3 | Fast, clean builds | A rebuild that reuses the cached dependency layer |
| 4 | Data and networks | A database whose data survives, reached by name |
| 5 | Docker Compose | A two-service stack started with one command |
| 6 | Production-ready images | A small, non-root, health-checked image that stops instantly |
| Final | Everything | A containerised app of your own |
Milestone 1: Run & explore
Learn: What Docker is · Running containers · Images · Looking inside
Tasks:
- Run
nginxin the background asm1-web, publishing it on port 8181. - Fetch the page and find "Welcome to nginx" in it.
- Read the container's logs, and list the files in
/usr/share/nginx/htmlinside it. - Replace
index.htmlinside the container with your own text and fetch the page again. - Remove the container.
Check: step 4 prints your text; afterwards docker ps -a --filter name=m1-web -q prints nothing.
Solution
docker run -d --name m1-web -p 8181:80 nginx:1.27 > /dev/null
sleep 1
curl -s localhost:8181 | grep -o "Welcome to nginx" # Welcome to nginx
docker logs m1-web 2>&1 | tail -1 # the request you just made
docker exec m1-web ls /usr/share/nginx/html # 50x.html index.html
docker exec m1-web sh -c 'echo "Hello from milestone 1" > /usr/share/nginx/html/index.html'
curl -s localhost:8181 # Hello from milestone 1
docker rm -f m1-web > /dev/null
docker ps -a --filter name=m1-web -q # (nothing)
Check yourself:
- If you ran
nginx:1.27again, would your changed page still be there? Why? - What's the difference between
docker stopanddocker rm?
Milestone 2: Containerise an app
Learn: Writing a Dockerfile · Building & publishing ports · Configuration
Tasks:
- Create the practice app (
app.pyandDockerfile). - Build it as
m2-hello:1.0. - Run it on port 8182 with
APP_NAME=milestone2. - Change the app so it also returns the hostname (
socket.gethostname()), rebuild as1.1, and run both versions side by side on 8182 and 8183.
Check: curl localhost:8182 says Hello from milestone2!, and version 1.1
on 8183 also shows a hostname — which is the container's id.
Solution
mkdir m2 && cd m2
cat > app.py <<'EOF'
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = f"Hello from {os.environ.get('APP_NAME', 'docker')}!\n".encode()
self.send_response(200)
self.end_headers()
self.wfile.write(body)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
EOF
cat > Dockerfile <<'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]
EOF
docker build -q -t m2-hello:1.0 . > /dev/null
docker run -d --name m2-v1 -p 8182:8000 -e APP_NAME=milestone2 m2-hello:1.0 > /dev/null
sed -i.bak 's/body = f"Hello from {os.environ.get(.APP_NAME., .docker.)}!\\n".encode()/body = f"Hello from {os.environ.get(\x27APP_NAME\x27, \x27docker\x27)} on {socket.gethostname()}!\\n".encode()/' app.py
sed -i.bak 's/^import os$/import os\nimport socket/' app.py
docker build -q -t m2-hello:1.1 . > /dev/null
docker run -d --name m2-v2 -p 8183:8000 -e APP_NAME=milestone2 m2-hello:1.1 > /dev/null
sleep 1
curl -s localhost:8182 # Hello from milestone2!
curl -s localhost:8183 # Hello from milestone2 on <container id>!
docker rm -f m2-v1 m2-v2 > /dev/null
sed edits the file from the command line so the solution runs in one go —
when you do it yourself, just edit app.py in your editor.
Check yourself:
- Why must the app listen on
0.0.0.0inside the container? - Which number in
-p 8182:8000is on your computer?
Milestone 3: Fast, clean builds
Learn: Layers & caching · .dockerignore · Cleaning up
Tasks:
- Give the app a
requirements.txt(for examplerequests==2.32.3) and a Dockerfile that installs it. - Order the Dockerfile so that changing
app.pydoes not reinstall dependencies. - Add a
.dockerignoreso a local.envfile and alogs/folder never reach the image.
Check: after changing app.py, the rebuild's pip install step says
CACHED, and docker run --rm <image> ls -a /app shows no .env and no logs.
Solution
mkdir m3 && cd m3
echo 'print("v1")' > app.py
echo "requests==2.32.3" > requirements.txt
echo "SECRET=1" > .env
mkdir logs && echo "old" > logs/app.log
cat > Dockerfile <<'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
EOF
printf '.env\nlogs/\n.dockerignore\nDockerfile\n' > .dockerignore
docker build -q -t m3-app . > /dev/null
echo 'print("v2")' > app.py
docker build --progress=plain -t m3-app . 2>&1 | grep -A1 "RUN pip install" | tail -1 # #8 CACHED
docker run --rm m3-app # v2
docker run --rm m3-app ls -a /app # . .. app.py requirements.txt
docker rmi -f m3-app > /dev/null
Check yourself:
- Why does a change to
app.pynot invalidate thepip installlayer here? - What else would you add to
.dockerignorein a real project?
Milestone 4: Data & networks
Learn: Volumes & bind mounts · Networks
Tasks:
- Create a network
m4-netand a volumem4-data. - Start PostgreSQL 17 on that network with that volume, named
m4-db. - From a second container on the network, connect to it by name and create a table with one row.
- Delete the database container, start a new one with the same volume, and read the row back.
Check: the final query prints your row, although the first database container no longer exists.
Solution
docker network create m4-net > /dev/null
docker volume create m4-data > /dev/null
docker run -d --name m4-db --network m4-net -e POSTGRES_PASSWORD=pw \
-v m4-data:/var/lib/postgresql/data postgres:17 > /dev/null
until docker run --rm --network m4-net postgres:17 pg_isready -q -h m4-db; do sleep 1; done
psql_in_net() { docker run --rm --network m4-net -e PGPASSWORD=pw postgres:17 psql -h m4-db -U postgres -tAc "$1"; }
psql_in_net "CREATE TABLE notes (body TEXT); INSERT INTO notes VALUES ('kept by a volume');"
docker rm -f m4-db > /dev/null # the container is gone…
docker run -d --name m4-db --network m4-net -e POSTGRES_PASSWORD=pw \
-v m4-data:/var/lib/postgresql/data postgres:17 > /dev/null
until docker run --rm --network m4-net postgres:17 pg_isready -q -h m4-db; do sleep 1; done
psql_in_net "SELECT body FROM notes;" # kept by a volume
docker rm -f m4-db > /dev/null && docker volume rm m4-data > /dev/null && docker network rm m4-net > /dev/null
Check yourself:
- Why didn't the second container need
-p? - What happens to the data if you remove the volume?
Milestone 5: Compose a stack
Learn: Docker Compose · Health checks & restarts
Tasks:
- Write a
compose.yamlwith two services:web(built from a Dockerfile) anddb(PostgreSQL 17 with a named volume). webshould connect todband report how many tables exist. It must wait until the database is healthy, not just started.- Start everything with one command, check the answer, and remove everything (including the volume) with one command.
Check: curl localhost:8185 answers with a table count, and after
docker compose down -v nothing from the project is left.
Solution
mkdir m5 && cd m5
cat > app.py <<'EOF'
import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
count = subprocess.run(
["psql", "-h", "db", "-U", "postgres", "-tAc", "SELECT count(*) FROM pg_tables"],
capture_output=True, text=True, env={"PGPASSWORD": "pw"},
).stdout.strip()
self.send_response(200)
self.end_headers()
self.wfile.write(f"tables: {count}\n".encode())
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
EOF
cat > Dockerfile <<'EOF'
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
EOF
cat > compose.yaml <<'EOF'
services:
web:
build: .
ports:
- "8185:8000"
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: pw
volumes:
- m5-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
retries: 20
volumes:
m5-data:
EOF
docker compose -p m5 up -d --build --wait > /dev/null 2>&1
curl -s localhost:8185 # tables: 68 (PostgreSQL's own tables)
docker compose -p m5 down -v > /dev/null 2>&1
docker ps -a --filter label=com.docker.compose.project=m5 -q # (nothing)
The solution's app shells out to psql to stay dependency-free; a real app
would use a database driver such as psycopg.
Check yourself:
- Why is
condition: service_healthybetter than a plaindepends_on: [db]? - How does
webfinddbwithout an IP address?
Milestone 6: Production-ready image
Learn: Multi-stage builds · Smaller, safer images · Resource limits · Graceful shutdown · Debugging playbook
Tasks:
- Build the app with a two-stage Dockerfile: install dependencies into a virtual environment in the first stage, copy only that environment and the code into the second.
- Run as a non-root user, with a
HEALTHCHECK. - Make the app handle SIGTERM so
docker stopfinishes in under a second. - Run it with a 128 MB memory limit.
Check: whoami in the container prints appuser; docker ps shows
(healthy); docker stop takes under a second; docker stats shows a
128 MiB limit.
Solution
mkdir m6 && cd m6
echo "requests==2.32.3" > requirements.txt
cat > app.py <<'EOF'
import signal
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
EOF
cat > Dockerfile <<'EOF'
FROM python:3.12-slim AS build
RUN python -m venv /venv
COPY requirements.txt .
RUN /venv/bin/pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
RUN useradd --create-home --uid 1000 appuser
COPY --from=build /venv /venv
WORKDIR /app
COPY --chown=appuser:appuser app.py .
USER appuser
ENV PATH="/venv/bin:$PATH"
HEALTHCHECK --interval=2s --timeout=2s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000')" || exit 1
CMD ["python", "app.py"]
EOF
docker build -q -t m6-app . > /dev/null
docker run -d --name m6 --memory 128m -p 8186:8000 m6-app > /dev/null
sleep 5
docker exec m6 whoami # appuser
docker ps --filter name=m6 --format '{{.Status}}' # Up 5 seconds (healthy)
docker stats --no-stream --format '{{.MemUsage}}' m6 # … / 128MiB
start=$(date +%s)
docker stop m6 > /dev/null
echo "stopped in $(( $(date +%s) - start )) s" # stopped in 0 s
docker rm m6 > /dev/null && docker rmi m6-app > /dev/null
Check yourself:
- Why does the final stage not contain
pip's download cache or build tools? - What exit code would you see if the app went over 128 MB?
Final project
In short: containerise something of your own, the way a team would ship it.
- Pick a small app you've written (any language) that uses a database.
- Write a multi-stage Dockerfile: cached dependencies, non-root user, health check, SIGTERM handling, pinned base image.
- Write a
compose.yamlwith the app, its database (named volume, health check), and one extra tool (for example Adminer). - Add a
.dockerignoreand check that no secrets or local build output end up in the image. - Scan the image (
docker scout cvesor Trivy) and fix what you can. - Tag it with a version and push it to a registry (a local
registry:2container is fine).
You're done when: a teammate can run docker compose up on a fresh
machine and get a working app, and you can explain every line of your
Dockerfile.
Next: read Common Pitfalls and the interview questions in the complete guide.