Skip to content

Dockerfile essentials

  • Lesson 4
  • 50 min
  • Module 2: Build the application image
Lesson 4 of 15 · Docker and Cloud Essentials for AI DevelopersDockerfile essentials

What you will be able to do

Until now, you ran images that other people made. In this lesson, you write the recipe for your own image, a Dockerfile. Then you build an image of the ticket API that runs the same way on every computer.

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.

A Dockerfile is a text file with the steps to make an image. Each line starts with an instruction, such as FROM or COPY. Docker runs the lines from top to bottom, and the result is an image.

Start with the smallest useful example. In a new, empty folder, ~/projects/hello-image, make two files:

hello.py
import platform
print("Hello from", platform.system(), "with Python", platform.python_version())
Dockerfile
FROM python:3.14-slim
WORKDIR /app
COPY hello.py .
CMD ["python", "hello.py"]

Build the image and give it the name hello-image. The . at the end means “this folder”:

Terminal, in ~/projects/hello-image
docker build -t hello-image .

The output (shortened):

[+] Building 0.4s (8/8) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
...
=> [1/3] FROM docker.io/library/python:3.14-slim@sha256:f85c5697265c178c 0.0s
...
=> CACHED [2/3] WORKDIR /app 0.0s
=> [3/3] COPY hello.py . 0.1s
...
=> => naming to docker.io/library/hello-image:latest 0.0s

Then run it:

Terminal
docker run --rm hello-image

The output:

Hello from Linux with Python 3.14.8

On the Mac that we tested this lesson on, python3 hello.py prints Hello from Darwin with Python 3.14.6. In the container, the same file runs on Linux, with the Python of the image. This is the point of an image: it brings its own operating system files and its own Python.

The three steps of the build are the three lines after FROM, and the build output numbers them [1/3] to [3/3]. CACHED means that Docker used a result from an earlier build on this computer instead of running the step again. On your computer, the line can say CACHED or not. The next lesson explains the cache.

These six instructions are enough for the ticket API:

Instruction What it does Example
FROM Starts from a base image: an image that already has an operating system and, here, Python. Every Dockerfile starts with FROM. FROM python:3.14-slim
WORKDIR Makes a folder in the image and goes into it, like mkdir and cd. Later paths are relative to it. WORKDIR /app
COPY Copies files from your project folder into the image. COPY hello.py .
RUN Runs a command while the image is built, for example to install packages. Its result is saved in the image. RUN pip install -r requirements-run.txt
EXPOSE Writes down the port that the app listens on. It is only documentation: it does not open the port. Module 3 explains this. EXPOSE 8000
CMD The command that runs when a container starts. It is saved in the image, but it does not run during the build. CMD ["python", "hello.py"]

The difference between RUN and CMD is the most important one. RUN happens once, on your computer, during docker build. CMD happens every time that somebody starts a container from the image, on any computer.

The image needs an exact list of the packages that the API needs to run. You already have requirements-lock.txt from the API course, but it is not the right list:

  • You made it on your computer, with your operating system. The image is Linux. A list made on Windows does not have uvloop, which fastapi[standard] installs only on Linux and macOS. A list made on a Mac can also differ.
  • It has the test tools (pytest, httpx2). The image only runs the API, so it does not need them.

So make the list where the image runs: in a Linux container, with the same Python. Run this command in the project folder. It takes about 15 seconds:

Terminal, in ~/projects/ticket-api
docker run --rm python:3.14.8-slim-trixie sh -c 'pip install -q --root-user-action=ignore --disable-pip-version-check "fastapi[standard-no-fastapi-cloud-cli]==0.142.2" && pip freeze' > requirements-run.txt

Piece by piece:

Piece Meaning
docker run --rm python:3.14.8-slim-trixie Starts a container from the Python image. --rm removes the container when it stops.
sh -c '...' Runs the text in quotes with the shell of the container: two commands, joined with &&.
pip install -q ... "fastapi[standard-no-fastapi-cloud-cli]==0.142.2" Installs FastAPI with its standard extras, but without the FastAPI Cloud command, which this course does not use. -q and the two options make pip quiet, so only the list comes out.
pip freeze Prints every installed package with its exact version.
> requirements-run.txt Your own shell, on your computer, writes what the container printed into the file.
Your terminal starts a Linux container with docker run. The container runs pip install, then pip freeze, which prints 50 lines. The lines come back to your terminal on standard output, and the shell's > writes them to requirements-run.txt on your computer. --rm removes the container.
The packages are installed in the container, but the file is written by your own shell, on your computer.
Text description of the diagram

The terminal sends docker run --rm python:3.14.8-slim-trixie sh -c '...' to a new Linux container. In the container, pip install installs FastAPI 0.142.2 with its extras, and pip freeze prints 50 lines. The lines go back to the terminal on standard output. The terminal’s shell, with >, writes them into requirements-run.txt in your project folder. Because of --rm, Docker removes the container when it stops. The container sees no folder of your computer, so the file belongs to you, not to the container’s user.

Check the file. On macOS and Linux:

Terminal
wc -l requirements-run.txt
grep -E "^fastapi|^uvloop" requirements-run.txt

The output:

50 requirements-run.txt
fastapi==0.142.2
fastapi-cli==0.0.32
uvloop==0.23.0

Fifty packages, each with one exact version, and uvloop is in the list.

Amira writes the first Dockerfile for ticket-api. Make the file Dockerfile (no extension) in the project folder:

Dockerfile
FROM python:3.14-slim
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"]
Line Why it is there
FROM python:3.14-slim Python 3.14 on a small Debian Linux. “slim” has only the files that Python needs. The next lesson pins an exact version.
WORKDIR /app The project lives in /app in the image.
COPY requirements-run.txt . First only the list of packages, so that pip install can run.
RUN pip install -r requirements-run.txt Installs the 50 exact packages into the image.
COPY pyproject.toml . fastapi run reads the entry point ticket_api.main:app from this file.
COPY ticket_api ./ticket_api The code of the API. The tests, the Postman files and .venv stay out: the image only runs the API.
EXPOSE 8000 Documents the port.
CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"] Starts the API in production mode. 0.0.0.0 and 8000 are the defaults of fastapi run. The line writes them out because Module 3 changes and explains them.

The packages come before the code on purpose. The next lesson shows how much time this order saves.

Each line of the Dockerfile and what it makes in the image ticket-api:1.0.0. FROM python:3.14-slim gives 4 base image layers with Debian and Python. WORKDIR /app makes a layer of 8.19 kB. COPY requirements-run.txt makes a layer of 12.3 kB. RUN pip install makes the largest layer, 50 packages, 117 MB. COPY pyproject.toml makes a layer of 12.3 kB. COPY ticket_api makes a layer of 102 kB. EXPOSE 8000 and CMD only make settings of 0 B.
Instructions that change files make a layer. EXPOSE and CMD only write settings. The sizes are from docker history.
Text description of the diagram

The left column has the eight lines of the Dockerfile. The right column has what each line makes in the image ticket-api:1.0.0. FROM python:3.14-slim brings the 4 layers of the base image: Debian and Python. WORKDIR /app makes a layer with the folder (8.19 kB). COPY requirements-run.txt . makes a layer with one file (12.3 kB). RUN pip install -r requirements-run.txt makes the largest layer: 50 packages, 117 MB. COPY pyproject.toml . makes a layer with one file (12.3 kB). COPY ticket_api ./ticket_api makes a layer with the code (102 kB). EXPOSE 8000 and CMD make no files. They only write settings into the image, with a size of 0 B.

An image name has the form name:tag. The tag, after the colon, chooses one version (Module 1). Build the image with the name ticket-api:1.0.0, where 1.0.0 is the version in pyproject.toml:

Terminal, in ~/projects/ticket-api
docker build -t ticket-api:1.0.0 .

The output, on a computer that already had the Python image:

[+] Building 13.4s (11/11) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 267B 0.0s
=> [internal] load metadata for docker.io/library/python:3.14-slim 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/6] FROM docker.io/library/python:3.14-slim@sha256:f85c5697265c178c 0.0s
=> => resolve docker.io/library/python:3.14-slim@sha256:f85c5697265c178c 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 47.05kB 0.0s
=> CACHED [2/6] WORKDIR /app 0.0s
=> [3/6] COPY requirements-run.txt . 0.0s
=> [4/6] RUN pip install -r requirements-run.txt 10.4s
=> [5/6] COPY pyproject.toml . 0.0s
=> [6/6] COPY ticket_api ./ticket_api 0.0s
=> exporting to image 2.7s
...
=> => naming to docker.io/library/ticket-api:1.0.0 0.0s

Read it from the top:

  • [1/6] to [6/6] are the six build steps. EXPOSE and CMD have no step: they only write settings.
  • pip install takes most of the 13.4 seconds. If Docker must download the Python image first, the build takes longer.
  • transferring context: 47.05kB: the files that docker build sent to Docker. The next lesson explains this build context.
  • naming to docker.io/library/ticket-api:1.0.0: the full name of the tag. docker.io/library/ is the default place, and you can leave it out.

Look at the new image:

Terminal
docker image ls ticket-api

The output:

IMAGE ID DISK USAGE CONTENT SIZE EXTRA
ticket-api:1.0.0 bbee51dd65d9 363MB 86.8MB

The next lesson makes this image smaller.

Run a container from the image. -p 127.0.0.1:8000:8000 connects port 8000 of your computer to port 8000 of the container, and -e API_KEY=local-dev-key gives the app its key when it starts. The key is not in the image.

Terminal
docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0

The output:

⚡️ Starting FastAPI in production mode
🐍 Using import string: ticket_api.main:app
🌐 Server started at http://0.0.0.0:8000
Documentation at http://0.0.0.0:8000/docs
Logs:
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

Started server process [1]: in the container, the API is process 1, the first and main process. Keep this terminal open. In a second terminal, call the API:

Terminal window
curl http://127.0.0.1:8000/health
curl -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d @requests/billing.json

The output:

{"status":"ok"}
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

The same answers as in the API course, but from Linux, with the packages of the image. The first terminal shows a log line for each request, for example:

INFO ticket_api: POST /v1/classify 200 2ms id=ab6cfe124415
INFO: 172.17.0.1:60676 - "POST /v1/classify HTTP/1.1" 200 OK

The client address is an address like 172.17.0.1, not 127.0.0.1: the request comes into the container through Docker’s network. Module 3 explains this. To stop the container, press Ctrl+C in the first terminal. --rm removes it.

CMD has two forms. The exec form is a list in square brackets: CMD ["fastapi", "run", ...]. Docker starts fastapi directly, so it is process 1. The shell form is plain text: CMD fastapi run --host 0.0.0.0 --port 8000. Docker then starts a shell, /bin/sh -c, as process 1, and the shell starts fastapi.

The difference shows when you stop the container. docker stop sends a stop signal to process 1, waits up to 10 seconds, and then kills the container. Amira tried both forms, with docker stop on a running container:

Form Process 1 (from docker top) docker stop took
Exec form /usr/local/bin/python3.14 /usr/local/bin/fastapi run --host 0.0.0.0 --port 8000 0.5 seconds
Shell form /bin/sh -c fastapi run --host 0.0.0.0 --port 8000 10.2 seconds

With the shell form, the shell gets the signal but does not pass it to fastapi. After 10 seconds, Docker kills both, and the app has no time to finish its requests. The build also warns you:

1 warning found (use docker --debug to expand):
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 8)

Always use the exec form for CMD.

An image name has two parts: the repository name (ticket-api) and the tag after the colon (1.0.0). If you leave out the tag, Docker uses latest:

Terminal
docker build -t ticket-api .
docker image ls ticket-api

The output:

IMAGE ID DISK USAGE CONTENT SIZE EXTRA
ticket-api:1.0.0 bbee51dd65d9 363MB 86.8MB
ticket-api:latest aa20ce395345 363MB 86.8MB

latest is only the default name. It does not mean “the newest”, and Docker does not update it for you. It moves to whatever you built last without a version. So ticket-api:latest on your computer and on Sam’s computer can be two different images. A version tag, such as 1.0.0, tells everyone exactly which code is in the image. Use version tags. Remove the latest tag now:

Terminal
docker image rm ticket-api:latest

The output:

Untagged: ticket-api:latest
  1. Open a terminal in ~/projects/ticket-api.

  2. Make requirements-run.txt with the docker run command in Make requirements-run.txt in a container.

  3. Check the file: it has 50 lines, and it has uvloop==0.23.0.

  4. Make the Dockerfile as in Write the Dockerfile.

  5. Build the image with docker build -t ticket-api:1.0.0 .. The output ends with naming to docker.io/library/ticket-api:1.0.0.

  6. Run docker image ls ticket-api. The tag ticket-api:1.0.0 is in the list.

  7. Start a container with docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0. The log says Uvicorn running on http://0.0.0.0:8000.

  8. In a second terminal, call /health and /v1/classify with curl. The answers are {"status":"ok"} and the billing classification.

  9. Call /v1/classify without the X-API-Key header. The status is 401.

  10. Press Ctrl+C in the first terminal to stop the container.

  11. In README.md, replace the section ## Deployed practice service with this section, so that the next person knows how to run the image:

    README.md
    ## Run it in a container
    You need Docker. The API listens on port 8000 in the container.
    ```sh
    docker build -t ticket-api:1.0.0 .
    docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0
    ```
    Check it: `curl http://127.0.0.1:8000/health` gives `{"status":"ok"}`.
Show the expected result of steps 8 and 9
{"status":"ok"}
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

Step 9, with curl -s -o /dev/null -w "%{http_code}\n" in front of the URL to print only the status: 401. The container’s log shows INFO ticket_api: POST /v1/classify 401 1ms id=…. The image has the same protection as the API had in the API course.

Start the image again, but add -e SHOW_DOCS=false. Before you open http://127.0.0.1:8000/docs, predict: do you need to build the image again? What status does /docs give?

Show the answer

You do not build again. SHOW_DOCS is read when the app starts, so the same image works with both values:

Terminal window
docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key -e SHOW_DOCS=false ticket-api:1.0.0

/docs gives 404. Without -e SHOW_DOCS=false, it gives 200. /health gives {"status":"ok"} in both cases. Modules 3 to 5 use this idea: one image, with different settings at start, runs on your computer and in the cloud.

Common mistake

The container stops at once: Could not find a default file to run

Symptom: the image builds, but the container stops a moment after it starts:

⚡️ Starting FastAPI in production mode
Could not find a default file to run, please provide an explicit path

The exit code is 1.

Cause: pyproject.toml is not in the image. fastapi run reads the entry point ticket_api.main:app from it. Without the file, it looks for a file such as main.py in /app, and finds none. Here, the line COPY pyproject.toml . was missing from the Dockerfile.

Diagnose: list the files in the image. A command after the image name replaces CMD:

Terminal window
docker run --rm ticket-api:1.0.0 ls /app
requirements-run.txt
ticket_api

pyproject.toml is not there.

Fix: add COPY pyproject.toml . before COPY ticket_api ./ticket_api, and build again. ls /app then shows pyproject.toml, requirements-run.txt and ticket_api.

For a real AI service: an image has only what the Dockerfile copies into it. If the app reads a model file, a settings file or a prompt template at start, the Dockerfile must copy it too. If not, the app works on your computer but fails in the image. When a container stops at once, run ls in the image before you change the code.

Check your understanding

What is the difference between RUN and CMD? Give the example of each from the ticket-api Dockerfile.

Show the answer

RUN runs a command once, during docker build, and saves its result in the image: RUN pip install -r requirements-run.txt installs the packages into the image. CMD is saved in the image and runs every time a container starts: CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"] starts the API. RUN changes the image; CMD does not run during the build.

Check your understanding

Why is requirements-run.txt made in a Linux container, and not with pip freeze in your own virtual environment?

Show the answer

The image runs Linux with Python 3.14.8. The packages that pip installs can depend on the operating system: on Windows, fastapi[standard] does not install uvloop, so a list from Windows misses it. A list from your virtual environment also has the test tools. A list made in a container from the same Python image has exactly the 50 packages that the image needs. Their versions work there.

Check your understanding

Sam pulls ticket-api:latest on Monday and again on Friday. Is it the same image both times?

Show the answer

Not for sure. latest is only the default tag. It points to the image that was last built or pushed without a version, so it can change between Monday and Friday without any warning. A version tag such as ticket-api:1.0.0 names one exact release. To be sure of the exact content, use the digest, which the next lesson explains.

Recap

  • A Dockerfile is the recipe for an image. FROM, WORKDIR, COPY and RUN make the image’s files. EXPOSE and CMD write settings.
  • requirements-run.txt lists the 50 exact packages, made in a Linux container so that the list fits the image.
  • docker build -t ticket-api:1.0.0 . builds the image. docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=... ticket-api:1.0.0 runs it, with the key given at start.
  • Use the exec form of CMD, so the app is process 1 and stops cleanly.
  • Use version tags. latest is a default name, not a version.

Next, you make builds fast and the image smaller, and you pin every version.

Next: Build efficiently