Chaya·Edu EDU.CHAYA.DEV

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.

Mon & Wed 12 & 14 Oct 2026 16:00–17:50 Google Meet Instructor Prof. Yair Chaya Reading ~24 min

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.

Step 1 · you write

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 build
Step 2 · Docker makes

Image — 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 run
Step 3 · it runs

Container — 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.

Same idea, bigger box

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.

Inside the box

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.

Taped on top

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:

app.pya small Flask web app
from flask import Flask

app = Flask(__name__)

@app.get("/")
def home():
    return "Hello from a container!"

@app.get("/healthz")
def healthz():
    return "ok"
requirements.txtthe libraries, with exact versions
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:

Dockerfileversion 1 — good enough to learn from
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
FROM picks 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. slim means “the small version” (Section 06 explains). Every Dockerfile starts with FROM.
Line 2
WORKDIR means “go into this folder, and create it if it doesn’t exist.” Like cd, but it sticks for every line after it, and for the running container too.
Line 3
ENV sets 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 in docker logs. Settings like this are fine to bake in. Secrets are not — Section 08.
Line 4
COPY copies 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 /app because 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
RUN runs a command while building and saves whatever it changed. This one installs Flask and gunicorn. --no-cache-dir tells 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
EXPOSE is 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
CMD is 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 from RUN. 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 vs. CMD, the one-line version

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:

Terminalbuild the image, then run one container
docker build -t photos-api:1.0 .
docker run --rm -p 8000:8000 photos-api:1.0
  • -t photos-api:1.0 gives 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 to COPY. Docker packs up that whole folder and hands it to the builder. Keep that in mind for Section 06.
  • -p 8000:8000 connects 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.
  • --rm deletes 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.

Plate 4.1 — Image layer stackphotos-api · sizes approximate, uncompressed

Base image (line 1 of the Dockerfile)

Image size
—total
Your part
—your layers
File layers
—sheets in the stack

Top of the stack ↓ (click a layer)

    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:

    The one rule

    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.

    Plate 5.1 — Build cache simulatorTimes are typical for a laptop · your mileage will vary

    Dockerfile order

    What changed since the last build? (each click runs a rebuild)

    Build time
    —seconds
    Steps rebuilt
    —of —
    New layers to push
    —to the registry

    Build output

      Challenges — they tick themselves when you find one

        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 RUN step, Docker only compares the text of the command. If your requirements say just flask (no version), and Flask releases a new version tomorrow, Docker still says CACHED and 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, like flask==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 curl sitting 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.

        Table 6.1 — Python base images, plainly
        BaseWhat’s insideAboutUse it when
        python:3.12Full Debian Linux, plus compilers, Git, and lots of tools1 GBBuilding things (as the first stage of a multi-stage build, below). Rarely what you ship.
        python:3.12-slimMinimal Debian Linux plus Python125 MBThe sensible default. Use this unless you have a reason not to.
        python:3.12-alpineAlpine Linux, a tiny distribution, plus Python50 MBSize 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.
        DistrolessJust the runtime and your app. No shell, no package manager.50 MBLocked-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 &&:

        Dockerfile fragmentinstall a system package, then tidy up, in one layer
        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.

        Dockerfilemulti-stage: build in the big image, ship the small one
        
        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
        
        
        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:

        .dockerignorenever sent to the builder, never in the image
        .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-slim lives. 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.

        Terminalpush to a cloud registry (AWS shown)
        
        aws ecr get-login-password --region us-east-1 \
          | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
        
        
        docker tag photos-api:1.4.0 123456789012.dkr.ecr.us-east-1.amazonaws.com/photos-api:1.4.0
        
        
        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:

        Plate 7.1 — Nickname or fingerprint?Three teams deploy photos-api three ways · step through the week
        —

        In the registry right now: which tag points where

          Three deployments, each with three servers

          Your call

          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.

          The practical rule

          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>.

          The Mac gotcha: “exec format error”

          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:

          Plate 8.1 — The secret that wouldn’t dieDeleted in a later layer ≠ deleted
          Image size
          —total
          Your part
          —your layers
          File layers
          —sheets in the stack

          Top of the stack ↓ (click a layer)

            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. .env goes 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.

            Dockerfileversion 2 — the one to copy
            FROM python:3.12.7-slim-bookworm
            
            
            RUN useradd --create-home --uid 10001 appuser
            
            WORKDIR /app
            ENV PYTHONUNBUFFERED=1
            
            
            COPY requirements.txt .
            RUN pip install --no-cache-dir -r requirements.txt
            
            
            COPY . .
            
            
            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.

            Table 9.1 — The working set
            CommandWhat it does
            docker build -t name:tag .Build an image from the Dockerfile in this folder and label it.
            docker imagesList the images on your machine, with sizes.
            docker history name:tagShow an image’s layers and what each one added (Plate 4.1, for real).
            docker run --rm -p 8000:8000 name:tagStart a container from an image, with a port connected.
            docker run -d --name api name:tagStart one in the background (-d, “detached”) with a name you choose.
            docker ps / docker ps -aList running containers / all containers, including stopped ones.
            docker logs -f apiShow a container’s output; -f keeps following it.
            docker exec -it api shOpen a shell inside a running container, to look around.
            docker stop api / docker rm apiStop a container / delete a stopped one (and its scratch pad).
            docker image inspect name:tagRead the settings card (Section 02) and the digests.
            docker tag src:tag registry/repo:tagAdd another name to an image (needed before pushing).
            docker push / docker pullUpload to / download from a registry.
            docker scout cves name:tagScan an image for known security holes (or use trivy image).
            docker compose up --buildBuild and start several containers together from a compose.yaml (Section 10).
            docker system df / docker image pruneSee 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=1 helps 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.

            compose.yamllocal stand-in for a whole multi-service system
            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.

            Score 0 / 0

            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.

            Bring to Session 13

            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 FROM that yours starts from, such as python: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:container actually connects a port when you run it.
            Build context
            The folder (the . in docker 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.