Sessions 11–12 · Notes 07
Recipes, Layers, and Labels
How a container image is really built, why the order of your Dockerfile decides whether a rebuild takes one second or one minute, where images live, and how to ship them without leaking secrets — Docker in depth, in plain words.
01The Recipe and the Meal
In 1956 a trucking man named Malcom McLean loaded fifty-eight steel boxes onto an old ship in New Jersey. Before that day, every sack, barrel, and crate was loaded by hand, one odd shape at a time. After it, the crane stopped caring what was inside. A box is a box. It fits on a ship, a train, or a truck, and it gets lifted the same way every time. Shipping got so cheap that it changed the world economy.
Docker did the same thing for software. A server doesn’t need to know whether your app is Python, Node, or Java, or which libraries it needs. It just needs to know how to run a container. Same box, any contents, any machine.
Back in Notes 02 (Section 04) you learned what a container is: an ordinary process that the kernel shows a private view of the machine. I promised then that Week 7 would cover how to build the box well. This is that week. First, three words that people mix up all the time. Keep them straight and half of Docker becomes easy.
Dockerfile — the recipe
A short text file of instructions: start from this, copy that in, install these, run this command. It’s a recipe card. You can’t eat a recipe card.
docker buildImage — the frozen meal
The result of following the recipe: everything the app needs, sealed and frozen. It never changes. You can copy it, ship it, and store it for years.
docker runContainer — the meal on the plate
One running copy of the image. Heat up the frozen meal and serve it. You can serve the same frozen meal to a hundred tables at once.
That last point matters a lot for the cloud: one image, many containers. When your app gets busy, you don’t build anything new. You just start more containers from the same image. Every copy is identical, because they all came from the same frozen meal. That’s exactly how horizontal scaling works in the cloud.
1.1 The image is frozen. The container gets a scratch pad.
An image is read-only. Nothing can change it after it’s built. So what happens when your running app writes a file? Docker gives each container a thin, blank, writable layer on top — a scratch pad. The app can write there all it likes. But the scratch pad belongs to that one container. Delete the container, and the scratch pad goes in the trash with it.
That sounds like a bug. It’s actually the feature. Every new container starts perfectly clean, from the same frozen image. No leftovers, no drift, no “but it worked yesterday.” The price: anything you want to keep must live outside the container — in a database, a storage bucket, or a mounted disk (the storage topic from Week 4). If your app saves user uploads inside the container, they vanish the next time it restarts.
Notes 03 called this immutable infrastructure for whole virtual machines: never patch a running server; build a new image and replace the old servers. Containers make that habit cheap. You never “fix” a running container. You change the recipe, build a new image, and replace the containers.
02What’s Actually in the Box
Open up an image and you find two things. Think of a moving box with a packing slip taped to the top.
A snapshot of files
Every file the app needs, frozen at build time: a small slice of a Linux system (the basic tools and libraries), the language runtime (say, Python), your libraries, and your code. Like a photo of a hard drive.
A settings card
A few notes for whoever runs the box: which command to start, which folder to start in, which environment variables to set, which user to run as, which port the app listens on. Docker calls this the image config.
Now notice what’s missing: there is no kernel in the box. Remember the shared-kernel experiment from Notes 02? The container borrows the host’s kernel. That’s why an image can be tiny — tens of megabytes — when a virtual machine’s disk image is gigabytes. The image only carries the parts that make your app your app.
You can read the settings card yourself with docker image inspect python:3.12-slim. It prints a big blob of JSON. Look for Cmd, Env, and WorkingDir. It’s a packing slip, nothing magic.
03The Dockerfile, Line by Line
Let’s containerize a real (tiny) app. It’s the photos service from last week’s examples, shrunk to two routes: a home page and a health check. Here’s the whole project folder:
from flask import Flask app = Flask(__name__) @app.get("/") def home(): return "Hello from a container!" @app.get("/healthz") def healthz(): return "ok"
flask==3.1.0 gunicorn==23.0.0
gunicorn is a production web server for Python apps. Flask’s built-in server is for your laptop only; gunicorn is what you run for real. And here is the recipe:
FROM python:3.12-slim WORKDIR /app ENV PYTHONUNBUFFERED=1 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Eight lines. Each one starts with an instruction in capital letters. Read it top to bottom, like a recipe:
- Line 1
FROMpicks the base image — what you start from. Nobody bakes bread by growing wheat. Here we start from an official image that already has a small Linux system and Python 3.12 installed.slimmeans “the small version” (Section 06 explains). Every Dockerfile starts withFROM.- Line 2
WORKDIRmeans “go into this folder, and create it if it doesn’t exist.” Likecd, but it sticks for every line after it, and for the running container too.- Line 3
ENVsets an environment variable (Notes 06, Section 06) that will be there when the app runs. This one tells Python to print log lines right away instead of saving them up, so you see them indocker logs. Settings like this are fine to bake in. Secrets are not — Section 08.- Line 4
COPYcopies files from your project folder into the image. The first path is on your computer; the second is inside the image. The.means “right here,” which is/appbecause of line 2. We copy only the requirements file for now. That looks odd. Section 05 explains why it’s the most important trick in this whole document.- Line 5
RUNruns a command while building and saves whatever it changed. This one installs Flask and gunicorn.--no-cache-dirtells pip not to keep its download cache, which would only waste space in the image.- Line 6
COPY . .copies everything else from your project folder — your code — into/app.- Line 7
EXPOSEis a note, not an action: “this app listens on port 8000.” It doesn’t open anything. It’s a sticky note for humans and tools. You actually connect a port when you run the container (below).- Line 8
CMDis the command the container runs when it starts. It goes on the settings card, not into the files. It runs at run time, not at build time — that’s the big difference fromRUN. Write it in the square-bracket form, as shown. That way your app gets the “please stop” signal directly when the container is told to shut down, and can finish its work cleanly.
RUN happens once, in the kitchen, while the meal is being made. CMD happens every time, at the table, when someone heats it up. Installing libraries is kitchen work. Starting the web server is table work.
3.1 Build it, run it
From the project folder, two commands:
docker build -t photos-api:1.0 . docker run --rm -p 8000:8000 photos-api:1.0
-t photos-api:1.0gives the image a name (photos-api) and a tag (1.0). A tag is a label, like a version number. Section 07 is all about tags.- The lonely
.at the end is easy to miss, and it matters. It’s the build context: the folder whose files Docker is allowed toCOPY. Docker packs up that whole folder and hands it to the builder. Keep that in mind for Section 06. -p 8000:8000connects port 8000 on your computer to port 8000 inside the container. The left number is your side; the right number is the container’s side. Without-p, the app runs but nobody outside can reach it.--rmdeletes the container when it stops, so stopped containers don’t pile up.
Open http://localhost:8000 in your browser and you’ll see the greeting. Press Ctrl+C to stop it. That’s the whole loop: edit, build, run.
04Layers: A Stack of See-Through Sheets
Remember overhead projectors? A teacher lays a clear plastic sheet on the glass, then another sheet on top with more drawing, then another. The screen shows one picture, but it’s really a stack. A sheet on top can draw over a spot on a sheet below. It can never erase the sheet below.
A Docker image works exactly like that. Each instruction that changes files — RUN, COPY, and ADD — makes a new layer: a sheet holding only the files that step added or changed. The base image is itself a few layers. The other instructions (ENV, EXPOSE, CMD, USER) don’t touch files at all; they only write on the settings card. When a container starts, Docker lays all the sheets on top of each other and the app sees one normal-looking filesystem.
Why build it that way? Because layers are shared. Each layer is named by a fingerprint of its contents. If ten of your images all start FROM python:3.12-slim, your laptop stores those base layers once, not ten times. When you docker pull an image and it says Already exists next to a layer, that’s this: you already have that sheet, so Docker skips the download. And when you push a new version of your app, only the sheets that changed travel over the network.
Explore the stack below. Click any layer to look inside it. Switch the base image to see where the size really comes from. Then press “What the container sees” to look down through all the sheets at once.
- Image size
- —total
- Your part
- —your layers
- File layers
- —sheets in the stack
Two things to notice. First, with any base image, your app is the small part. Your code is a few kilobytes and your libraries about 12 MB. Almost everything else is the base. Picking the base is the biggest size decision you make, and you make it in line 1. Second, the dashed rows have no files at all. They’re settings-card entries. They cost nothing.
You can see the real stack for any image with docker history photos-api:1.0. It lists every step, newest on top, with the size each one added.
05The Build Cache, and Why Order Matters
Building an image from scratch is slow. Pulling the base and installing libraries can take a minute or more. If every tiny code change cost a minute, you’d go crazy. So Docker keeps every layer it built last time, and reuses whatever it can. This is the build cache.
Think of a cook making the same lasagna every day. The sauce takes an hour. If yesterday’s sauce is in the fridge and the sauce recipe hasn’t changed, the cook uses it. Done in minutes.
Docker walks your Dockerfile from top to bottom. At each step it asks one question: “Is this step exactly the same as last time — same instruction, same files going in, same everything above it?” If yes, it reuses the saved layer and prints CACHED. If no, it rebuilds that step. And here is the rule that matters:
Once one step rebuilds, every step after it rebuilds too. Each layer sits on the one below it. Change a lasagna layer in the middle, and you have to rebuild every layer on top of it — even the ones that didn’t change. So: put things that rarely change at the top of the Dockerfile, and things that change often at the bottom.
What changes most often? Your code, many times a day. What rarely changes? The base image and your list of libraries. That’s why our Dockerfile copies requirements.txt by itself, installs the libraries, and only then copies the rest of the code. Try both orders yourself.
- Build time
- —seconds
- Steps rebuilt
- —of —
- New layers to push
- —to the registry
The lesson in one sentence: in the bad order, changing one line of app.py reinstalls every library, every time. In the good order, it takes a fraction of a second, because pip install sits above the line that copies your code. Same files, same final image, very different day. (Notice the “new layers to push” number, too. The bad order also makes you upload 12 MB of libraries to the registry for every code change.)
5.1 Two cache gotchas
- The cache can’t see the internet. For a
RUNstep, Docker only compares the text of the command. If your requirements say justflask(no version), and Flask releases a new version tomorrow, Docker still saysCACHEDand keeps the old one. Your teammate, building fresh, gets the new one. Now you two have different images from the same Dockerfile. The fix is pinning exact versions, likeflask==3.1.0. Pinned versions mean the same input always gives the same output. - Sometimes you want a fresh start. To ignore the cache and pick up the newest version of your base image (say, after a security patch), build with
docker build --pull --no-cache -t photos-api:1.1 .. That’s the “base image updated” button in the plate: slow, but it’s how security fixes get in.
06Smaller Images
Why care about size? Three reasons, and all of them show up on your bill or your pager:
- Speed. A new server has to download the image before it can start a container. Notes 03 said it plainly: boot time is scaling latency. A 1 GB image during a traffic spike is a slow rescue.
- Money. Registries charge for storage, and moving data costs money too. Every version you push is stored.
- Safety. Every program in the image is something that might have a security hole. A compiler, a text editor, or
curlsitting in your production image does nothing for your app — but it’s a handy tool for an attacker who gets in. Less stuff, fewer holes.
6.1 Pick a smaller base
Most official images come in a few sizes. Here are the common Python ones. Sizes are approximate and change with every release; check yours with docker images.
| Base | What’s inside | About | Use it when |
|---|---|---|---|
python:3.12 | Full Debian Linux, plus compilers, Git, and lots of tools | 1 GB | Building things (as the first stage of a multi-stage build, below). Rarely what you ship. |
python:3.12-slim | Minimal Debian Linux plus Python | 125 MB | The sensible default. Use this unless you have a reason not to. |
python:3.12-alpine | Alpine Linux, a tiny distribution, plus Python | 50 MB | Size really matters. Careful: Alpine uses a different core C library (musl), so some Python libraries can’t use their ready-made installs and must compile, which can be slow or fail. |
| Distroless | Just the runtime and your app. No shell, no package manager. | 50 MB | Locked-down production images. Harder to debug: there’s no shell to poke around in. |
6.2 Clean up in the same step
Here’s the projector rule again: a later layer can’t make an earlier layer smaller. If one RUN downloads 50 MB of package lists and the next RUN deletes them, the image doesn’t shrink. The files are still on the earlier sheet; the later sheet just hides them. So do the mess and the cleanup in the same step, joined with &&:
RUN apt-get update \ && apt-get install -y --no-install-recommends libpq5 \ && rm -rf /var/lib/apt/lists/*
The backslash just continues the line. All three commands make one layer, and the junk from line 1 is gone before the layer is saved. Hold on to this rule. In Section 08 it stops being about size and starts being about secrets.
6.3 Multi-stage builds: cook in the kitchen, serve only the plate
Some libraries need a compiler to install. A compiler is hundreds of megabytes, and your running app never uses it. A multi-stage build lets you use two images in one Dockerfile. The first is the kitchen: big, messy, full of tools. The second is the plate you serve: small and clean. You copy just the finished food from the kitchen to the plate. The kitchen gets thrown away.
# ---- Stage 1: the kitchen ---- FROM python:3.12 AS build RUN python -m venv /venv ENV PATH="/venv/bin:$PATH" COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # ---- Stage 2: the plate ---- FROM python:3.12-slim COPY --from=build /venv /venv ENV PATH="/venv/bin:$PATH" PYTHONUNBUFFERED=1 WORKDIR /app COPY . . EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Stage 1 (AS build) uses the full 1 GB image, with every compiler, and installs the libraries into a virtual environment: a self-contained folder of Python libraries, here /venv. Stage 2 starts fresh from slim and copies in only that folder (line 10, COPY --from=build). The image you ship is only the last stage. The compilers never leave the kitchen. For Python the saving is moderate. For languages that compile to a single program, like Go or Rust, it’s dramatic: the kitchen is 800 MB and the plate can be under 20 MB.
6.4 .dockerignore: don’t pack the junk drawer
Remember the build context — that . at the end of docker build? Docker sends the whole folder to the builder, and COPY . . copies all of it into the image. That includes your .git history, your local virtual environment, your cache files … and your .env file with real passwords in it. (Notes 06, Plate 6.1, snippet 2 warned you about exactly this.)
A .dockerignore file works just like .gitignore: a list of things to leave out. Put it next to your Dockerfile:
.git .env .env.* .venv/ venv/ __pycache__/ *.pyc .pytest_cache/ Dockerfile .dockerignore
Smaller image, faster builds, fewer cache busts from files the app never reads — and, most importantly, no .env. Make it a habit: every Dockerfile gets a .dockerignore on day one.
07Registries, Tags, and Digests
You built an image on your laptop. Now a server in the cloud needs it. How does it get there? Through a registry: a storage service for images. Think of an app store. You push (upload) your image to it once. Any machine with permission can pull (download) it, whenever it needs to.
- Docker Hub is the big public one. It’s where
python:3.12-slimlives. Free, public, and limited: anonymous pulls are rate-limited. - Cloud registries live inside your cloud account: Amazon ECR, Google Artifact Registry, and Azure Container Registry. They are private by default, sit close to your servers (fast pulls), and use your cloud’s IAM for permissions. This is what real teams ship to. (Project 2 uses Docker Hub — simpler, and public is the point.)
7.1 Reading an image name
A full image name has three parts. Here’s one from Amazon ECR:
123456789012.dkr.ecr.us-east-1.amazonaws.com/photos-api:1.4.0
- Registry
- Where it lives: the address of the registry. Your account number and region are part of it on ECR. On Google it looks like
us-docker.pkg.dev/my-project/my-repo; on Azure,myregistry.azurecr.io. - Repository
- Which app: one repository per image, holding all of its versions. Project 2 needs exactly one.
- Tag
- Which version: a label you pick.
When you write just python:3.12-slim, Docker fills in the rest for you: it means docker.io/library/python:3.12-slim — Docker Hub, the official images. And if you leave off the tag entirely, Docker quietly adds :latest. Remember that.
# 1. Log Docker in to your registry, using your cloud identity aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com # 2. Give your local image its full registry name (a second label, not a copy) docker tag photos-api:1.4.0 123456789012.dkr.ecr.us-east-1.amazonaws.com/photos-api:1.4.0 # 3. Upload it docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/photos-api:1.4.0
On Google Cloud, step 1 is gcloud auth configure-docker us-docker.pkg.dev; on Azure it’s az acr login --name myregistry. Notice step 1 is Notes 06 all over again. Pushing and pulling are actions in an IAM policy. Your laptop (or your build pipeline) needs permission to push. Your servers only need permission to pull. Least privilege: servers that can push could overwrite your images.
7.2 Tags are nicknames. Digests are fingerprints.
A tag is a nickname that points at an image. And like a nickname, it can be moved to someone else. Anyone with push permission can push a different image under the same tag, and the tag now points at the new one. The old image is still in the registry; it just lost its label.
A digest is a fingerprint. It’s a long code like sha256:3f9a1c7e… computed from the image’s exact contents. Change one byte in the image and the fingerprint changes completely. So a digest can never point at different bits. You can always pull by digest: photos-api@sha256:3f9a1c7e… (note the @ instead of :).
And latest? It’s the most misunderstood word in Docker. It does not mean “newest.” It’s just the default tag name Docker uses when you don’t type one. It points at whatever someone last pushed with that tag — or without any tag. It’s a sticky note that keeps getting moved. Watch what that does to a real deployment:
Which deployment is guaranteed to run the exact same bits on every server, every time it restarts?
The fleet using latest ended up running two different versions at once, and nobody decided that. A server just happened to restart. The 1.4.0 fleet looked safe — it’s a version number! — but a version tag is still a nickname, and someone moved it. Only the digest held. Bugs like this are miserable to find: half your servers behave one way, half another, and the deploy history says nothing changed.
Never deploy latest. Tag every build with something unique that you never reuse — a version number plus the Git commit is a good habit, like 1.4.0 and git-3f9a1c2. Turn on tag immutability in your registry (ECR and Artifact Registry both have it; on ACR you can lock tags) so a tag can’t be moved once pushed. And when it really matters, deploy by digest. You get the digest after a push, or with docker image inspect --format '{{index .RepoDigests 0}}' <image>.
Apple-silicon Macs have ARM chips. Most cloud servers are x86 (amd64). An image built on an M-series Mac is built for ARM by default, and on an x86 server it crashes at once with exec format error. Fix: build for the server’s chip, with docker buildx build --platform linux/amd64 -t photos-api:1.4.0 . — or let your cloud build pipeline do it (Week 12).
08Building Safe Images
Last week was about keys, badges, and locked boxes. An image is a box you’re about to hand to a registry, a cluster, your teammates, and maybe the public. Here’s how to make sure nothing in it can hurt you.
8.1 Never put a secret in an image. Not even for one step.
Here’s the teaser from Notes 06, paid off. A student copies their project folder into the image, .env and all. They realize the password is in there, so they add RUN rm /app/.env a few lines later. The running container has no .env. Problem solved?
No. Think about the projector. rm in a later layer doesn’t erase the earlier sheet. It lays down a new sheet with a little note that says “hide .env” (Docker calls it a whiteout). The file is still sitting, whole, in the earlier layer. And anyone who can pull the image can unpack every layer, one by one: docker save photos-api:1.0 -o img.tar, then open the archive. Bots and researchers scan public registries for exactly this. See for yourself:
- Image size
- —total
- Your part
- —your layers
- File layers
- —sheets in the stack
From inside the container, the mistake looks clean. From the registry, it’s a leaked password. So the rules are:
- Keep secrets out of the build context.
.envgoes in.dockerignore(Section 06) as well as.gitignore. - Give secrets to the container at run time, never at build time: as environment variables set when the container starts, filled in from a secrets manager by a role (Notes 06, Section 06). The image says where to find the password; it never contains it.
- If a build step truly needs a secret (say, a token to download a private library), use a build secret:
RUN --mount=type=secret,id=pip_token …. The secret is mounted for that one step and never written into any layer. - If a secret ever got into a pushed image, it’s leaked. Rebuilding a clean image doesn’t un-leak it. Follow last week’s three-step drill: revoke it first.
8.2 Don’t run as root
Here’s something most people don’t know: unless you say otherwise, the app inside a container runs as root, the all-powerful admin user. It’s root inside the box, not on the host — but the box’s walls are drawn by the shared kernel (Notes 02). If an attacker breaks into your app through a bug, root lets them rewrite any file in the container, install tools, and gives them a much better start at climbing out through a kernel bug. Your app needs none of that power. It needs to read its own files and listen on a port.
That’s least privilege, the valet key from Notes 06, applied inside the box. Two lines fix it: create a normal user, then switch to it with USER. Put USER after the steps that need root to install things, and before CMD.
8.3 Pin your versions
python:3.12-slim is a moving tag (Section 07). The Python team moves it on purpose, to ship security fixes. That’s good — but it means two builds a month apart can get different bases. Pin a specific version, like python:3.12.7-slim-bookworm (Python 3.12.7 on Debian 12, “bookworm”), or even a digest. Pin your libraries with ==. Then you decide when to upgrade, on purpose, by changing one line and rebuilding.
8.4 Scan your images
Every package in your image has a version, and security researchers keep a public list of known holes in specific versions. Each hole gets an ID called a CVE. An image scanner reads the list of packages in your image and checks it against that list. It’s like a recall check for your car: you type in the model and year, and it tells you what’s been recalled.
Try docker scout cves photos-api:1.0 (built into Docker Desktop) or the free tool trivy image photos-api:1.0. All three cloud registries can also scan automatically every time you push. Run one on the full python:3.12 image, then on slim. The full image usually reports far more issues, mostly in tools your app never uses. That’s the size argument from Section 06, turned into a number.
8.5 Putting it together
Here’s the version-1 Dockerfile from Section 03, upgraded with everything in this section. Use it as the starting point for each Project 2 service.
FROM python:3.12.7-slim-bookworm # A normal user to run the app (made early: this line rarely changes) RUN useradd --create-home --uid 10001 appuser WORKDIR /app ENV PYTHONUNBUFFERED=1 # Libraries first: cached until requirements.txt changes COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Code last: changes all the time (.dockerignore keeps .env out) COPY . . # Drop root before the app starts USER appuser EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
No passwords anywhere. The database password arrives when the container starts, from the secrets manager, through the role. Check the result with docker run --rm photos-api:2.0 whoami. It should print appuser, not root.
09Docker Commands You’ll Actually Use
Docker has hundreds of commands. You’ll use about fifteen. Here they are, in roughly the order you’ll need them.
| Command | What it does |
|---|---|
docker build -t name:tag . | Build an image from the Dockerfile in this folder and label it. |
docker images | List the images on your machine, with sizes. |
docker history name:tag | Show an image’s layers and what each one added (Plate 4.1, for real). |
docker run --rm -p 8000:8000 name:tag | Start a container from an image, with a port connected. |
docker run -d --name api name:tag | Start one in the background (-d, “detached”) with a name you choose. |
docker ps / docker ps -a | List running containers / all containers, including stopped ones. |
docker logs -f api | Show a container’s output; -f keeps following it. |
docker exec -it api sh | Open a shell inside a running container, to look around. |
docker stop api / docker rm api | Stop a container / delete a stopped one (and its scratch pad). |
docker image inspect name:tag | Read the settings card (Section 02) and the digests. |
docker tag src:tag registry/repo:tag | Add another name to an image (needed before pushing). |
docker push / docker pull | Upload to / download from a registry. |
docker scout cves name:tag | Scan an image for known security holes (or use trivy image). |
docker compose up --build | Build and start several containers together from a compose.yaml (Section 10). |
docker system df / docker image prune | See how much disk Docker uses / delete unused images to get it back. |
10What This Means for Project 2
Project 2 — Put Your Site in a Box — is deliberately small: pack your Project 1 page into one container with a two-line Dockerfile, run it at localhost, and push the image to Docker Hub. The full spec is on the course page. Everything below is the bigger version of the same ideas — how real multi-service teams use this week’s material. Your project needs only a slice of it, but this is the shape of the real thing.
- One Dockerfile per service. A real system of three services means three images and three repositories in a registry. Say, a web front end, an API, and a background worker. Each starts from the version-2 Dockerfile in Section 08.
- Order for the cache. You’ll rebuild these images dozens of times before the due date. Dependencies first, code last. It’s the difference between a one-second rebuild and a one-minute one, times three services, times every change.
- Push to your cloud’s registry, tagged with a version and the Git commit. Record the digest of what you actually deploy — your write-up should be able to say exactly which bits were running.
- The datastore password is not in any image. It’s managed (that’s the “managed datastore” part), so it lives in your cloud. Each service gets the password at run time from the secrets manager, through its role — the identity map you sketched for Session 11.
- Pull-only for the cluster. The machines that run your containers need permission to pull from the registry, and nothing more.
- A health endpoint in every service. Our sample app has
/healthz. Notes 03 showed how a load balancer uses a check like that to route around a sick server. Next week, the orchestrator uses it to restart sick containers. Build it in now. - Logs go to the screen. Print logs to standard output (that’s what
PYTHONUNBUFFERED=1helps with). Don’t write log files inside the container — they vanish with the scratch pad.
10.1 Run all three on your laptop first
Before any cluster, get the whole system running locally with Docker Compose: a single file that says “start these containers, build them from these folders, and connect them on one private network.” Locally, an ordinary Postgres container stands in for your managed datastore.
services: web: build: ./web ports: ["8080:8080"] depends_on: [api] api: build: ./api environment: DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app depends_on: [db] worker: build: ./worker environment: DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/app depends_on: [db] db: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: app
Run docker compose up --build and all four start. Three things to notice. Services find each other by name: the API connects to the host db, and Compose makes that name work (the networking topic from Week 5, in miniature). ${DB_PASSWORD} is filled in from your local .env file, which is in .gitignore and .dockerignore, so the password is in neither your repo nor your images. And only web publishes a port; the API and database are reachable only from inside the private network. Compose is for your laptop. In the cloud, the orchestrator takes over that job — next week.
The image checklist for any image you ship — worth running even on your little Project 2 box. It remembers your progress on this device.
11Self-Check
Eight quick questions. Pick an answer to see whether you’re right and why. Nothing is recorded; this is for you.
12Before Session 13 — October 19
The items below are this week’s practice — they feed straight into Project 2. The list remembers your progress on this device.
Three numbers and one line for one of your services: its image size, its rebuild time after a one-line code change, the number of issues the scanner found — and the digest you pushed. We’ll put everyone’s sizes side by side and see whose is the smallest, and why.
12.1 Coming up: Notes 08 — Container orchestration (Oct 19 & 21)
Right now you start containers by hand. That’s fine for one laptop. It’s hopeless for thirty containers on ten servers at 3 a.m. Next week we hand the job to Kubernetes: you tell it “run three copies of this image,” and it finds servers, starts them, checks their health, restarts the ones that die, and adds more when traffic grows. You’ll give it an image reference — which is why the tag-vs-digest lesson in Plate 7.1 matters so much. Project 2 — Put Your Site in a Box — is due Monday, November 9.
13Key Terms
- Dockerfile
- A text file of build instructions. The recipe.
- Image
- A frozen, read-only package: a snapshot of files plus a settings card. The frozen meal.
- Container
- One running copy of an image, with its own throwaway writable layer. The meal on the plate.
- Writable layer
- The scratch pad each container gets on top of its image. Deleted with the container.
- Image config
- The settings card: start command, working folder, environment variables, user, ports.
- Base image
- The image named in
FROMthat yours starts from, such aspython:3.12-slim. - Instruction
- One line of a Dockerfile: FROM, WORKDIR, COPY, RUN, ENV, EXPOSE, USER, CMD, and a few more.
- RUN vs. CMD
- RUN executes once while building. CMD is the command each container runs when it starts.
- EXPOSE / -p
- EXPOSE notes which port the app uses;
-p host:containeractually connects a port when you run it. - Build context
- The folder (the
.indocker build) whose files are sent to the builder and can be copied. - .dockerignore
- A list of files to leave out of the build context. Keeps out
.git,.env, and junk. - Layer
- One see-through sheet of file changes, made by RUN, COPY, or ADD. Shared between images and named by its contents.
- Whiteout
- The “hide this file” marker a later layer uses for a deleted file. The file is still in the earlier layer.
- Build cache
- Saved layers from earlier builds that Docker reuses when a step hasn’t changed.
- Cache bust
- When a step changes and must rebuild — along with every step after it.
- Multi-stage build
- A Dockerfile with several FROM stages; only the last one is shipped. Cook in the kitchen, serve the plate.
- Slim / Alpine / distroless
- Smaller base images: minimal Debian; a tiny distribution using musl; just a runtime with no shell.
- Registry
- A storage service for images (Docker Hub, ECR, Artifact Registry, ACR). The app store.
- Repository
- One named image in a registry, holding all its versions, e.g.
photos-api. - Push / pull
- Upload an image to a registry / download one from it. Both are IAM permissions.
- Tag
- A movable label on an image, like
1.4.0. A nickname. - latest
- The default tag when you don’t type one. Not “newest” — just a label that keeps getting moved.
- Digest
- A
sha256:fingerprint of an image’s exact contents. Pull with@. Can never point at different bits. - Tag immutability
- A registry setting that stops a tag from being moved once it’s pushed.
- Non-root user
- A normal user the app runs as (via
USER), instead of the default all-powerful root. - Build secret
- A secret mounted for one RUN step only (
--mount=type=secret), never saved into a layer. - CVE / image scanning
- A CVE is a public ID for a known security hole; a scanner checks your image’s packages against the list.
- Platform (amd64 / arm64)
- The chip type an image is built for. Build for the server’s chip, or get “exec format error.”
- Docker Compose
- A tool that starts several containers together from one
compose.yaml, on one private network.