Skip to content

Build efficiently

  • Lesson 5
  • 50 min
  • Module 2: Build the application image
Lesson 5 of 15 · Docker and Cloud Essentials for AI DevelopersBuild efficiently

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 readyAbout 50 minutes

What you need to know

What you need on your computer

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.

Amira adds a comment to ticket_api/models.py, above the line Category = Literal[...]:

ticket_api/models.py
# 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?

Terminal, in ~/projects/ticket-api
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.

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:

  1. Is the instruction the same as last time?
  2. 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.0s

Every step is CACHED. The files are again the same as in an earlier build, and Docker still has that layer.

The same one-line change in ticket_api/models.py, in two Dockerfile orders. Good order, packages first: FROM is the same image, WORKDIR, COPY requirements-run.txt, RUN pip install and COPY pyproject.toml are CACHED, and only COPY ticket_api runs, in 0.0 s. The whole build takes 0.4 s. Poor order, all files first: FROM is the same image, WORKDIR is CACHED, then COPY . . runs in 0.9 s and RUN pip install runs in 11.4 s. The whole build takes 16.7 s. A dashed line marks the first step whose input changed.
One changed line of code. In the good order, the cache keeps pip install. In the poor order, the change comes before pip install, so every package is installed again.
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.

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?

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
docker build, good order: 1 of 6 steps run again
StepInstructionResultTime
[1/6]FROM python:3.14-slimsame image-
[2/6]WORKDIR /appCACHED-
[3/6]COPY requirements-run.txt .CACHED-
[4/6]RUN pip install -r requirements-run.txtCACHED-
[5/6]COPY pyproject.toml .CACHED-
[6/6]COPY ticket_api ./ticket_apiRUN0.0 s
Amira's Dockerfile, packages first. Pick the file that changed. The table shows each build step, CACHED or RUN, with the measured seconds.
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.

Many Dockerfiles that you find on the internet use a shorter order: copy the whole folder, then install.

A poor order (do not use)
FROM python:3.14-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements-run.txt
EXPOSE 8000
CMD ["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

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
docker build, poor order: 2 of 4 steps run again
StepInstructionResultTime
[1/4]FROM python:3.14-slimsame image-
[2/4]WORKDIR /appCACHED-
[3/4]COPY . .RUN0.9 s
[4/4]RUN pip install -r requirements-run.txtRUN11.4 s
The same changes, with the second control: the good order (packages first) or the poor order (COPY . . first).
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 . 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:

Terminal
docker run --rm ticket-api-poor ls -a /app

The output:

.
..
.env
.git
.gitignore
.pytest_cache
.venv
Dockerfile
README.md
browser-test
contract
postman
pyproject.toml
requests
requirements-lock.txt
requirements-run.txt
requirements.txt
tests
ticket_api

The 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:

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

The project folder ~/projects/ticket-api in two groups. Stays on your computer: .venv, .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. These never reach the builder. Sent to Docker: requirements-run.txt, pyproject.toml and ticket_api. They pass the .dockerignore allowlist and become the build context of 16.86 kB, the only files that COPY can use in docker build.
The allowlist sends three items to the builder. Everything else, including .env and .venv, cannot reach the image.
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.

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:

Dockerfile
FROM python:3.14-slim
FROM python:3.14.8-slim-trixie

Even 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:

Terminal
docker image inspect python:3.14.8-slim-trixie --format '{{index .RepoDigests 0}}'

The output:

python@sha256:f85c5697265c178cc6887276c55fe16cf3d14ca35c3df6a5eab3b360534a55d2

You 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 sets an environment variable in the image. The build and every container use it. Add these lines after FROM:

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

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”
  1. Make .dockerignore in the project folder, as in The build context and .dockerignore.

  2. In the Dockerfile, change the first line to FROM python:3.14.8-slim-trixie.

  3. Add the ENV lines after FROM, as in ENV lines and a smaller image. Your Dockerfile is now:

    Dockerfile
    FROM python:3.14.8-slim-trixie
    ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_ROOT_USER_ACTION=ignore
    WORKDIR /app
    COPY requirements-run.txt .
    RUN pip install -r requirements-run.txt
    COPY pyproject.toml .
    COPY ticket_api ./ticket_api
    EXPOSE 8000
    CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]
  4. Predict which steps run, then build with docker build -t ticket-api:1.0.0 ..

  5. Check the line transferring context. It is a few kilobytes.

  6. Run docker image ls ticket-api. The image is about 309MB.

  7. Run docker run --rm ticket-api:1.0.0 ls -a /app. It lists only pyproject.toml, requirements-run.txt and ticket_api.

  8. 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.1s

The 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 EXTRA
ticket-api:1.0.0 ee0aa1f19820 309MB 65.3MB

Step 7:

.
..
pyproject.toml
requirements-run.txt
ticket_api

Your 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.0s

ticket_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 found

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

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.

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.

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.
  • .dockerignore as an allowlist sends only three items to the build. .env, .venv and .git cannot 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 .dockerignore made 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