AI EngineeringZero to ProductionHome·About·What’s new·Contact
Containers & Deployment · Chapter CD2

Images, layers & optimization

Fast, lean, safe images: layers & the cache (modeled in Python), base-image choice, .dockerignore, multi-stage builds, finding bloat, the hardening checklist, and a CI scoring gate.

⏱️ ~3 hours🧪 10 labs🎯 Beginner→Tech-lead

Learning objectives

  • Explain layers, the build cache, and how to exploit them.
  • Cut image size with slim bases, .dockerignore, and multi-stage builds.
  • Apply the full production hardening checklist.
  • Score and gate image quality in CI.
▶ Runnable companionCode saved under code/cd2-images-layers/. Python runs offline; configs are ready to use.

1 · Images are stacks of layers essential

Here's the moment this chapter pays for itself. You change one line of your app, run docker build, and wait two minutes while Docker re-installs every dependency from scratch — again — even though you never touched the requirements. A colleague on the same project changes a line and their build finishes in three seconds. Same code, same tools; the only difference is that they understand how an image is assembled, and you don't yet. This section fixes that, and everything else in the chapter — cache tricks, size cuts, multi-stage builds — is just consequences of the one idea below.

Picture an image the way you'd picture a stack of transparent sheets on an overhead projector. You lay down a first sheet (the operating system), then a second on top (your dependencies), then a third (your code). Each sheet only draws the changes it adds; stack them and you see the finished picture. That's a Docker image: not one solid blob, but a pile of thin, ordered, read-only layers, each one recording the filesystem change made by a single Dockerfile instruction. The image you run is simply "all these sheets, in this order, flattened."

Two properties of this design are what make Docker fast, and they're worth naming before we go on. First, layers are cached: once Docker has built a layer, it keeps the result and reuses it verbatim next time — no work repeated. Second, layers are shared: if ten images all start FROM python:3.12-slim, that base layer is stored on disk once and every image points at the same copy. This is why pulling your second Python image is nearly instant, and why a hundred images don't cost a hundred times the disk. The container you actually run then adds one more sheet on top — a thin writable layer — so your program can create temp files without ever mutating the read-only stack beneath it.

The mistake nearly everyone makes at first is treating a Dockerfile as an ordinary shell script where line order is just personal style. It isn't. Because each line is a cached layer stacked on the ones before it, the order of your instructions silently decides how much work every future rebuild does. Put the thing you edit hourly (your code) near the bottom and the thing you touch monthly (dependencies) near the top, and 95% of your rebuilds skip the slow part entirely. Get that order backwards and you pay full price every time. Hold that thought — the diagram below shows the stack, and §2 turns this ordering insight into a hard, measurable rule.

Every Dockerfile instruction creates a read-only layer; the image is those layers stacked. A running container adds a thin writable layer on top. Layers are cached and shared between images — the key to both speed and small storage.

FROM base shared base COPY reqs deps list RUN pip install installed deps COPY code your code
🗺️ How to read this diagram

A Docker image is built up in slices called layers, and this picture shows them left-to-right in the exact order the Dockerfile runs. Each box is one instruction, and each instruction adds one read-only layer on top of the one before it.

  • Read the boxes left to right — that is the order the instructions run and the order the layers stack. FROM base is the starting point (a ready-made OS + Python), then COPY reqs brings in your dependency list, RUN pip install installs those dependencies, and COPY code adds your own program last.
  • The small grey text under each box (shared base, deps list, installed deps, your code) tells you what that layer contains.
  • The arrows mean "depends on everything before it". Docker can re-use a layer from a previous build (a cache hit) only if that instruction and every box to its left are unchanged.
  • So if you edit COPY code (the last box), only that one layer rebuilds. But if you change something early — say COPY reqs — every box to its right must rebuild too, because they all sit on top of it.

In short: Layers stack in order and each one depends on the ones before it. Put the things that rarely change (base image, dependencies) first so their layers stay cached, and put the thing you edit constantly (your code) last.

2 · The build cache — order matters enormously essential

This is the single highest-leverage habit in all of Docker, and it costs you nothing but paying attention to line order. A team that gets it right rebuilds in seconds and iterates fast; a team that gets it wrong burns a coffee break on every trivial edit and slowly learns to dread deploying. The difference is not tooling or hardware — it's whether the slow step in their Dockerfile sits above or below the fast-changing one.

Think of the cache like a checklist you re-run top to bottom. Docker walks your instructions in order and, for each one, asks: "Is this step — and everything above it — exactly as it was last time?" As long as the answer is yes, it reuses the finished layer for free and moves on. The instant it hits a step that changed, the honeymoon is over: that layer and every single layer below it must be rebuilt, no matter how expensive. There's no skipping ahead and no reaching back — the cache invalidates from the first changed layer straight down to the end.

A kitchen analogy makes the rule stick. Imagine prepping a dish where step 2 is "simmer the stock for forty minutes" and step 5 is "sprinkle parsley on top." If you decide to change the parsley, you obviously don't re-simmer the stock — the slow work near the start is already done and untouched. But if you change the stock (an early step), everything downstream that was built on it has to be redone. Docker is exactly this literal: expensive, stable steps belong early so they stay done; cheap, volatile steps belong late so a change to them is cheap to redo.

The classic self-inflicted wound is writing COPY . . (which grabs all your source, and changes on nearly every commit) before the RUN pip install that takes forty seconds. Now every one-character edit to your code invalidates the copy layer, which drags the whole dependency install down with it — you re-install NumPy and Pandas to fix a typo. The fix is almost insultingly simple: copy only requirements.txt first, install, and copy the rest of your code last. Because your dependency list changes far less often than your code, the slow install layer stays cached across the vast majority of builds. That's the golden rule — rarely-changing things first, often-changing things last — and the lab below turns it into a stopwatch you can read.

Docker reuses a layer's cache if that instruction and everything before it are unchanged. So the golden rule: put things that change rarely (dependencies) before things that change often (your code).

edit CODE (last layer) FROM base · cached ✓ COPY reqs · cached ✓ pip install 40s · cached ✓ COPY code · rebuild (1s) total ≈ 1s edit REQS (early layer) FROM base · cached ✓ COPY reqs · rebuild ✗ pip install 40s · rebuild ✗ COPY code · rebuild ✗ total ≈ 41s The cache invalidates from the first changed layer downward. Edit the last layer and only it rebuilds; edit an early layer and every layer below it rebuilds too — so the slow install belongs above your fast-changing code.
🗺️ How to read this diagram

Read each stack top to bottom in Dockerfile order; green means the layer was reused from cache (free), red means it had to rebuild.

  • The left stack is the good order after you edit your code. Only the bottom COPY code box turns red — everything above it, including the 40-second install, stays green and cached.
  • The right stack is what happens when you change an early layer (COPY reqs): that box and every box below it go red, because each layer sits on top of the one before.
  • The totals at the bottom are the payoff: ≈1s versus ≈41s for essentially the same build — the whole cost difference is which layer you invalidated.

In short: A changed layer, plus everything after it, rebuilds. Keep the slow, stable steps early so an everyday code edit only pays for the cheap last layer.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · model the cache to see the win (runs)
cache_model.pydef rebuild_time(layers, changed):
    """layers: [(name, seconds)]. A layer rebuilds if it or anything before it changed."""
    total, broken = 0, False
    for name, secs in layers:
        if name in changed: broken = True
        if broken: total += secs
    return total

good = [("FROM",2), ("COPY reqs",1), ("RUN pip install",40), ("COPY code",1)]
bad  = [("FROM",2), ("COPY code",1), ("RUN pip install",40)]   # code before install
print("edit code, GOOD order:", rebuild_time(good, {"COPY code"}), "s")   # 1s
print("edit code, BAD order: ", rebuild_time(bad,  {"COPY code"}), "s")   # 41s
print("same edit, 40x slower rebuild just from instruction order")
edit code, GOOD order: 1 s
edit code, BAD order:  41 s
same edit, 40x slower rebuild just from instruction order
▶ How this works

This tiny Python program models the Docker build cache so you can see, in plain numbers, why instruction order matters. It is not running Docker — it is simulating how long a rebuild takes given which layer you changed.

  1. rebuild_time(layers, changed) takes a list of layers — each is a (name, seconds) pair saying how long that step takes — and a set of layer names that changed since last build.
  2. It walks the layers in order. The moment it hits a changed layer it flips broken = True, meaning "the cache is broken from here on". Every layer from that point adds its secs to total; layers before the change stay cached and cost nothing.
  3. good installs dependencies before copying code; bad copies code before installing. Both then simulate the same everyday action: you edited COPY code.
  4. In good order, COPY code is last, so only its 1 second rebuilds. In bad order, changing the earlier COPY code layer forces the 40-second RUN pip install that sits after it to run again.

What the output means: GOOD order: 1 s vs BAD order: 41 s — the exact same code edit is 40× slower to rebuild purely because the dependency install was placed after the code copy.

Try this: Change {"COPY code"} to {"FROM"} and re-run: changing the very first layer rebuilds everything, so both orders become slow. That is why the base image goes first — you almost never change it.

config · the cache-friendly Dockerfile
Dockerfile.cacheFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .            # changes rarely -> this layer stays cached
RUN pip install --no-cache-dir -r requirements.txt
COPY . .                           # changes often -> only THIS layer rebuilds
CMD ["python", "app.py"]
▶ How this works

This is the same lesson as the Python model, but written as a real Dockerfile. Each line is one instruction that becomes one layer, and the ordering is deliberately chosen so the slow step stays cached.

  1. FROM python:3.12-slim picks the starting image (a small Python). WORKDIR /app sets the folder inside the image where the next commands run.
  2. COPY requirements.txt . copies only the dependency list first — on its own, before your code. Your requirements change rarely, so this layer (and the install after it) usually stays cached.
  3. RUN pip install ... -r requirements.txt is the slow step. Because it sits right after the requirements copy, Docker re-uses its cached result on every build where requirements.txt did not change.
  4. COPY . . copies your actual source code last, because it changes on almost every build. Only this final layer rebuilds when you edit your code — the expensive install above stays cached.

Try this: If you instead put COPY . . above the pip install line, every code edit would re-run the install. This one ordering choice is the single most common Docker speed-up.

3 · Shrinking images — base image choice intermediate

A bloated image isn't just untidy — it costs you money and minutes every single day. Picture the whole lifecycle: your CI pipeline pushes the image to a registry (upload the fat gigabyte), then every server or Kubernetes node that runs it pulls the gigabyte back down before the container can even start. Multiply that by dozens of deploys and hundreds of nodes and a careless base-image choice turns into slow rollouts, sluggish autoscaling, and a fatter cloud bill — for an app whose actual code is a few hundred kilobytes.

The intuition to carry: your image is only as small as the foundation you build it on, and the foundation is the FROM line. Think of it like renting a truck to move a single armchair. The default python:3.12 image is the full moving van — it ships an entire build toolchain, compilers, and OS utilities you'll almost never touch at runtime, close to a gigabyte before you add a line of your own. A -slim variant is the sensible hatchback: the Python you need and little else. Since everything you COPY and RUN stacks on top of that base, starting lean is the one decision that shrinks the total more than any other.

There's a second, quieter reason smaller is better, and it matters more than speed: attack surface. Every package baked into the base is code an attacker could potentially exploit and a vulnerability scanner will flag. A slim or distroless image with no shell, no package manager, and no compilers simply has less for anything to go wrong with. The table below lays out the honest trade-offs — slim is the right default for almost everyone; alpine is tiny but its musl C library occasionally breaks pre-built Python wheels and forces slow source compiles; distroless is the most locked-down but gives you no shell to exec into when you need to debug.

The mistake to avoid is optimizing the wrong thing first. Beginners often reach straight for alpine chasing the smallest number on the chart, then lose an afternoon fighting cryptic wheel-build failures — a poor trade when -slim would have gotten them 85% of the saving with zero drama. And the base image is only half the size story: the other half is what you needlessly stuff into the build, which is exactly what the .dockerignore lab below is about. Choose a lean base, keep junk out of the context, and you've handled the two biggest levers before ever reaching for the advanced tricks in §4.

Image size drives push/pull time, cold-start latency, and attack surface. The single biggest lever is the base image.

python:3.12 · ~1 GB · full toolchain python:3.12-slim · ~150 MB · sensible default python:3.12-alpine · ~50 MB · musl can break wheels distroless · ~50 MB · no shell, hardest to debug bar length = image size · everything you add stacks on top of this The base image sets your floor. The full python:3.12 is ~7× the size of -slim before you add a single line; -slim is the low-drama default, while alpine and distroless go smaller at a real usability cost.
🗺️ How to read this diagram

Read the bars top to bottom, longest to shortest — the length of each bar is roughly how many megabytes that base image weighs before you copy in any of your own code.

  • The red top bar is the full python:3.12 at ~1 GB — it ships compilers and OS tools you rarely need at runtime, which is why it stretches across the whole chart.
  • The amber bar is python:3.12-slim at ~150 MB — a fraction of the full image and the recommended default for almost everyone.
  • The two short bars (alpine, distroless) are ~50 MB — smallest, but the labels name the catch: alpine's musl libc can break pre-built wheels, and distroless has no shell to debug in.

In short: Because everything you build stacks on the base, switching from the full image to -slim is the single biggest size win — worth far more than chasing the last few megabytes with alpine.

Base~SizeNotes
python:3.12~1 GBfull toolchain; dev only
python:3.12-slim~150 MBthe sensible default
python:3.12-alpine~50 MBtiny; musl libc can break some wheels
distroless~50 MBno shell/pkg mgr; most secure, harder to debug
config · .dockerignore keeps junk out
.dockerignore# .dockerignore  (like .gitignore, for the build context)
.venv/
__pycache__/
*.pyc
.git/
tests/
.env
*.md
# smaller context = faster builds + nothing secret/bulky sneaks into the image
▶ How this works

Before Docker builds anything, it bundles up your project folder and sends it to the builder — this bundle is called the build context. A .dockerignore file lists what to leave out of that bundle, exactly like .gitignore keeps files out of Git.

  1. Each line is a file or folder pattern to exclude. .venv/, __pycache__/ and *.pyc are local Python junk that has no business inside the image.
  2. .git/ (your whole version history) and tests/ are large and not needed to run the app, so excluding them shrinks the context and the image.
  3. .env and *.md matter for a different reason: .env holds secrets you must never copy into an image, and docs just add weight.
  4. The final comment states the two payoffs: a smaller context builds faster, and nothing secret or bulky can accidentally get copied in by a broad COPY . ..

Try this: Add a giant file like data/ to a project with no .dockerignore and watch docker build pause on "sending build context" — that pause is Docker uploading everything you forgot to ignore.

4 · Multi-stage builds — ship only what runs advanced

Here's the frustration that multi-stage builds were invented to end. To install many Python packages you need compilers and development headers — the C toolchain that turns source into working code. But once the packages are built, you never need those tools again to run the app. Without multi-stage builds you're stuck: either you ship all those compilers to production (a fat, insecure image) or you contort your Dockerfile with fragile apt-get install ... && ... && apt-get purge gymnastics trying to delete them afterward — which, as §5 shows, doesn't even shrink the image. Multi-stage builds make the whole dilemma disappear.

The mental model is a professional kitchen versus the plate that reaches the table. The kitchen is chaos — knives, mixers, flour everywhere, pots that will never leave the building. The plate carries only the finished dish. A multi-stage build gives you exactly that separation: a first "builder" stage is your messy kitchen, stuffed with compilers and dev headers, where the real work of installing dependencies happens. A second, pristine "runtime" stage is the plate — it starts fresh from a tiny base and receives only the finished result copied across. The diners (production) never see the kitchen.

The mechanism that makes this work is one keyword and one flag. You name the first stage — FROM python:3.12 AS builder — and later, in the slim final stage, you reach back into it with COPY --from=builder /path /path to lift out just the built artifacts. Everything else in the builder — every compiler, every dev header, the hundreds of megabytes of toolchain — is simply left behind, discarded when the build finishes. The final image contains a clean base, your dependencies, and your code, and nothing that was only needed to assemble it. Smaller to push and pull, and a far smaller attack surface because there's no shell-scripting toolchain sitting in production for an intruder to abuse.

The common misconception is that multi-stage builds are an exotic, expert-only technique you'll reach for someday. In reality they're the default for any real Python service, and the pattern is short enough to memorize: build in a fat stage, copy the result into a slim stage, ship the slim stage. The one subtlety to respect is that the runtime stage is a genuine clean slate — nothing from the builder tags along unless you explicitly COPY --from it, so if you forget to copy a needed file the runtime will fail to find it. The lab below shows the pattern, and the size-estimate lab right after puts a hard percentage on the payoff.

A multi-stage build uses a fat "builder" stage (compilers, dev headers) and copies only the result into a tiny final image. Build tools never ship to production — smaller and safer.

Stage 1 · builder (fat) FROM python:3.12 · compilers RUN pip install → /deps /deps (built result) everything else discarded COPY --from=builder Stage 2 · runtime (slim, ships) FROM python:3.12-slim /deps (copied in) COPY code · USER 1000 no compilers ship Build in the kitchen, ship only the plate. The builder stage holds the compilers and installs dependencies; COPY --from=builder lifts out just the built result into a clean slim stage — the toolchain is left behind.
🗺️ How to read this diagram

Read it left to right: the big box on the left is the throwaway "builder" stage, the box on the right is the slim "runtime" stage that actually ships, and the arrow between them is the one line that connects the two.

  • The left builder box starts from the full Python image (compilers included) and runs pip install into a single /deps folder — the green box is that finished result.
  • The arrow labelled COPY --from=builder is the mechanism: it reaches back into the builder and lifts out only /deps. The grey note "everything else discarded" is the whole point — compilers never cross the arrow.
  • The right runtime box begins fresh from -slim, receives the copied deps, adds your code, and drops to a non-root USER — this is the only image that ships, and it carries "no compilers".

In short: One keyword (AS builder) and one flag (COPY --from=builder) let you do the heavy build in a fat stage but ship a slim one — smaller to pull and a smaller attack surface.

config · multi-stage
Dockerfile.multi# Stage 1 — build (has compilers/dev tools)
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt

# Stage 2 — runtime (tiny; only deps + code, no build tools)
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .
USER 1000                          # non-root
CMD ["python", "app.py"]
▶ How this works

A multi-stage build uses two images in one Dockerfile: a big "builder" that has all the compilers and dev tools needed to install things, and a tiny "runtime" that ships to production with only the finished result — no build tools.

  1. FROM python:3.12 AS builder starts the first stage on the full Python image (includes compilers). Naming it AS builder lets a later stage copy files out of it.
  2. RUN pip install --target=/deps ... installs all dependencies into one folder (/deps) inside the builder, so the whole result is easy to grab in one move.
  3. FROM python:3.12-slim begins the second stage on the small image. This is a fresh start — none of the builder's compilers come along automatically.
  4. COPY --from=builder /deps ... reaches back into the builder stage and copies only the installed packages into the slim image. COPY . . adds your code, USER 1000 switches off the root account, and only this slim stage is what actually ships.

Try this: The pairing with the next lab is the point: the fat builder can be 400 MB of tools, but because none of it is copied forward, the final image only carries the base + dependencies + your code.

Python · estimate the size saving (runs)
size_estimate.pydef image_size(base_mb, deps_mb, build_tools_mb, multistage):
    # multi-stage drops the build tools from the final image
    return base_mb + deps_mb + (0 if multistage else build_tools_mb)

single = image_size(150, 120, 400, multistage=False)
multi  = image_size(150, 120, 400, multistage=True)
print(f"single-stage: {single} MB")
print(f"multi-stage:  {multi} MB  ({round(100*(single-multi)/single)}% smaller)")
single-stage: 670 MB
multi-stage:  270 MB  (60% smaller)
▶ How this works

This program puts a number on the multi-stage saving. It adds up the megabytes an image carries and shows how much you cut by leaving build tools behind.

  1. image_size(base_mb, deps_mb, build_tools_mb, multistage) sums the pieces of an image: the base OS, your dependencies, and (only for a single-stage build) the heavy build tools.
  2. The key line adds build_tools_mb only when multistage is False: (0 if multistage else build_tools_mb). A multi-stage image carries zero build-tool weight.
  3. single and multi call it with identical numbers (150 MB base, 120 MB deps, 400 MB tools) — the only difference is the multistage flag.
  4. The f-string prints each total, and round(100*(single-multi)/single) computes the percentage shrink so the win is stated as a headline figure.

What the output means: single-stage: 670 MB vs multi-stage: 270 MB (60% smaller) — dropping the 400 MB of build tools cuts the shippable image well over half.

Try this: Bump build_tools_mb from 400 to 800 and re-run — the multi-stage image stays 270 MB while the single-stage balloons, so the percentage saving climbs even higher.

5 · Layer inspection & caching busting advanced

Sooner or later you'll stare at a 900 MB image and have no idea where the weight came from. Your code is tiny, your requirements look reasonable, and yet the thing is enormous. Guessing wastes hours. The good news is that an image is not a black box — because it's a stack of layers and each layer records exactly what it added, you can open it up and read the size of every slice. Debugging bloat becomes a lookup, not a hunt.

The tool for this is docker history, and the right way to think about it is a receipt, itemized line by line. Just as a grocery receipt shows what each item cost so you can spot the one absurd charge, docker history shows how many megabytes each instruction added, top to bottom. When one line reads 400 MB and the rest read a few kilobytes, you've found your culprit in seconds — usually a RUN that downloaded something huge or an apt-get that pulled in half an OS. You optimize by reading the receipt and attacking the fattest line first, not by rewriting the whole Dockerfile on a hunch.

The flip side of the cache is knowing what busts it — what forces a rebuild whether you wanted one or not. A layer's cache is invalidated when the instruction text changes, when a file it copies changes, when a build argument it depends on changes, or when you explicitly pass --no-cache. And remember the invalidation cascades: bust one layer and everything below it rebuilds too. Most of the time the cache is your friend, but occasionally you need to force a clean build to prove the image assembles from scratch or to shake loose a stale dependency — that's what --no-cache is for, used deliberately rather than reflexively.

The subtle trap that catches even experienced people is assuming that if a file isn't in the final image, it isn't costing you. Layers are append-only: once bytes are committed to a layer they live there forever, and a later instruction can only hide them, never reclaim the space. So RUN wget big.tar followed by a separate RUN rm big.tar gives you an image that still carries the full download, plus a later layer that merely marks it deleted — the worst of both worlds. The cure, shown in the lab and the callout below, is to download, use, and delete a file within a single RUN … && … && rm … so the bytes are never committed to a layer in the first place.

Diagnose bloated images: docker history shows each layer's size, so you can find the instruction that added 400MB. And know the cache busters — a changed file, a new arg, or --no-cache forces a rebuild from that point.

two RUNs: download then delete wget big.tar · +400 MB committed RUN rm big.tar · only hides it image still carries 400 MB worst of both worlds one RUN: download && use && rm RUN wget … && … && rm big.tarbytes never committed to a layer image stays lean +0 MB Layers are append-only. A file downloaded in one RUN and deleted in a later one is already committed forever — the later rm only hides it. Chain download-use-delete in a single RUN so the bytes are never committed.
🗺️ How to read this diagram

Compare the two stacks side by side: the left is the naive two-RUN approach, the right is the single-RUN fix. Each box is a layer, top to bottom in build order.

  • On the left, the red top box commits 400 MB to a layer; the grey box below is a separate RUN rm that only hides the file. Because layers are append-only, the red bottom box shows the image still weighs the full 400 MB.
  • On the right, one green box does download, use, and rm all inside a single RUN … && … && rm, so the big file is gone before the layer is committed — nothing heavy ever lands.
  • The +400 MB vs +0 MB labels are the punchline: same intent, but only the single-RUN version actually reclaims the space.

In short: You can't delete bytes out of an earlier layer — you can only avoid committing them. Do download, use, and cleanup in one RUN.

shell · find what bloats an image
history.shdocker history myapp --human           # size added by each layer
docker image inspect myapp --format '{{.Size}}'
docker build --no-cache -t myapp .      # force full rebuild (ignore cache)
# a RUN that downloads then deletes in SEPARATE layers still keeps the download
# in the earlier layer -> combine with && in ONE RUN to keep it out
▶ How this works

These shell commands are the detective tools for a bloated image: they show you which layer added the weight, and how to force a clean rebuild. Run them in a terminal where Docker is installed.

  1. docker history myapp --human lists every layer of the image myapp with the size each one added, in human-readable units (MB/GB). This is how you spot the one instruction that added 400 MB.
  2. docker image inspect myapp --format '{{.Size}}' prints just the total image size as a single number — handy for scripts and before/after comparisons.
  3. docker build --no-cache -t myapp . rebuilds from scratch, ignoring every cached layer. Use it to prove a fresh build works or to bust a stale cache.
  4. The comment flags a classic trap: if a RUN downloads a big file and a separate later RUN deletes it, the bytes are already committed in the earlier layer forever. Chain download-use-delete in one RUN ... && ... so they never land in a layer.

Try this: On any image you have locally, run docker history <image> --human and read it top-to-bottom: the fattest lines are your optimisation targets.

Deleting in a later layer doesn't shrink the imageRUN wget big.tar then RUN rm big.tar leaves the file in the first layer forever. Do downloads, use, and cleanup in a single RUN … && … && rm … so the bytes never get committed to a layer.

6 · Professional — the production hardening checklist professional

An image that merely "works" and an image that's ready for production are two very different things — and the gap is where incidents come from. The image that works on your laptop happily runs as the all-powerful root user, bakes a stray API key into a layer, and gives the orchestrator no way to tell whether the app inside is actually alive. Ship that and you've handed an attacker root, leaked a credential to every registry that pulls the image, and left your platform restarting a hung container by luck rather than design. Hardening is the discipline of closing those gaps before they become a 2 a.m. page.

The mindset to adopt is that a container is a box you're handing to strangers — other teams, a CI system, a public cloud node — so you assume the worst about what happens inside it. If the process is compromised, how much damage can it do? Run it as root and the answer is "everything the host allows"; run it as an unprivileged user and a break-in is trapped in a sandbox with almost no power. This is the principle of least privilege made concrete: the app gets exactly the permissions it needs to do its job and not one more. A useradd plus a USER line is the cheapest security win in the whole chapter.

Two more practices round out the checklist, and each answers a real operational question. A health check gives the platform a heartbeat: instead of assuming a running process means a working app, Docker periodically pokes an endpoint and marks the container unhealthy — so it can be restarted or pulled from the load balancer — when the app has silently wedged. And keeping secrets out of the image is non-negotiable: a key copied in via COPY .env or written in the Dockerfile is committed to a layer permanently and travels with the image everywhere, so secrets must be injected at runtime (environment variables, mounted files, a secrets manager) and never at build time.

The mistake that undoes all of this is treating hardening as a final polish step you'll "get to later." Later never comes, and the insecure image quietly becomes the template everyone copies. The fix is to make the hardened Dockerfile your starting point, not your cleanup — which is exactly why the next section turns this checklist into an automated scorer that fails the build when any box is unchecked. The lab below is that starting point: a single file that folds in the slim base, cache-friendly order, a non-root user, ownership via --chown, and a HEALTHCHECK, so you never have to remember the list by hand.

"just works" image runs as root secret baked in a layer no health signal full base image harden production-ready image USER appuser (non-root) secret injected at runtime HEALTHCHECK polls /healthz slim base · --chown code least privilege · nothing secret in a layer · a heartbeat to poll Hardening closes the gaps before they page you. Each red risk on the left maps to a concrete fix on the right — drop root, inject secrets at runtime not build time, and give the platform a health signal.
🗺️ How to read this diagram

Read it left to right: the red column is the image that merely "works on my laptop", the green column is the same image hardened, and each row is one risk paired with its fix.

  • The red boxes are the four default dangers — running as all-powerful root, a secret committed into a layer, no way for the platform to tell if the app is alive, and a bloated full base.
  • The "harden" arrow is the transformation; follow each red row straight across to its green counterpart.
  • The green boxes are the concrete fixes: a non-root USER, secrets injected at runtime instead of baked in, a HEALTHCHECK that polls /healthz, and a slim base with --chowned code.

In short: Hardening is not a vibe — it is a fixed list of risk→fix swaps. Give the app the least privilege it needs, keep secrets out of every layer, and hand the platform a heartbeat to watch.

config · production Dockerfile
Dockerfile.prodFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home appuser         # a non-root user
USER appuser                              # never run as root in prod
COPY --chown=appuser . .
HEALTHCHECK --interval=30s --timeout=3s \
  CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]
▶ How this works

This is the production-ready Dockerfile — it folds in every safety practice from the lesson: small base, cache-friendly order, a non-root user, and a health check so the platform knows the app is alive.

  1. The first four lines are the cache-friendly pattern you already saw: slim base, WORKDIR, copy requirements.txt first, then pip install.
  2. RUN useradd --create-home appuser creates an ordinary user, then USER appuser switches to it. Everything after runs as this user, not root — so a break-in has far less power. COPY --chown=appuser . . gives that user ownership of the code.
  3. HEALTHCHECK tells Docker how to test if the app is healthy: every 30s it runs a tiny Python one-liner that fetches /healthz. If that fails, the container is marked unhealthy and can be restarted.
  4. CMD ["uvicorn", ...] is the command that actually launches the web app when the container starts, binding it to all interfaces on port 8000.

Try this: Compare this with Dockerfile.cache above: the differences (non-root user, --chown, HEALTHCHECK) are exactly the boxes the scorer in the next lab checks for.

Never bake secrets into an imageA key in the Dockerfile or COPY .env is committed to a layer forever and ships to every registry that pulls it — deleting it later doesn't help. Pass secrets at runtime and .dockerignore your .env.

7 · Tech-lead — score images & gate CI tech-lead

A checklist that lives in someone's head is a checklist that gets skipped under deadline pressure. You can write the most beautiful hardening guide, but on the Friday-afternoon deploy the tired engineer forgets the non-root user, nobody catches it in review, and the insecure image ships. Relying on human diligence to enforce standards is how standards quietly erode. The tech-lead move is to stop asking people to remember and start making the pipeline refuse anything that falls short.

The idea is to turn the checklist into a credit score for your image. Just as a lender doesn't debate your character but reads a number, CI shouldn't host an opinion-fight about whether an image is "good enough" — it should read a score and act. You start every image at a perfect 100 and subtract points for each sin: too big, runs as root, an unpinned base, no health check, and — the fatal one — a baked-in secret. Weighting matters here, and it encodes real judgment: a slightly oversized image is a minor ding, but a leaked credential is catastrophic on its own, so it alone should sink the score below any passing line.

The second half of the pattern is the gate: a hard threshold (say, score ≥ 70) that turns the number into a pass or fail the build must respect. This is the difference between a linter that warns and a gate that blocks — warnings get ignored, gates get fixed. Returning the human-readable reasons alongside the score is what makes it humane rather than tyrannical: when the build fails, the engineer sees exactly which boxes are unchecked and what to do, instead of a bare red X. Policy replaces politics, and the same standard applies to everyone including you.

The trap to sidestep is building a scorer so strict or so noisy that the team learns to route around it — copying the "skip CI" incantation until the gate is theater. A good gate is calibrated: it blocks the things that genuinely matter (secrets, root, gross bloat) and stays quiet about the rest, so a failure always means something real. The lab below is a compact, honest version of exactly this scorer — small enough to read in a minute, weighted the way a real team would weight it, and wired to a ≥ 70 gate you can drop straight into a pipeline.

Make image quality objective and enforced. A scorer turns the checklist into a CI pass/fail — no opinions, just policy.

startscore = 100 subtract penalties size > 400MB … −25 runs as root … −40 secret baked in … −100 unpinned base … −20 no healthcheck … −10 gatescore ≥ 70 ? PASS FAIL one fatal issue (secret −100) sinks the score alone The checklist becomes a credit score with a hard cutoff. Every image starts at 100, loses weighted points per sin, then a ≥ 70 gate branches the build to PASS or FAIL — policy replaces argument.
🗺️ How to read this diagram

Follow the arrows left to right: a score is created, penalties are subtracted, then a single gate decides the build's fate.

  • The start box gives every image a perfect 100 — you are innocent until proven bloated.
  • The middle box lists the weighted deductions. The weights encode judgment: a slightly big image is a small ding (−25), but a baked-in secret is −100 (shown in red) — fatal on its own.
  • The gate diamond asks one question, score ≥ 70?, and the two arrows split to the green PASS or red FAIL outcome — the same threshold for everyone, no debate.

In short: Turn the hardening checklist into a number and a cutoff. The build passes or fails on policy, and one catastrophic issue (a leaked secret) fails it regardless of everything else.

Python · image policy scorer (runs)
image_score.pydef score_image(facts):
    score, notes = 100, []
    if facts["size_mb"] > 400:      score -= 25; notes.append("image > 400MB")
    if facts["runs_as_root"]:       score -= 40; notes.append("runs as root")
    if facts["has_secrets"]:        score -= 100; notes.append("secrets baked in!")
    if not facts["pinned_base"]:    score -= 20; notes.append("unpinned base")
    if not facts["has_healthcheck"]:score -= 10; notes.append("no HEALTHCHECK")
    return max(score, 0), notes

good = score_image(dict(size_mb=180,runs_as_root=False,has_secrets=False,pinned_base=True,has_healthcheck=True))
bad  = score_image(dict(size_mb=900,runs_as_root=True, has_secrets=True, pinned_base=False,has_healthcheck=False))
print("good:", good)
print("bad: ", bad)
print("CI gate (>=70):", "PASS" if good[0]>=70 else "FAIL", "/", "PASS" if bad[0]>=70 else "FAIL")
good: (100, [])
bad:  (0, ['image > 400MB', 'runs as root', 'secrets baked in!', 'unpinned base', 'no HEALTHCHECK'])
CI gate (>=70): PASS / FAIL
▶ How this works

This turns the hardening checklist into an automatic grade. Instead of arguing about whether an image is "good enough", a CI pipeline runs this scorer and blocks anything that scores too low — policy, not opinion.

  1. score_image(facts) starts every image at a perfect 100 with an empty notes list, then subtracts points for each problem it finds.
  2. The penalties are weighted by how much each issue matters: too big (-25), runs as root (-40), and has_secrets is a fatal -100 — one baked-in secret alone drops the score to zero. Every deduction also appends a human-readable reason to notes.
  3. max(score, 0) stops the score going negative, and the function returns the number plus the list of reasons so a human can see why it failed.
  4. good and bad are two example fact sheets. The final line applies the gate: PASS if the score is >= 70, otherwise FAIL.

What the output means: good: (100, []) passes clean; bad hits every penalty, lands at 0 with all five reasons listed, and the gate prints PASS / FAIL.

Try this: Flip one field of bad — say set has_secrets=False — and re-run. The score jumps by 100 but other penalties may still keep it under 70, showing how a single fix is rarely enough to pass the gate.

Exercise CD2.1 — Optimize a bloated image

Context: Taking a fat, naive image and driving it down to a lean, hardened one is the everyday craft of image work. Doing it against a measurable target — a passing score — is how real teams keep image quality from drifting.

Your task: Start from a single-stage image on the full python:3.12 base, measure it with docker history, then optimize it step by step and prove the improvement reaches a passing gate.

Requirements:

  • Add a .dockerignore and reorder instructions for cache friendliness
  • Switch to a slim base and add a non-root user plus a HEALTHCHECK
  • Convert the build to multi-stage so build tools never ship
  • Run size_estimate to quantify the size drop
  • Run image_score and confirm the result passes the ≥ 70 gate

💡 Hint: Read docker history top to bottom to spot the fattest layers first — those are your highest-leverage optimization targets.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Order a cache-friendly DockerfileBeginner

Context: Every Dockerfile instruction becomes a cached layer, and the order of those layers decides whether a rebuild takes one second or forty. Getting the ordering right is the single most common Docker speed-up.

Your task: Order the four instructions of a cache-friendly Dockerfile so that editing your source code only rebuilds the last, cheap layer, and explain which line stays cached.

Requirements:

  • Copy requirements.txt and run pip install before copying your code
  • Put COPY . . last, because your code changes on almost every build
  • Keep the slow pip install layer cached when only code changed
  • Explain that Docker reuses a layer's cache only if it and everything before it are unchanged

💡 Hint: Docker walks layers top-down; anything after the first changed instruction must rebuild, so the rarely-changing dependency install belongs above your code copy.

Show solution

Docker reuses a layer's cache only if that instruction and everything before it are unchanged. So copy requirements.txt and install before copying your code.

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .            # changes rarely -> this layer stays cached
RUN pip install --no-cache-dir -r requirements.txt
COPY . .                          # changes often -> only THIS layer rebuilds
CMD ["python", "app.py"]

Because your requirements change rarely, the slow pip install stays cached across builds; editing code only rebuilds the final COPY . . layer.

Exercise 2 · Write a .dockerignoreIntermediate

Context: Before Docker builds anything it bundles your project folder into a 'build context' and ships it to the builder. A .dockerignore keeps junk, bulk, and secrets out of that bundle — and out of the image.

Your task: Write a .dockerignore that keeps local Python junk, the git history, tests, secrets, and docs out of the build context, and explain the two payoffs the lesson names.

Requirements:

  • Exclude local Python junk: .venv/, __pycache__/, *.pyc
  • Exclude bulk not needed to run the app: .git/, tests/
  • Exclude secrets and docs: .env, *.md
  • State payoff one: a smaller context builds faster
  • State payoff two: nothing secret or bulky is pulled in by a broad COPY . .

💡 Hint: It works exactly like .gitignore — one exclusion pattern per line; the .env line matters most because a baked secret ships with the image.

Show solution

The build context is the bundle Docker sends to the builder. .dockerignore works like .gitignore — each line is a pattern to exclude.

# .dockerignore  (for the build context)
.venv/
__pycache__/
*.pyc
.git/
tests/
.env
*.md

Two payoffs: a smaller context builds faster, and nothing secret or bulky (like .env) can be accidentally pulled in by a broad COPY . .. The .env line matters most — it holds secrets that must never land in a layer.

Exercise 3 · Quantify why layer order mattersAdvanced

Context: Claiming 'order matters' is abstract until you put a number on it. Modeling the build cache in plain Python turns instruction order into a concrete rebuild time you can compare.

Your task: Model the build cache in Python to quantify why instruction order matters: given (layer, seconds) pairs and which layer changed, compute the rebuild time for a good order (deps before code) versus a bad one (code before deps).

Requirements:

  • Take a list of (name, seconds) layers and a set of changed layer names
  • Walk the layers in order; once a changed layer is hit, the cache is broken from there on
  • Sum only the seconds of layers at or after the first change; earlier layers cost zero
  • Compare a good order (deps before code) to a bad one (code before deps) on the same code edit
  • Show the same edit is ~40x slower to rebuild purely due to ordering

💡 Hint: A single broken flag that flips true at the first changed layer, then adds every subsequent layer's cost, is the whole model.

Show solution

Walk the layers in order; once you hit a changed layer, the cache is broken from there on and every subsequent layer's cost is added.

def rebuild_time(layers, changed):
    """layers: [(name, seconds)]. A layer rebuilds if it or anything before it changed."""
    total, broken = 0, False
    for name, secs in layers:
        if name in changed:
            broken = True
        if broken:
            total += secs
    return total

good = [("FROM",2), ("COPY reqs",1), ("RUN pip install",40), ("COPY code",1)]
bad  = [("FROM",2), ("COPY code",1), ("RUN pip install",40)]
print("edit code, GOOD:", rebuild_time(good, {"COPY code"}), "s")   # 1s
print("edit code, BAD :", rebuild_time(bad,  {"COPY code"}), "s")   # 41s

Same code edit, 40x slower rebuild in the bad order — because the code copy sits before the 40-second install, changing it forces the install to re-run.

Exercise 4 · Multi-stage build & size savingExpert

Context: Compilers and dev headers are needed to install dependencies but have no business shipping to production. A multi-stage build keeps a fat builder stage and copies only the finished result into a tiny runtime image — smaller and safer.

Your task: Convert a single-stage image to a multi-stage build that leaves compilers and dev tools behind, and model the size saving in Python (base 150MB, deps 120MB, build tools 400MB).

Requirements:

  • Name the first stage FROM python:3.12 AS builder and install deps into one folder (e.g. --target=/deps)
  • Start a fresh slim runtime stage and copy only the result with COPY --from=builder
  • Switch off root in the runtime stage with a USER line
  • Model the size as base + deps, adding build tools only for the single-stage case
  • Report the percentage saving (670MB → 270MB, ~60% smaller)

💡 Hint: The runtime stage is a clean start — nothing from the builder comes along unless you explicitly COPY --from it, which is what strips the 400MB of tools.

Show solution

A fat builder stage installs deps into one folder; the slim runtime stage copies only that result via --from=builder, so build tools never ship.

# Stage 1 — build (has compilers/dev tools)
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt

# Stage 2 — runtime (tiny; only deps + code, no build tools)
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .
USER 1000                          # non-root
CMD ["python", "app.py"]
def image_size(base_mb, deps_mb, build_tools_mb, multistage):
    return base_mb + deps_mb + (0 if multistage else build_tools_mb)

single = image_size(150, 120, 400, multistage=False)
multi  = image_size(150, 120, 400, multistage=True)
print(f"single: {single} MB")
print(f"multi:  {multi} MB ({round(100*(single-multi)/single)}% smaller)")

670MB down to 270MB — 60% smaller — purely by not shipping the 400MB of build tools.

Exercise 5 · The production hardening DockerfileProfessional

Context: A production-ready image folds every safety practice into one file: small base, cache-friendly order, a non-root user, and a health check the platform can poll. It also avoids the classic trap of thinking a later delete shrinks an image.

Your task: Write the production hardening Dockerfile that folds in every safety practice, and state why deleting a big file in a later RUN does not shrink the image.

Requirements:

  • Use a slim base and the cache-friendly copy-then-install order
  • Create a non-root user with useradd and switch to it with USER
  • Give that user ownership of the code with COPY --chown
  • Add a HEALTHCHECK that polls the app's /healthz endpoint
  • Explain that a file deleted in a later layer stays committed in the earlier one — chain download-use-delete in a single RUN ... && ... && rm ...

💡 Hint: Layers are append-only: once bytes are committed to a layer, a later rm only hides them; and never COPY .env — a baked secret ships to every registry.

Show solution

Create and switch to a non-root user, --chown the copied code to it, and add a health check the platform can poll.

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home appuser         # a non-root user
USER appuser                              # never run as root in prod
COPY --chown=appuser . .
HEALTHCHECK --interval=30s --timeout=3s \
  CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/healthz')"
CMD ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]

A file downloaded in one RUN and deleted in a later RUN stays committed in the earlier layer forever. Chain download, use, and cleanup in a single RUN ... && ... && rm ... so the bytes never land in a layer. Never COPY .env — a baked secret ships to every registry that pulls it.

Exercise 6 · An objective image scorerIndustry scenario

Context: 'Is this image good enough?' should not be an opinion argued in review. A tech lead turns the hardening checklist into a weighted score and a hard CI gate.

Your task: As a tech lead, turn the hardening checklist into an objective CI scorer: start at 100 and deduct for size > 400MB (-25), running as root (-40), baked secrets (-100), an unpinned base (-20), and no HEALTHCHECK (-10), then gate at score ≥ 70.

Requirements:

  • Start every image at 100 with an empty notes list
  • Weight penalties by severity — a baked-in secret is a fatal -100
  • Append a human-readable reason for each deduction
  • Clamp the score at zero and return it alongside the notes
  • Apply a PASS/FAIL gate at ≥ 70 and show a clean image versus a failing one

💡 Hint: Weighting matters: one fatal issue (secrets) should sink the score alone, and returning the reasons lets CI tell the engineer exactly what to fix.

Show solution

Weight the penalties by how much each issue matters — a baked-in secret is fatal on its own. Clamp the score at zero and return the reasons.

def score_image(facts):
    score, notes = 100, []
    if facts["size_mb"] > 400:       score -= 25;  notes.append("image > 400MB")
    if facts["runs_as_root"]:        score -= 40;  notes.append("runs as root")
    if facts["has_secrets"]:         score -= 100; notes.append("secrets baked in!")
    if not facts["pinned_base"]:     score -= 20;  notes.append("unpinned base")
    if not facts["has_healthcheck"]: score -= 10;  notes.append("no HEALTHCHECK")
    return max(score, 0), notes

good = score_image(dict(size_mb=180, runs_as_root=False, has_secrets=False,
                        pinned_base=True, has_healthcheck=True))
bad  = score_image(dict(size_mb=900, runs_as_root=True, has_secrets=True,
                        pinned_base=False, has_healthcheck=False))
print("good:", good)
print("bad :", bad)
gate = lambda s: "PASS" if s >= 70 else "FAIL"
print("CI gate:", gate(good[0]), "/", gate(bad[0]))

The clean image scores 100 (PASS); the bad one hits every penalty, lands at 0 with all five reasons, and FAILs the gate — policy, not opinion, blocks the merge.

✓ Checkpoint — you can move on when you can…

  • Explain layers, the cache, and exploit instruction order.
  • Shrink images (slim, .dockerignore, multi-stage) and quantify it.
  • Find bloat with docker history; avoid delete-in-later-layer.
  • Apply the hardening checklist and gate images in CI.

Knowledge check check yourself

✓ Knowledge check

Why does putting COPY requirements.txt + pip install before COPY . . dramatically speed up rebuilds?

Show answer
Each instruction is a cached layer, and Docker reuses a layer's cache only if that instruction and everything before it are unchanged. Copying requirements and installing first keeps the slow install cached across builds where requirements didn't change, so editing your code (copied last) rebuilds only that final cheap layer -- the cache-model lab showed 1s vs 41s for the same edit.
✓ Knowledge check

How does a multi-stage build shrink the final image, and why doesn't deleting a large file in a later RUN achieve the same thing?

Show answer
A fat builder stage holds compilers/dev tools, and the slim runtime stage copies only the built result (--from=builder) so build tools never ship -- e.g. 670MB down to 270MB. Deleting a file in a later layer doesn't shrink the image because the bytes are already committed in the earlier layer; you must download, use, and remove within a single RUN ... && ... so they never get committed.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in