Build efficiently
- Lesson 5
- 50 min
- Module 2: Build the application image
Lesson 5 of 15 · Docker and Cloud Essentials for AI DevelopersBuild efficiently
1. Understand containers
2. Build the application image
3. Networking, configuration and persistence
4. Cloud building blocks
5. Deploy and clean up
What you will be able to do
Make every rebuild fast, send nothing secret or large to the build, and make the image the same every time you build it. A code change then rebuilds in under a second instead of 15, and the image is 54 MB smaller.
Before you startWhat to know and to have ready
What you need to know
- Layers and
docker history, from Images and running containers: What is in an image. - The six instructions and the build output, from Dockerfile essentials: The six instructions and Build the image.
What you need on your computer
- The
Dockerfile, from Dockerfile essentials: Write the Dockerfile. requirements-run.txt, from Dockerfile essentials: Make requirements-run.txt in a container.- The image
ticket-api:1.0.0, from Dockerfile essentials: Build the image. - If your project is missing or broken, copy the API course’s last snapshot,
end-of-m07, and do the guided practice of Dockerfile essentials. Use a snapshot has the steps.
Tested with Docker Desktop 4.81.0 (Docker Engine 29.6.1) on macOS, Apple silicon. The Windows and Linux steps are not tested. Build times depend on your computer and your network.
Change one file, build again
Section titled “Change one file, build again”Amira adds a comment to ticket_api/models.py, above the line Category = Literal[...]:
# The categories that the classifier can return.Category = Literal["billing", "login", "shipping", "account", "other"]Then she builds the image again, with the same command.
Predict: the first build took 13.4 seconds, and pip install took 10.4 of them. Which of the six steps will say CACHED now? How long will the build take?
docker build -t ticket-api:1.0.0 .The output (shortened):
[+] Building 0.6s (11/11) FINISHED docker:desktop-linux ... => [1/6] FROM docker.io/library/python:3.14-slim@sha256:f85c5697265c178c 0.0s ... => => transferring context: 3.77kB 0.0s => CACHED [2/6] WORKDIR /app 0.0s => CACHED [3/6] COPY requirements-run.txt . 0.0s => CACHED [4/6] RUN pip install -r requirements-run.txt 0.0s => CACHED [5/6] COPY pyproject.toml . 0.0s => [6/6] COPY ticket_api ./ticket_api 0.0s ...0.6 seconds. Steps 2 to 5 say CACHED. Only step 6, which copies the changed folder, ran again. Docker did not install a single package.
The layer cache
Section titled “The layer cache”Each step of a build makes one layer. Docker keeps the layers that it made, in its build cache. Before it runs a step, it checks two things:
- Is the instruction the same as last time?
- For
COPY: are the files the same, byte for byte? Docker compares a checksum of their content, not the date of the file.
If both are the same, and the step before it also came from the cache, Docker takes the old layer: CACHED. At the first step that is different, the cache stops helping. That step runs, and every step after it runs too, because each layer is built on top of the layer before it.
So the rule for the order of a Dockerfile is: what changes rarely goes first, what changes often goes last. The base image changes a few times a year. The list of packages changes when you add a package. The code changes every day. That is the order of Amira’s Dockerfile.
Now remove the comment from models.py and build again. Predict first: does step 6 run again?
The output (shortened):
[+] Building 0.2s (11/11) FINISHED docker:desktop-linux ... => CACHED [5/6] COPY pyproject.toml . 0.0s => CACHED [6/6] COPY ticket_api ./ticket_api 0.0sEvery step is CACHED. The files are again the same as in an earlier build, and Docker still has that layer.
Text description of the diagram
Two columns show the same change, one line in ticket_api/models.py, built with two orders of the Dockerfile.
The good order has six steps. [1/6] FROM python:3.14-slim uses the same image. [2/6] WORKDIR /app, [3/6] COPY requirements-run.txt ., [4/6] RUN pip install and [5/6] COPY pyproject.toml . are CACHED. A dashed line comes before [6/6] COPY ticket_api ./ticket_api, the first step whose input changed. It runs in 0.0 s. The whole build takes 0.4 s.
The poor order has four steps. [1/4] FROM uses the same image, and [2/4] WORKDIR /app is CACHED. The dashed line comes before [3/4] COPY . ., which copies every file, including the changed one. It runs in 0.9 s. [4/4] RUN pip install comes after it, so it runs too, in 11.4 s. The whole build takes 16.7 s.
Explore: which steps run again?
Section titled “Explore: which steps run again?”Every number in the explorer is a real measurement on the test computer of this lesson: the median of three builds for each change. Your times will be different, but the CACHED and RUN pattern is the same on every computer.
Explore
Layer cache: which steps run again after a change?
Predict first: You change one file and build again. Which change makes pip install run again: models.py, requirements-run.txt, pyproject.toml, README.md or the base image?
- Steps that run again
- 1 of 6
- pip install
- CACHED
- Whole build
- 0.4 s
Change: one line added to ticket_api/models.py. In the good order, 1 of 6 steps run again and the build takes 0.4 s. [6/6] COPY ticket_api ./ticket_api is the first step whose input changed. It runs again, and so does every step after it. pip install comes from the cache.
- CACHED: taken from the cache
- RUN: runs again
| Step | Instruction | Result | Time |
|---|---|---|---|
| [1/6] | FROM python:3.14-slim | same image | - |
| [2/6] | WORKDIR /app | CACHED | - |
| [3/6] | COPY requirements-run.txt . | CACHED | - |
| [4/6] | RUN pip install -r requirements-run.txt | CACHED | - |
| [5/6] | COPY pyproject.toml . | CACHED | - |
| [6/6] | COPY ticket_api ./ticket_api | RUN | 0.0 s |
Text description of the explorer
The explorer shows the six build steps of Amira’s Dockerfile after one change, with the result of each step and its measured time. The starting state is a one-line change in ticket_api/models.py: only [6/6] COPY ticket_api ./ticket_api runs, and the build takes 0.4 s.
The other changes: a line in requirements-run.txt makes steps 3 to 6 run, with pip install at 12.8 s of a 15.8 s build. A line in pyproject.toml makes steps 5 and 6 run, in 0.4 s. A line in README.md changes nothing for Docker, because no step copies README.md: every step after FROM is CACHED, in 0.3 s. A new base image (FROM python:3.13-slim) makes every step run, with pip install at 13.5 s of a 16.3 s build.
What if the order is poor?
Section titled “What if the order is poor?”Many Dockerfiles that you find on the internet use a shorter order: copy the whole folder, then install.
FROM python:3.14-slimWORKDIR /appCOPY . .RUN pip install -r requirements-run.txtEXPOSE 8000CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]It works, and it is two lines shorter. Amira tried it in a copy of the project, to measure it. Switch the explorer to the poor order, and try each file again.
Explore
Layer cache: the good order and the poor order
Predict first: In the poor order, COPY . . comes before pip install. Which of the five changes still let pip install come from the cache?
- Steps that run again
- 2 of 4
- pip install
- runs, 11.4 s
- Whole build
- 16.7 s
Change: one line added to ticket_api/models.py. In the poor order, 2 of 4 steps run again and the build takes 16.7 s. [3/4] COPY . . is the first step whose input changed. It runs again, and so does every step after it. pip install runs again: 11.4 s of the 16.7 s.
- CACHED: taken from the cache
- RUN: runs again
| Step | Instruction | Result | Time |
|---|---|---|---|
| [1/4] | FROM python:3.14-slim | same image | - |
| [2/4] | WORKDIR /app | CACHED | - |
| [3/4] | COPY . . | RUN | 0.9 s |
| [4/4] | RUN pip install -r requirements-run.txt | RUN | 11.4 s |
Text description of the explorer
The explorer starts in the poor order, with the one-line change in ticket_api/models.py. [3/4] COPY . . runs (0.9 s), and so does [4/4] RUN pip install (11.4 s). The build takes 16.7 s, against 0.4 s in the good order.
In the poor order, every change runs pip install again. The whole build takes 16.7 s for models.py and 16.0 s for requirements-run.txt. It takes 20.0 s for pyproject.toml, 21.2 s for README.md and 29.0 s for the base image. COPY . . copies every file in the folder. So even a change in README.md, which the app does not use, installs all 50 packages again. In the good order, only a change in requirements-run.txt or in the base image runs pip install.
The poor order costs about 16 seconds for each code change. A team that builds 50 times a day waits more than 13 minutes every day. Each build also downloads the same packages again.
The build context and .dockerignore
Section titled “The build context and .dockerignore”The . at the end of docker build -t ticket-api:1.0.0 . is the build context: the folder whose files Docker can use in COPY. Before the build, the docker command sends the files of the context to the builder, the part of Docker that runs the steps.
Look at the line transferring context in the build output. Amira measured it in new copies of the project, with a .venv, a .git folder and a .env file with a key, as most projects have:
| Dockerfile | .dockerignore |
transferring context |
|---|---|---|
Poor order, COPY . . |
none | 84.87MB (4.1 s) |
Good order, three COPY lines |
none | 47.05kB |
| Either order | the allowlist below | 16.86kB |
The builder asks only for the files that the COPY lines need. With COPY . ., that is every file: the virtual environment, the Git history and .env. All of it then goes into the image. Amira checked the image of the poor order:
docker run --rm ticket-api-poor ls -a /appThe output:
....env.git.gitignore.pytest_cache.venvDockerfileREADME.mdbrowser-testcontractpostmanpyproject.tomlrequestsrequirements-lock.txtrequirements-run.txtrequirements.txtteststicket_apiThe key in .env is now in the image. Anyone who gets the image can read it. That image was also 492MB, against 363MB for the good order.
The good order sent 47.05kB, although Amira’s Dockerfile has no COPY . . line. The folder ticket_api has __pycache__ folders from the tests in the API course, and they went into the image. And one day, somebody can add COPY . . to the Dockerfile.
A .dockerignore file stops this. It lists the files that the build must never see. Make it in the project folder:
# Send only what the image needs. Everything else stays out of the build,# including .env, secrets/, .venv and .git.*!requirements-run.txt!pyproject.toml!ticket_api/**/__pycache__/This is an allowlist. The line * ignores everything. Each line with ! allows one thing again. The last line takes the __pycache__ folders out again. The other way is a denylist (.env, .venv, .git, …). You must change it for every new secret file, and nobody remembers that. An allowlist cannot forget: a new file stays out until you allow it.
Text description of the diagram
On the left is the project folder in two groups. The group “Stays on your computer” has .venv/ (most of the 84.87 MB), .git/, .env with the API key, .pytest_cache/, tests/, postman/, requests/, contract/, browser-test/, README.md, requirements.txt, requirements-lock.txt and the __pycache__/ folders. A dashed line says that they never reach the builder, so they cannot be copied into the image. The group “Sent to Docker” has requirements-run.txt, pyproject.toml and ticket_api/. An arrow takes them through .dockerignore, with its rules *, !requirements-run.txt, !pyproject.toml, !ticket_api/ and **/__pycache__/: ignore everything, then allow three things. They become the build context, 16.86 kB, and docker build can copy only these files.
Pin the versions
Section titled “Pin the versions”Pinning means choosing one exact version, so that a build next month gives the same image as a build today. requirements-run.txt already pins every package. The base image is not pinned yet: python:3.14-slim is a tag that moves. Today it is Python 3.14.8 on Debian 13. After the next Python release, the same tag gives another image.
The Python image has more exact tags. python:3.14.8-slim-trixie names the Python version (3.14.8), the variant (slim) and the Debian release (13, called trixie). Use it in the Dockerfile:
FROM python:3.14-slimFROM python:3.14.8-slim-trixieEven an exact tag can change. The people who make the image can publish a new build under the same tag, for example with a security fix. The strictest pin is the digest: a checksum of the image content. Show it:
docker image inspect python:3.14.8-slim-trixie --format '{{index .RepoDigests 0}}'The output:
python@sha256:f85c5697265c178cc6887276c55fe16cf3d14ca35c3df6a5eab3b360534a55d2You can write FROM python:3.14.8-slim-trixie@sha256:f85c… with the full digest. Then the build always uses these exact bytes. The cost: the image never gets a security fix until you change the digest yourself. This course pins the exact tag, and you check for a newer image when you work on the project. A team with a tool that updates digests automatically can pin the digest.
Today, python:3.14-slim and python:3.14.8-slim-trixie have the same digest: the build output above showed sha256:f85c5697… for both. The tag is only a name; the digest is the content.
ENV lines and a smaller image
Section titled “ENV lines and a smaller image”ENV sets an environment variable in the image. The build and every container use it. Add these lines after FROM:
ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_ROOT_USER_ACTION=ignore| Variable | What it does | Why |
|---|---|---|
PYTHONDONTWRITEBYTECODE=1 |
Python does not write .pyc files. |
The container does not need them, and in the next lesson the app may not write to /app. |
PYTHONUNBUFFERED=1 |
Python writes each log line at once. | docker logs shows a line when it happens, not later. |
PIP_NO_CACHE_DIR=1 |
pip does not keep a copy of each download. | The copy would stay in the layer, and nobody would use it. |
PIP_DISABLE_PIP_VERSION_CHECK=1 |
pip does not check for a newer pip. | A faster build, and no message about it. |
PIP_ROOT_USER_ACTION=ignore |
pip does not warn that it runs as root. | The build ran as root and printed WARNING: Running pip as the 'root' user can result in broken permissions…. In an image that is normal. |
The \ at the end of a line continues the instruction on the next line. Together, the pinned tag, the ENV lines and .dockerignore change the image:
| First image (lesson 1) | Now | |
|---|---|---|
docker image ls: disk usage / content size |
363MB / 86.8MB | 309MB / 65.3MB |
RUN pip install layer (docker history) |
117MB | 84.1MB |
COPY ticket_api layer |
102kB | 49.2kB |
The pip layer is 33 MB smaller, because it has no pip cache. The code layer is half the size, because it has no __pycache__. Content size is the size of the compressed layers that a push or a pull moves over the network. Disk usage is their size unpacked on your computer.
Small, trusted base images
Section titled “Small, trusted base images”The base image is most of your image, and you get all of its programs and all of its security problems. Amira compared three variants of the same Python 3.14.8 (linux/arm64, from docker image ls --tree):
| Base image | Disk usage | Content size | Has a C compiler (gcc) and curl |
|---|---|---|---|
python:3.14.8-trixie |
1.62GB | 405MB | Yes |
python:3.14.8-slim-trixie |
204MB | 43.8MB | No |
python:3.14.8-alpine |
85.9MB | 21.1MB | No |
The full image has build tools for packages that must be compiled. Amira’s packages do not need them, so slim is eight times smaller with nothing missing.
Alpine is smaller again, but it uses a different C library, musl, instead of glibc. pip can then use only wheels (ready-made packages) built for musl, with musllinux in the file name. Amira tried pip install -r requirements-run.txt on python:3.14.8-alpine: all eight packages with compiled code, such as pydantic_core and uvloop, had a musllinux wheel, and the install worked. A package without such a wheel must be compiled, and the Alpine image has no compiler. Check every package before you choose Alpine, and check again when you add one. The course uses slim.
Choose a base image that you can trust. python is a Docker Official Image: Docker reviews it, and it gets security updates. Its page on Docker Hub, hub.docker.com/_/python, lists every tag. An image from an unknown account can contain anything.
Guided practice: a fast, small, pinned build
Section titled “Guided practice: a fast, small, pinned build”-
Make
.dockerignorein the project folder, as in The build context and .dockerignore. -
In the
Dockerfile, change the first line toFROM python:3.14.8-slim-trixie. -
Add the
ENVlines afterFROM, as in ENV lines and a smaller image. Your Dockerfile is now:Dockerfile FROM python:3.14.8-slim-trixieENV PYTHONDONTWRITEBYTECODE=1 \PYTHONUNBUFFERED=1 \PIP_NO_CACHE_DIR=1 \PIP_DISABLE_PIP_VERSION_CHECK=1 \PIP_ROOT_USER_ACTION=ignoreWORKDIR /appCOPY requirements-run.txt .RUN pip install -r requirements-run.txtCOPY pyproject.toml .COPY ticket_api ./ticket_apiEXPOSE 8000CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"] -
Predict which steps run, then build with
docker build -t ticket-api:1.0.0 .. -
Check the line
transferring context. It is a few kilobytes. -
Run
docker image ls ticket-api. The image is about 309MB. -
Run
docker run --rm ticket-api:1.0.0 ls -a /app. It lists onlypyproject.toml,requirements-run.txtandticket_api. -
Start the container and call
/health, as in Dockerfile essentials: Run the image. It gives{"status":"ok"}.
Show the expected build output of step 4
Every step runs. The ENV lines come right after FROM, so every step after them has a new instruction before it. The step lines, from a builder with an empty cache:
=> [1/6] FROM docker.io/library/python:3.14.8-slim-trixie@sha256:f85c569 5.5s ... => [2/6] WORKDIR /app 0.2s => [3/6] COPY requirements-run.txt . 0.0s => [4/6] RUN pip install -r requirements-run.txt 19.7s => [5/6] COPY pyproject.toml . 0.1s => [6/6] COPY ticket_api ./ticket_api 0.1sThe ENV lines have no step of their own: like EXPOSE, they only write settings. Your times can be different. If this computer built the same steps before, some lines can say CACHED. Step 6:
IMAGE ID DISK USAGE CONTENT SIZE EXTRAticket-api:1.0.0 ee0aa1f19820 309MB 65.3MBStep 7:
...pyproject.tomlrequirements-run.txtticket_apiYour turn: predict a change in pyproject.toml
Section titled “Your turn: predict a change in pyproject.toml”Add a comment line at the end of pyproject.toml, for example # Built with Docker. Before you build, write down which steps will say CACHED and which will run. Then build, check, and remove the comment.
Show the answer
Steps 2 to 4 are CACHED, so pip install does not run. Step 5, COPY pyproject.toml ., runs because the file changed. Step 6 runs because it comes after step 5:
[+] Building 0.3s (11/11) FINISHED docker:desktop-linux => [1/6] FROM docker.io/library/python:3.14.8-slim-trixie@sha256:f85c569 0.0s => CACHED [2/6] WORKDIR /app 0.0s => CACHED [3/6] COPY requirements-run.txt . 0.0s => CACHED [4/6] RUN pip install -r requirements-run.txt 0.0s => [5/6] COPY pyproject.toml . 0.0s => [6/6] COPY ticket_api ./ticket_api 0.0sticket_api did not change, but its layer is built on top of the new layer of step 5, so it must be made again. After you remove the comment, the next build is CACHED everywhere.
Common mistake
COPY fails with not found, but the file is there
Symptom: after a change to .dockerignore, the build stops:
#9 [5/6] COPY pyproject.toml .#9 ERROR: failed to calculate checksum of ref 65tu4hq3l2veiuoltpznypryw::4xw03kvqm5pe2q3u3gfngbhsy: "/pyproject.toml": not found...ERROR: failed to build: failed to solve: failed to compute cache key: failed to calculate checksum of ref 65tu4hq3l2veiuoltpznypryw::4xw03kvqm5pe2q3u3gfngbhsy: "/pyproject.toml": not foundls shows that pyproject.toml is in the project folder.
Cause: the file is on your computer, but not in the build context. Here, the line !pyproject.toml was missing from the allowlist, so * ignored the file, and COPY cannot see it. The message says “not found” because, for the builder, the file does not exist.
Fix: add the file to the allowlist with a ! line, and build again. When you add a file that the image needs, add it to COPY and to .dockerignore.
For a real AI service: this error is the allowlist doing its job. If the allowlist misses a model file that the image needs, the build fails loudly. That is better than a secret that goes into the image without a sound. Prefer a clear build error to a quiet leak.
Check your understanding
Why does Amira's Dockerfile copy requirements-run.txt and run pip install before it copies ticket_api?
Show the answer
The code changes often and the package list changes rarely. Docker reuses cached steps only until the first step whose input changed; that step and every step after it run again. With the packages first, a code change runs only COPY ticket_api (0.4 s here), and pip install comes from the cache. With the code first, every code change installs all 50 packages again (about 16 s here).
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Sam's .dockerignore is a list of what to leave out: .venv, .git and .env. Next week, the team adds a file secrets/db_password.txt. What happens with Sam's file, and what happens with the allowlist?
Show the answer
With Sam’s denylist, secrets/db_password.txt is not listed, so it is in the build context. If the Dockerfile has COPY . ., the password goes into the image. With the allowlist, * ignores every file, including new ones. Only requirements-run.txt, pyproject.toml and ticket_api/ can reach the build, so the new file stays out without any change to .dockerignore.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
What is the difference between pinning python:3.14.8-slim-trixie and pinning its digest? Give one advantage of each.
Show the answer
The exact tag names a Python version, a variant and a Debian release. But its publishers can push a new build under the same tag, for example with a security fix. Advantage: you get those fixes when you build again. The digest is a checksum of the exact content, so the image can never change. Advantage: every build uses the same bytes. The cost of the digest is that you must update it yourself to get fixes.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- The layer cache reuses each step until the first step whose input changed. That step and every step after it run again.
- Put what changes rarely first: base image, package list,
pip install, then the code. A code change then rebuilds in under a second. .dockerignoreas an allowlist sends only three items to the build..env,.venvand.gitcannot reach the image.- Pin the base image to an exact tag (
python:3.14.8-slim-trixie) and the packages to exact versions. A digest is the strictest pin. - A slim Docker Official Image is small and trusted. The ENV lines and
.dockerignoremade the image 309MB instead of 363MB.
Next, you make the app run as a normal user. You also prove that a secret in a build layer stays in the image after you delete it.
Next: Runtime safety