Dockerfile essentials
- Lesson 4
- 50 min
- Module 2: Build the application image
Lesson 4 of 15 · Docker and Cloud Essentials for AI DevelopersDockerfile essentials
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
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 ready
What you need to know
- Images, containers,
docker run,docker image lsanddocker history, from Images and running containers: Get an image, A first container and What is in an image. - Production mode with
fastapi run, and the entry point inpyproject.toml, from Hosting bridge: Worked example: describe the project for a platform. - The API key in the setting
API_KEY, from Basic protection: The API key. - Exact versions with
pip freeze, from Isolate dependencies: Record the versions.
What you need on your computer
- Docker, installed and checked in Install and verify: Check the installation.
docker run hello-worldworks. - The
ticket-apiproject at the end of the API course, in~/projects/ticket-api(Windows:$HOME\projects\ticket-api). It haspyproject.tomlfrom Hosting bridge: Worked example: describe the project for a platform. - A terminal in the project folder:
cd ~/projects/ticket-api. - If your project is missing or broken, copy the API course’s last snapshot,
end-of-m07. 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.
A first Dockerfile
Section titled “A first Dockerfile”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:
import platform
print("Hello from", platform.system(), "with Python", platform.python_version())FROM python:3.14-slimWORKDIR /appCOPY hello.py .CMD ["python", "hello.py"]Build the image and give it the name hello-image. The . at the end means “this folder”:
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.0sThen run it:
docker run --rm hello-imageThe output:
Hello from Linux with Python 3.14.8On 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.
The six instructions
Section titled “The six instructions”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.
Make requirements-run.txt in a container
Section titled “Make requirements-run.txt in a container”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, whichfastapi[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:
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.txtPiece 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. |
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:
wc -l requirements-run.txtgrep -E "^fastapi|^uvloop" requirements-run.txtThe output:
50 requirements-run.txtfastapi==0.142.2fastapi-cli==0.0.32uvloop==0.23.0Fifty packages, each with one exact version, and uvloop is in the list.
Write the Dockerfile
Section titled “Write the Dockerfile”Amira writes the first Dockerfile for ticket-api. Make the file Dockerfile (no extension) in the project folder:
FROM python:3.14-slimWORKDIR /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"]| 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.
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.
Build the image
Section titled “Build the image”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:
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.0sRead it from the top:
[1/6]to[6/6]are the six build steps.EXPOSEandCMDhave no step: they only write settings.pip installtakes most of the 13.4 seconds. If Docker must download the Python image first, the build takes longer.transferring context: 47.05kB: the files thatdocker buildsent 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:
docker image ls ticket-apiThe output:
IMAGE ID DISK USAGE CONTENT SIZE EXTRAticket-api:1.0.0 bbee51dd65d9 363MB 86.8MBThe next lesson makes this image smaller.
Run the image
Section titled “Run the image”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.
docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0The 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:
curl http://127.0.0.1:8000/healthcurl -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.jsoncurl.exe http://127.0.0.1:8000/healthcurl.exe -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"curl http://127.0.0.1:8000/healthcurl -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.jsonThe 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=ab6cfe124415INFO: 172.17.0.1:60676 - "POST /v1/classify HTTP/1.1" 200 OKThe 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.
Exec form and shell form
Section titled “Exec form and shell form”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.
Tags and why latest is not a version
Section titled “Tags and why latest is not a version”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:
docker build -t ticket-api .docker image ls ticket-apiThe output:
IMAGE ID DISK USAGE CONTENT SIZE EXTRAticket-api:1.0.0 bbee51dd65d9 363MB 86.8MBticket-api:latest aa20ce395345 363MB 86.8MBlatest 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:
docker image rm ticket-api:latestThe output:
Untagged: ticket-api:latestGuided practice: build and run ticket-api
Section titled “Guided practice: build and run ticket-api”-
Open a terminal in
~/projects/ticket-api. -
Make
requirements-run.txtwith thedocker runcommand in Make requirements-run.txt in a container. -
Check the file: it has 50 lines, and it has
uvloop==0.23.0. -
Make the
Dockerfileas in Write the Dockerfile. -
Build the image with
docker build -t ticket-api:1.0.0 .. The output ends withnaming to docker.io/library/ticket-api:1.0.0. -
Run
docker image ls ticket-api. The tagticket-api:1.0.0is in the list. -
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 saysUvicorn running on http://0.0.0.0:8000. -
In a second terminal, call
/healthand/v1/classifywithcurl. The answers are{"status":"ok"}and the billing classification. -
Call
/v1/classifywithout theX-API-Keyheader. The status is401. -
Press Ctrl+C in the first terminal to stop the container.
-
In
README.md, replace the section## Deployed practice servicewith this section, so that the next person knows how to run the image:README.md ## Run it in a containerYou need Docker. The API listens on port 8000 in the container.```shdocker 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.
Your turn: the same image, other settings
Section titled “Your turn: the same image, other settings”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:
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 pathThe 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:
docker run --rm ticket-api:1.0.0 ls /apprequirements-run.txtticket_apipyproject.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.
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
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.
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 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.
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
- A Dockerfile is the recipe for an image.
FROM,WORKDIR,COPYandRUNmake the image’s files.EXPOSEandCMDwrite settings. requirements-run.txtlists 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.0runs 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.
latestis a default name, not a version.
Next, you make builds fast and the image smaller, and you pin every version.
Next: Build efficiently