Dockerfile Deep Dive
Why this matters
The Dockerfile is the source of truth for how your image is built — every instruction in it becomes a layer, and the order and content of those instructions determines build speed, image size, and reproducibility. Writing a bad Dockerfile still "works" but rebuilds slowly, produces bloated images, and sometimes silently bakes in the wrong files. This is the module that turns Docker from "commands I copy-paste" into something you actually author.
Concepts
A Dockerfile is a script that produces layers
A Dockerfile is read top to bottom, and most instructions produce a new filesystem layer (module 02). The final image is the stack of those layers. Reading a Dockerfile is like reading a build script — except each step's result is cached and reusable.
Dockerfile docker build
┌────────────────────────┐ produces layers, bottom-up
│ FROM python:3.12-slim │ ───────► [ base layers ]
│ WORKDIR /code │ ───────► [ + metadata ]
│ COPY requirements.txt .│ ───────► [ layer ]
│ RUN pip install ... │ ───────► [ layer ]
│ COPY app.py . │ ───────► [ layer ]
│ CMD ["python","app.py"]│ ───────► [ + metadata ]
└────────────────────────┘ │
▼
tagged image (lab:v1)
Layer caching keys on the instruction and its inputs
Docker caches each layer keyed on the instruction plus its inputs. If an earlier layer's cache is invalidated (its instruction or the files it copies changed), every layer after it must rebuild too. This is why instruction order matters: put things that change rarely (installing dependencies) before things that change often (copying application code), so a code edit doesn't force a dependency reinstall.
edit app.py ─► which layers rebuild?
FROM python:3.12-slim CACHED ┐
COPY requirements.txt . CACHED │ unchanged inputs → cache hit
RUN pip install ... CACHED ┘
COPY app.py . REBUILD ◄── first invalidated layer
CMD ["python","app.py"] REBUILD ◄── everything AFTER also rebuilds
The build context is uploaded before the build starts
The build context is the set of files sent to the Docker daemon when
you run docker build <path> — everything under that path (respecting
.dockerignore) is uploaded before the build even begins. A huge context
(accidentally including node_modules or .git) slows every build
regardless of what your Dockerfile does with it.
.dockerignore trims the context
.dockerignore works exactly like .gitignore: patterns of files to
exclude from the context upload. Keeping it tight is the simplest way to
keep builds fast and avoid baking unwanted files into COPY . steps.
Instructions, in terms you already know
FROM— picks the base image (starting layers). Every Dockerfile needs one (orFROM scratchfor a truly empty base, module 07).WORKDIR— likecd, but also creates the directory and persists as the working directory for every later instruction and at runtime.COPY— copies files from the build context into the image. Plain file copy, nothing clever.ADD— likeCOPY, but also auto-extracts local.tararchives and can fetch URLs. PreferCOPYunless you specifically need that "magic" —ADD's implicit behavior is a common surprise.RUN— executes a command during the build and commits the result as a layer (e.g. installing packages). Different fromCMD/ENTRYPOINT, which define what happens at container start.ENV— sets an environment variable baked into the image, visible to laterRUNsteps and to the running container (likeexport VAR=value, but persisted in image metadata).ARG— a build-time-only variable, available duringdocker build(e.g. via--build-arg) but not in the final running container unless you also assign it into anENV.EXPOSE— documentation/metadata declaring which port the app listens on; it does not publish the port (that's-pondocker run, module 05).USER— switches which user laterRUNsteps and the final process run as (defaultroot). Same Linux user model as always; covered in depth in module 09.CMD— the default command at container start, overridable by args passed todocker run <image> <other-cmd>(exactly what happened accidentally in module 02's exercise 9).ENTRYPOINT— also defines the startup command, butdocker runargs are appended to it rather than replacing it.ENTRYPOINT+CMDtogether is common:ENTRYPOINTfixes the program,CMDsupplies default arguments that are easy to override.HEALTHCHECK— a command Docker runs periodically inside the container to decide if it's "healthy," surfaced indocker psand usable by orchestrators.
In Docker Desktop: after you build, the Images tab lists your tagged image; click it to see its layers with per-layer sizes — a visual version of
docker historythat makes "each instruction is a layer" concrete. A container's(healthy)state from aHEALTHCHECKalso shows in the Containers tab status column.
Command reference
| Command / Instruction | What it does | Example |
|---|---|---|
docker build -t <tag> <context> |
Builds an image from a Dockerfile in <context>, tagging it |
docker build -t lab:v1 . |
docker build --no-cache -t <tag> . |
Rebuilds ignoring all layer cache | docker build --no-cache -t lab:v1 . |
docker build --build-arg K=V -t <tag> . |
Passes a build-time ARG value |
docker build --build-arg APP_VERSION=1.2.3 -t lab:arg . |
FROM <image> |
Sets the base image | FROM python:3.12-slim |
WORKDIR <path> |
Sets/creates the working directory | WORKDIR /code |
COPY <src> <dest> |
Copies files from build context into the image | COPY requirements.txt . |
ADD <src> <dest> |
Like COPY, plus auto-extracts archives / fetches URLs |
ADD app.tar.gz /code/ |
RUN <cmd> |
Executes a command at build time, commits result as a layer | RUN pip install --no-cache-dir -r requirements.txt |
ENV <K>=<V> |
Sets an environment variable baked into the image | ENV PYTHONUNBUFFERED=1 |
ARG <name>[=<default>] |
Declares a build-time-only variable | ARG APP_VERSION=dev |
EXPOSE <port> |
Documents the port the app listens on (no publishing) | EXPOSE 8000 |
USER <name> |
Sets the user for later RUN steps and the runtime process |
USER appuser |
CMD ["exe", "arg", ...] |
Default startup command, overridable by docker run args |
CMD ["python", "app.py"] |
ENTRYPOINT ["exe", "arg", ...] |
Fixed startup command; docker run args are appended |
ENTRYPOINT ["python"] |
HEALTHCHECK --interval=<t> CMD <cmd> |
Periodic in-container check for health status | HEALTHCHECK --interval=30s CMD python -c "..." || exit 1 |
.dockerignore (file, not instruction) |
Excludes paths from the build context upload | .git / __pycache__/ / *.pyc |
Flag breakdown for the multi-flag build commands:
docker build -t lab:v1 .:-t lab:v1tags the built imagelabwith tagv1..is the build context — the current directory, uploaded to the daemon (minus.dockerignoreentries).
docker build --no-cache -t lab:v1 .:--no-cacheignores every cached layer and rebuilds all of them — useful to prove a clean build or bust a stale cache.
docker build --build-arg APP_VERSION=1.2.3 -t lab:arg .:--build-arg APP_VERSION=1.2.3supplies a value for anARGdeclared in the Dockerfile; it exists only during the build unless copied into anENV.
Hands-on exercises
You'll create a small, self-contained lab app once, then modify its Dockerfile throughout. Nothing is downloaded.
(WSL2 Ubuntu terminal) Create the lab app and build it, twice, to see caching:
mkdir -p ~/learn-docker/dockerfile-lab && cd ~/learn-docker/dockerfile-lab cat > app.py <<'EOF' from flask import Flask app = Flask(__name__) @app.get("/health") def health(): return {"status": "ok"} if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) EOF cat > requirements.txt <<'EOF' flask==3.0.3 EOF cat > Dockerfile <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 CMD ["python", "app.py"] EOF cat > .dockerignore <<'EOF' __pycache__/ *.pyc .git EOF time docker build -t lab:v1 . time docker build -t lab:v1 .Expect the second build to be dramatically faster and show
CACHEDnext to most steps — nothing changed, so every layer's cache is reused.(WSL2 Ubuntu terminal) Prove instruction order matters. Add a harmless comment line to
app.py, then rebuild:echo "# harmless change" >> app.py docker build -t lab:v2 .Expect
COPY requirements.txt .andRUN pip install ...to stayCACHED, butCOPY app.py .and everything after it to re-run — because the Dockerfile copiesrequirements.txt(rarely changes) beforeapp.py(changes constantly), only layers from the changed point onward are invalidated.(WSL2 Ubuntu terminal) Now prove the opposite ordering hurts. Make a scratch Dockerfile that copies the code before installing dependencies:
cat > Dockerfile.bad <<'EOF' FROM python:3.12-slim WORKDIR /code COPY app.py . COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt EXPOSE 8000 CMD ["python", "app.py"] EOF echo "# another change" >> app.py docker build -f Dockerfile.bad -t lab:bad .Flag note:
-f Dockerfile.badpicks a non-default Dockerfile name. Expect thepip installstep to now re-run just because you touched application code — confirming why dependency installation should always come before copying application code.(WSL2 Ubuntu terminal) Explore
.dockerignore. Create a large irrelevant file, confirm it's excluded, then compare:cat .dockerignore mkdir -p .venv && dd if=/dev/zero of=.venv/bigfile bs=1M count=20 echo ".venv/" >> .dockerignore docker build -t lab:v3 . 2>&1 | head -n 5Then temporarily comment out the
.venv/line, rebuild, and compare how much data is transferred to the daemon at the start of the build (with BuildKit, watch the "transferring context" size/time near the top) — expect it noticeably larger without the ignore rule.(WSL2 Ubuntu terminal) Use
ARGvsENVconcretely. Make a scratch Dockerfile that promotes a build arg into a runtime env var:cat > Dockerfile.arg <<'EOF' FROM python:3.12-slim ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . CMD ["python", "app.py"] EOF docker build -f Dockerfile.arg --build-arg APP_VERSION=1.2.3 -t lab:arg . docker run --rm lab:arg env | grep APP_VERSIONExpect
APP_VERSION=1.2.3. Now delete theENV APP_VERSION=...line, rebuild, and re-run theenv | grep— it disappears, provingARGalone is not visible at runtime.(WSL2 Ubuntu terminal) Confirm
EXPOSEdoesn't publish anything. Run without-p:docker run -d --name noport lab:v1 curl -m 3 http://localhost:8000/healthExpect the
curlto fail (connection refused/timeout) even though the Dockerfile hasEXPOSE 8000— it's metadata, not a publish rule. Clean up:docker rm -f noport.(Docker Desktop GUI, then CLI) Add a
HEALTHCHECKand watch the status turn healthy in the GUI. Build a scratch image with one:cat > Dockerfile.health <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . EXPOSE 8000 HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 CMD ["python", "app.py"] EOF docker build -f Dockerfile.health -t lab:health . docker run -d --name healthtest -p 8003:8000 lab:healthFlag breakdown for the
HEALTHCHECK:--interval=10sruns the check every 10 seconds;--timeout=3sfails a check that takes longer than 3 seconds;--retries=3waits for 3 consecutive failures before markingunhealthy. Now open Docker Desktop's Containers tab: after the first couple of intervals,healthtest's status shows(healthy). Cross-check from the CLI:docker ps --filter name=healthtestExpect the same
Up ... (healthy). Clean up:docker rm -f healthtest.(WSL2 Ubuntu terminal) Understand
CMDoverride vsENTRYPOINTappend. Build an image that fixes the program but lets you swap the port argument:cat > Dockerfile.entry <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . ENTRYPOINT ["flask", "--app", "app", "run", "--host", "0.0.0.0"] CMD ["--port", "8000"] EOF docker build -f Dockerfile.entry -t lab:entry . docker run -d --name e1 -p 8004:8000 lab:entry docker run -d --name e2 -p 8005:8080 lab:entry --port 8080Expect
e1to listen on 8000 (defaultCMDargs used) ande2on 8080 (yourdocker runargs replaced theCMDportion, but the fixedENTRYPOINTstayed). Confirm:curl -s http://localhost:8004/health curl -s http://localhost:8005/healthClean up:
docker rm -f e1 e2.Diagnose and fix: image won't build. Introduce a realistic typo. Make a scratch Dockerfile that copies
requirement.txt(missing thes), then build:cat > Dockerfile.typo <<'EOF' FROM python:3.12-slim WORKDIR /code COPY requirement.txt . RUN pip install --no-cache-dir -r requirement.txt COPY app.py . CMD ["python", "app.py"] EOF docker build -f Dockerfile.typo -t lab:broken .Expect a build failure like
"/requirement.txt": not found(exact wording varies by Docker version). Read the error, fix the tworequirement.txtreferences back torequirements.txt, and confirm the build succeeds.(WSL2 Ubuntu terminal) Clean up everything from this module:
docker rm -f $(docker ps -aq --filter ancestor=lab:v1) 2>/dev/null docker rmi lab:v1 lab:v2 lab:v3 lab:arg lab:health lab:entry lab:bad 2>/dev/null docker images | grep labExpect no
lab:*images remaining. You can also delete these from the Images tab in Docker Desktop and confirmdocker imagesagrees.
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Author a Dockerfile from scratch for a tiny app of your own (a two-file script plus a dependency manifest is plenty) that satisfies three constraints at once: editing the application code must not invalidate the dependency-install layer's cache; the container must have a fixed program it always runs, with a default argument that a docker run argument can override without replacing the whole command; and a value you pass only at build time must end up readable as an environment variable inside the running container. Then prove each of the three properties actually holds. Build on module 02: use the same "each instruction is a layer" inspection you did with docker history there to confirm which layers rebuilt and which stayed cached after a code-only change.
Stuck? One hint
Think about instruction ordering for the caching property, ENTRYPOINT paired with CMD for the fixed-program-with-overridable-argument property, and an ARG promoted into an ENV for the build-time-to-runtime property — then verify with a rebuild, a docker run with an extra argument, and docker run ... env.
Common mistakes & troubleshooting
- Copying the whole project before installing dependencies. Kills
layer caching for the dependency-install step on every code change —
always
COPYdependency manifests (requirements.txt,package.json) and install first, thenCOPYthe rest. - No
.dockerignore, so.git, virtual envs, ornode_modulesget sent as build context. Slows every build and can leak files you didn't intend to ship. - Using
ADDout of habit for plain file copies. Its implicit archive-extraction and URL-fetch can silently do more than you expect; useCOPYunless you specifically needADD. - Expecting an
ARGto be visible in the running container. It's build-time only unless explicitly re-assigned into anENV. - Assuming
EXPOSEopens a port to the host. It's documentation only — you still need-pondocker run(orports:in Compose). - Setting
HEALTHCHECKwith too-aggressive an interval for a slow-starting app, marking itunhealthybefore it finishes booting — add--start-periodto give the app time to come up. - Not pinning the base image tag (e.g.
FROM python:latest).latestmoves over time; a build that worked last month can produce a different, broken image today. Pin specific versions (python:3.12-slim).
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- Why should
COPY requirements.txt .and the dependency install step come beforeCOPY app.py .in a Dockerfile? - What's the difference between
ARGandENV, and what do you need to do to make a build-timeARGvalue visible inside the running container? - Does
EXPOSE 8000in a Dockerfile let you reach the container on port 8000 from your host without any other flags? Why or why not? - What's the practical difference between
CMDandENTRYPOINTwhen you pass extra arguments todocker run? - Why is
COPYgenerally preferred overADDfor simple file copies? - What does
.dockerignoredo, and why does a bloated build context slow down builds even if your Dockerfile only copies a few files? - What determines whether a Dockerfile layer is rebuilt or pulled from cache, and where can you see the resulting layers in Docker Desktop?
Show answers
- Docker caches layers in order and invalidates everything from the first changed layer onward; dependency manifests change far less often than application code, so installing dependencies first means code-only changes don't force a dependency reinstall.
ARGis only available during the build (insideRUNsteps or via--build-arg) and is not present in the final image's runtime environment by default;ENVis baked into the image and visible both to later build steps and to the running container. To expose anARGat runtime, assign it to anENV(e.g.ENV FOO=${FOO}).- No —
EXPOSEis metadata/documentation only. Reaching the container requires publishing the port at runtime with-p host:containerondocker run(orports:in Compose). - With
CMDalone, arguments passed todocker run <image> <args>replace the entire default command. WithENTRYPOINTset, those arguments are appended to the fixedENTRYPOINTinstead (and aCMDalongsideENTRYPOINTsupplies default arguments that get replaced when you pass your own). COPYdoes a plain, predictable file copy;ADDadditionally auto-extracts local tar archives and can fetch remote URLs — implicit behavior that can surprise you..dockerignoreexcludes matching paths from the build context uploaded to the daemon before the build starts; a large context (.git,node_modules, virtual envs) takes longer to transfer regardless of what the Dockerfile references.- Docker keys the cache on the instruction and its inputs (for
COPY/ADD, the copied files' content); if an earlier layer is invalidated, every later layer rebuilds too. In Docker Desktop, click the image in the Images tab to see its layers and sizes.
Cumulative review
Closed-book. Don't reopen earlier modules while attempting these — the point is to find out what actually stuck.
- You run
docker run hello-worldfrom your Ubuntu WSL2 terminal and it works, but a teammate reports the same command fails on their machine with only aClient:block and a daemon-connection error. Walk through where the engine actually runs on Windows and what single per-distro setting most likely differs between the two machines. - A container's main process shows as PID 1 inside the container, its image is only tens of MB on disk, and it shares your host's exact kernel version. Explain how all three facts trace back to the same underlying reason a container is not a VM.
- You build an image, run a container from it, write a file into that
container, then
docker rmthe container anddocker runa fresh one from the same image. The file is gone. Which layer held the file, why didn't it survive, and what would you have changed in the Dockerfile if you wanted that file present in every container from the start? - A Dockerfile copies
app.pybeforeCOPY requirements.txtand thepip installstep. Describe concretely what happens to build time on the tenth code-only edit, and connect your answer to whatdocker historywould show about the image's layers. - Explain why
EXPOSE 8000in a Dockerfile, a container showingUpindocker ps, and a failedcurl http://localhost:8000can all be true at the same time — and what you'd actually need to change to make the curl succeed. - You override a container's command with
docker run <image> python -c "print('hi')"and it exits immediately with status 0. Explain why, referencing both whatdocker rundoes to the image's defaultCMDand what "the container's life is tied to its PID 1" means. - Two images both start
FROM python:3.12-slim. You already have the first pulled; pulling the second is much faster than the first was. Explain the mechanism, and say where in Docker Desktop you could visually confirm the shared layers. - You want a build-time version string (
--build-arg APP_VERSION=1.4.0) to be visible as an environment variable inside the running container. A colleague used onlyARG APP_VERSIONand it isn't showing up at runtime. What's missing, and why doesARGalone behave that way? - Give two independent reasons a project directory living under
/mnt/c/...and lacking a.dockerignorewould makedocker buildslower than the same project in your Linux home directory with a tight.dockerignore— one reason rooted in the WSL2 filesystem boundary, one in the build context upload.
Show answers
- The engine (
dockerd) runs inside the lightweight Linux VM Docker Desktop manages via WSL2, not natively in Ubuntu; thedockerCLI in Ubuntu is a client pointed at that shared engine. The most likely difference is that the teammate hasn't enabled WSL Integration for their distro (Settings → Resources → WSL Integration), so their CLI has no engine to reach. - All three follow from a container being an ordinary host process with a restricted view rather than a separate machine: PID 1 is a PID-namespace illusion over a real host PID; the small image size is because it ships no kernel/boot infrastructure; and the identical kernel version is because there is only one kernel (the host's / the WSL2 VM's), shared by every container.
- The file lived in the container's thin writable layer, which is created
fresh per container and deleted with
docker rm— a newdocker runstarts a brand-new empty writable layer. To have it present in every container from the start, bake it into the image with aCOPY(or generate it in aRUN) so it becomes a read-only image layer. - Because the code is copied before dependencies are installed, every
code edit invalidates the
COPY app.pylayer and therefore every layer after it — includingpip install— so each of the ten edits re-runs a full dependency install.docker historywould show those later layers rebuilt (new sizes/timestamps) rather than reused. EXPOSEis only metadata/documentation;Uponly means PID 1 is running inside the container's own network namespace; andlocalhostfrom the host doesn't reach the container's namespace without a published port. Making the curl succeed requires publishing the port with-p 8000:8000(or a Composeports:entry) at run time.- Passing a command after the image name replaces the image's default
CMDentirely, so the long-running server never starts; thepython -cone-liner finishes instantly, and because a container exits when its PID 1 exits, the container is immediatelyExited (0). docker pullonly downloads layers not already present locally; the sharedpython:3.12-slimbase layers are reused, so only the second image's unique layers are fetched. Clicking an image in Docker Desktop's Images tab shows its layers, letting you see the common base.- An
ARGis build-time only and isn't part of the running container's environment; you must assign it into anENV(ENV APP_VERSION=${APP_VERSION}) for it to persist into image metadata and be visible at runtime. - First, paths under
/mnt/c/...cross the WSL2 Windows/Linux filesystem translation boundary, adding per-file overhead the native Linux home directory avoids. Second, with no.dockerignore, the entire directory (including things like.gitor virtual envs) is uploaded as build context to the daemon before the build starts, which is slower regardless of what the Dockerfile actually copies.
Further reading & sources
- Dockerfile reference - the complete, authoritative reference for every instruction covered in this module.
- Docker: Building best practices - official guidance on instruction ordering, cache efficiency, and small images.
- Docker: Optimize cache usage in builds - deep dive on how the layer cache is keyed and invalidated.
- Docker: .dockerignore file - how to trim the build context, the file this module has you author.
- Docker: CMD vs ENTRYPOINT interaction - the exact table describing how run arguments combine with the two instructions.
Next
Continue to 04-volumes-and-bind-mounts to learn how to persist data beyond a container's writable layer.