Part 6 of 7 · 4 min read
Exercises
Try each exercise before opening its hint.
Easy
Easy 1: Build and run it (30 minutes)
Install Docker. In code/ship-it run docker build -t ship-it:dev ., start the container, and call /health and /echo with curl. Run docker images and note the image size.
Done when: curl localhost:8000/health returns {"status":"ok"} from inside a container.
Hint
on Windows keep Docker Desktop running; if port 8000 is busy, map -p 8080:8000.
Easy 2: Prove the cache works (20 minutes)
Rebuild once (everything cached). Change one line in app.py and rebuild: note which steps say CACHED. Then add a comment line to requirements.txt and rebuild again. Write one sentence explaining the difference.
Done when: you can predict which layers rebuild before you run the build.
Hint
docker build prints CACHED next to reused steps; --progress=plain shows more detail.
Easy 3: Image or container? (10 minutes)
Decide whether each is an image or a container: (a) what docker build produces, (b) what docker run starts, (c) the thing you push to a registry, (d) the thing that has a running process and a writable layer.
Done when: all four are right and you can explain the relationship in one sentence.
Medium
Medium 1: Configure without rebuilding (20 minutes)
Start the container twice, once with -e APP_GREETING=Namaste and once with the default. Show that /echo changes with no rebuild. Then run docker history ship-it:dev and check that no greeting value is stored in the layers.
Done when: the same image gives two behaviours.
Hint
docker history lists the command that created each layer.
Medium 2: Compose it (25 minutes)
Run docker compose up --build with the provided file. Add a second service redis (image redis:7-alpine) and, without using Redis yet, show with docker compose ps that both run. Stop everything cleanly.
Done when: both services are listed as running and docker compose down removes them.
Medium 3: Check the image is hardened (30 minutes)
The example already runs as a non-root user and has a .dockerignore. Prove it. Run docker run --rm ship-it:dev id and read the user. Put a file named .env in the folder, rebuild, and show it is not inside the image (docker run --rm ship-it:dev ls -a /app). Then remove .env from .dockerignore, rebuild, and observe what changes. Put the line back.
Done when: you can show the non-root user, show .env is absent with the ignore file, and explain what the ignore file protects against.
Hint
if id prints uid=0, you are root; appuser has a different number.
Hard
Hard 1: Make CI real (60 minutes)
Put ship-it in a new GitHub repository with the workflow at .github/workflows/ci.yml. Push and watch the Actions tab go green. Then push a branch that breaks a test, open a pull request and see it fail. Turn on branch protection so the pull request cannot be merged while red.
Done when: a red pull request is blocked, and the fix turns it green.
Hint
Settings, Branches, "Add rule", then require the status check named test.
Hard 2: Catch your own leak (45 minutes)
On a branch, add the line API_KEY = "abcdefghijklmnopqrstuv" to a Python file and push. Confirm the secret-scan step fails the pipeline. Replace the home-made scanner with gitleaks (use its official GitHub Action) and compare what each catches. Use a fake key only.
Done when: both scanners fail on the planted key and pass on the fixed branch, and you have listed one thing each catches that the other misses.
Hint
never push a real key, even to test.
Hard 3: A health check that means something (50 minutes)
The Dockerfile already has a HEALTHCHECK that calls /health, but /health always says ok. Make /health return 503 until the app can reach a Redis service in Compose. Start the stack with Redis stopped and show the container reported as unhealthy, then start Redis and show it turn healthy.
Done when: docker compose ps shows unhealthy, then healthy, driven by a real dependency.
Hint
the curl program may not exist in a slim image; use a short Python one-liner in the check command instead. Compose can also use depends_on with a health condition.