Runtime safety
- Lesson 6
- 50 min
- Module 2: Build the application image
Lesson 6 of 15 · Docker and Cloud Essentials for AI DevelopersRuntime safety
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
Limit what an attacker can do if they take over the API, and make sure that the image never carries a secret. Both are cheap to do now, and expensive to fix after you publish the image.
Before you startWhat to know and to have ready
What you need to know
- The layer cache and the order of the Dockerfile, from Build efficiently: The layer cache.
.dockerignoreas an allowlist, from Build efficiently: The build context and .dockerignore.- Why a secret stays out of the code, from Configuration and secrets: Secrets.
docker execanddocker history, from Images and running containers: Worked example: a file in a container and What is in an image.
What you need on your computer
- The pinned
Dockerfilewith the ENV lines, from Build efficiently: Guided practice: a fast, small, pinned build. .dockerignore, from Build efficiently: The build context and .dockerignore.- The image
ticket-api:1.0.0built from them (309MB), from the same guided practice. - 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 and Build efficiently. 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.
Who runs the app?
Section titled “Who runs the app?”Start the API, with a name for the container, so that you can use it in other commands. -d runs the container in the background:
docker run -d --rm --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0Ask the container which user runs it:
docker exec ticket-api idThe output:
uid=0(root) gid=0(root) groups=0(root)root, user number 0, the user that can do everything. A Dockerfile without a USER line runs the app as root. Amira tries what root can do. She makes a new file next to the code, and then replaces the classifier with one line:
docker exec ticket-api touch /app/ticket_api/x.pydocker exec ticket-api sh -c 'echo "print(1)" > /app/ticket_api/classifier.py'Both commands work, with no message. The container now runs changed code. Stop it before it does anything else:
docker stop ticket-api--rm removes the container, and with it the changed file. The image did not change. But think about an attacker who finds a bug in the API and can run commands through it. As root, they can change the code of the running service, install programs and read every file in the container. Container isolation keeps them away from your computer. But root in the container is the same user number 0 as root on the host. A bug in the isolation can then give them root on the host.
The fix is simple: the app does not need to be root. It must only read its code and answer requests.
Run as a user that is not root
Section titled “Run as a user that is not root”Add two lines to the Dockerfile. RUN useradd makes a normal user called app in the image. USER app says that every later step, and every container, runs as that user:
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
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY requirements-run.txt .RUN pip install -r requirements-run.txt
COPY pyproject.toml .COPY ticket_api ./ticket_api
USER app
EXPOSE 8000CMD ["fastapi", "run", "--host", "0.0.0.0", "--port", "8000"]| Part | Why |
|---|---|
useradd ... app |
A normal user, with no special rights. |
--uid 10001 |
A fixed user number. Every build gives the same number, so a later file mount or a security rule can use it. A high number does not clash with the users of a normal Linux computer, which start at 1000. |
--create-home |
Makes /home/app. Some tools write their settings to the home folder. |
RUN useradd near the top |
The user changes almost never, so it goes before the package list, as the cache rule says. |
USER app after the COPY lines |
pip install and COPY still run as root, so the code and the packages belong to root. Only the app runs as app. |
This is the finished Dockerfile of this course. It does not change in later modules. Build it:
docker build -t ticket-api:1.0.0 .The step lines, from a builder with an empty cache (shortened):
=> [1/7] FROM docker.io/library/python:3.14.8-slim-trixie@sha256:f85c569 4.5s ... => [2/7] RUN useradd --create-home --uid 10001 app 0.4s => [3/7] WORKDIR /app 0.0s => [4/7] COPY requirements-run.txt . 0.0s => [5/7] RUN pip install -r requirements-run.txt 16.7s => [6/7] COPY pyproject.toml . 0.1s => [7/7] COPY ticket_api ./ticket_api 0.1sNow seven steps. The new step 2 comes before everything else, so every step runs again once. USER has no step: like ENV, it is a setting. The image is the same size as before, 309MB.
Check it
Section titled “Check it”Start the new image, with the same command as before:
docker run -d --rm --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0docker exec ticket-api idThe output (after the container ID):
uid=10001(app) gid=10001(app) groups=10001(app)Try the same changes as root did:
docker exec ticket-api touch /app/xdocker exec ticket-api sh -c 'echo "print(1)" > /app/ticket_api/classifier.py'The output:
touch: cannot touch '/app/x': Permission deniedsh: 1: cannot create /app/ticket_api/classifier.py: Permission deniedThe files belong to root, and other users can only read them:
docker exec ticket-api ls -l /appThe output:
total 12-rw-r--r-- 1 root root 296 Oct 7 16:49 pyproject.toml-rw-r--r-- 1 root root 1092 Oct 7 16:50 requirements-run.txtdrwxr-xr-x 2 root root 4096 Oct 7 17:03 ticket_apirw-r--r-- means: the owner (root) can read and write, everybody else can only read. That is all the app needs. /health still gives {"status":"ok"}. docker top ticket-api shows the process with the user number:
UID PID PPID C STIME TTY TIME CMD10001 20466 20442 25 17:10 ? 00:00:00 /usr/local/bin/python3.14 /usr/local/bin/fastapi run --host 0.0.0.0 --port 8000Text description of the diagram
The left panel is the container without a USER line. fastapi runs as root, uid 0. It can read the code in /app. It can also change the code in /app and write in /usr/local, where Python and the packages are. The diagram marks these two rows: an attacker who takes over the process can use them. The right panel is the container with USER app. fastapi runs as app, uid 10001. It can read the code in /app, but it cannot change it, and it cannot write in /usr/local.
Stop the container: docker stop ticket-api.
Keep credentials out of the image
Section titled “Keep credentials out of the image”An image is a file that you copy: to a registry, to Sam, to the cloud. Everyone who can pull it can read every layer. So a secret must never be part of a build. The ticket API already does it right: the key comes in when the container starts, with -e API_KEY=..., and Module 3 gives it as a file. The image has no key.
There are three common ways to put a secret into an image by mistake:
| Mistake | Where the secret stays |
|---|---|
COPY .env . or COPY . . with a .env in the folder |
In the layer of that COPY, as a file |
ENV API_KEY=... |
In the image settings. docker image inspect shows it to everyone. |
ARG API_KEY and --build-arg API_KEY=... |
In the build history of the RUN lines that use it |
Your allowlist already stops the first one. Amira tests it with a Dockerfile that tries to copy .env:
#7 [3/4] COPY .env .#7 ERROR: failed to calculate checksum of ref 65tu4hq3l2veiuoltpznypryw::0sv39oljj5ojc6ywju1h5qxh8: "/.env": not found.env is not in the build context, so COPY cannot find it. The common mistake below shows what happens in a project without the allowlist.
If a build itself needs a credential, for example a token to download a private package, use a build secret. RUN --mount=type=secret,id=api_key ... makes the file /run/secrets/api_key available to that one RUN step, and it is never saved in a layer. You give the file with docker build --secret id=api_key,src=api_key.txt .. Amira tested it: the step read 31 bytes from /run/secrets/api_key, and in a container from the image, ls /run/secrets says No such file or directory. The ticket API does not need a build secret.
Common mistake
The key is in the image, even after RUN rm
Do this in a new folder, ~/projects/leak-demo, with a fake key. Never use a real key for this test.
Symptom: none that you can see. A colleague wrote a Dockerfile that copies .env and then deletes it, “to be safe”:
FROM python:3.14.8-slim-trixieWORKDIR /appCOPY .env .RUN rm .envThe folder has a .env file with one line, API_KEY=demo-key-7731-not-real, and no .dockerignore. The build works:
docker build -t leak-demo:1 .docker run --rm leak-demo:1 ls -a /appThe output (the step lines, then ls):
=> [1/4] FROM docker.io/library/python:3.14.8-slim-trixie@sha256:f85c569 0.0s => CACHED [2/4] WORKDIR /app 0.0s => [3/4] COPY .env . 0.0s => [4/4] RUN rm .env 0.4s.../app is empty. The key looks gone.
Diagnose: look at the layers. docker history shows that the COPY .env . layer is still there:
docker history leak-demo:1IMAGE CREATED CREATED BY SIZE COMMENT54aae749bcd1 2 seconds ago RUN /bin/sh -c rm .env # buildkit 8.19kB buildkit.dockerfile.v0<missing> 2 seconds ago COPY .env . # buildkit 12.3kB buildkit.dockerfile.v0<missing> 19 minutes ago WORKDIR /app 8.19kB buildkit.dockerfile.v0<missing> 39 hours ago CMD ["python3"] 0B buildkit.dockerfile.v0...Layers are read-only. RUN rm .env cannot change the layer below it. It only adds a new layer with a marker, a whiteout file, that hides .env in a running container. Prove it: save the image as a file, unpack it, and find the layer with app/.env:
docker image save leak-demo:1 -o leak-demo.tarmkdir layers && tar -xf leak-demo.tar -C layersfor f in layers/blobs/sha256/*; do tar -tf "$f" 2>/dev/null | grep -qx app/.env && echo "$f"; donedocker image save leak-demo:1 -o leak-demo.tarmkdir layers; tar -xf leak-demo.tar -C layersGet-ChildItem layers/blobs/sha256 | ForEach-Object { if ((tar -tf $_.FullName 2>$null) -contains 'app/.env') { $_.FullName } }docker image save leak-demo:1 -o leak-demo.tarmkdir layers && tar -xf leak-demo.tar -C layersfor f in layers/blobs/sha256/*; do tar -tf "$f" 2>/dev/null | grep -qx app/.env && echo "$f"; doneThe output (the image file is 42 MB):
layers/blobs/sha256/ed3d3b3e5e58ff6e9574e76ff47278683ebdfd02bf7f54b38b1953fc56b04942Read the file from that layer. Use the name from your own output:
tar -xOf layers/blobs/sha256/ed3d3b3e5e58ff6e9574e76ff47278683ebdfd02bf7f54b38b1953fc56b04942 app/.envAPI_KEY=demo-key-7731-not-realThe key, complete. layers/manifest.json lists the image’s seven layers in order: four of the base image, then WORKDIR, then this one (layer 6). Layer 7 comes from RUN rm, and has only app/ and app/.wh..env, the whiteout.
Text description of the diagram
The image is a stack of layers with the newest on top. Layers 1 to 4 come from FROM python:3.14.8-slim-trixie. Layer 5 comes from WORKDIR /app and has the folder. Layer 6 comes from COPY .env . and has app/.env with the API key. The diagram highlights it. Layer 7 comes from RUN rm .env and has only a marker, .wh..env, that hides .env. A note at the bottom says: in a container, ls -a /app shows no .env, but in the saved image, layer 6 still has the file and the key.
ENV is even easier to read. With ENV API_KEY=demo-key-7731-not-real in a Dockerfile, docker image inspect --format '{{json .Config.Env}}' prints it with the other settings:
["PATH=/usr/local/bin:...","PYTHON_VERSION=3.14.8","PYTHON_SHA256=c221...","API_KEY=demo-key-7731-not-real"]Cause: every layer of an image stays in the image. A later step can hide a file, but it cannot remove it.
Fix:
- Treat the key as public. Make a new key and stop using the old one. Deleting the image is not enough: you do not know who copied it.
- Remove the image everywhere:
docker image rm leak-demo:1on your computer, and in every registry that you pushed it to. - Change the Dockerfile so that the secret never enters a build: no
COPYof secret files, noENVorARGwith a secret. Give the secret when the container starts. Use an allowlist.dockerignore.
For a real AI service: API keys for model providers are worth money to an attacker. And images go to registries that many people and systems can pull. An image must be safe to publish. Keep every key, token and password outside it, and put them in only when the container starts.
Clean up the test: docker image rm leak-demo:1, then delete the folder ~/projects/leak-demo with its .env, leak-demo.tar and layers.
Guided practice: a user that is not root
Section titled “Guided practice: a user that is not root”- Add
RUN useradd --create-home --uid 10001 appafter theENVlines of yourDockerfile. - Add
USER appafterCOPY ticket_api ./ticket_api. - Compare your file with the finished Dockerfile in Run as a user that is not root. It must be the same.
- Build with
docker build -t ticket-api:1.0.0 .. The build has seven steps. - Start the container with
docker run -d --rm --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0. - Run
docker exec ticket-api id. The output starts withuid=10001(app). - Run
docker exec ticket-api touch /app/x. The output isPermission denied. - Call
/health. It gives{"status":"ok"}. - Stop the container with
docker stop ticket-api.
Show the expected output of steps 6 and 7
uid=10001(app) gid=10001(app) groups=10001(app)touch: cannot touch '/app/x': Permission deniedIf step 6 says uid=0(root), the image has no USER app line, or you started an old image. Build again, and check that docker image inspect ticket-api:1.0.0 --format '{{.Config.User}}' prints app.
Your turn: where can the app write?
Section titled “Your turn: where can the app write?”The app cannot write in /app. Predict: can it write in /tmp? In /home/app? Check with docker exec ticket-api touch /tmp/x and docker exec ticket-api touch /home/app/x, then explain.
Show the answer
Both work. /tmp is open to every user (ls -ld /tmp shows drwxrwxrwt). /home/app belongs to app, because useradd --create-home made it for that user (drwx------ 2 app app). So the app can still write temporary files, but not its own code. Files in /tmp and /home/app are in the container’s writable layer: they disappear with the container. Module 3 shows where data that must survive belongs.
Module practice
Section titled “Module practice”The module practice: build the ticket API image and inspect its configuration, the settings that the image carries besides its files. First a small worked example.
Worked example: one setting
Section titled “Worked example: one setting”docker image inspect prints the image’s full description as JSON. --format picks one field with a template: {{.Config.User}} is the user that USER set.
docker image inspect ticket-api:1.0.0 --format '{{.Config.User}}'appYour task: the image’s configuration
Section titled “Your task: the image’s configuration”- Build the image from the finished Dockerfile, as
ticket-api:1.0.0, if you have not done it yet. - Print these fields with
docker image inspect ticket-api:1.0.0 --format '...':{{.Config.User}},{{json .Config.Cmd}},{{json .Config.ExposedPorts}},{{.Config.WorkingDir}}and{{json .Config.Env}}. - For each field, write one line: which Dockerfile line set it.
- Look at
Env. Is there a key or a password in it? Which variables come from the base image? - Run
docker history ticket-api:1.0.0. Name the layers of 0B, and say why they have no size.
Show the model answer
A single command for the first four fields:
docker image inspect ticket-api:1.0.0 --format 'User={{.Config.User}} Cmd={{json .Config.Cmd}} ExposedPorts={{json .Config.ExposedPorts}} WorkingDir={{.Config.WorkingDir}}'User=app Cmd=["fastapi","run","--host","0.0.0.0","--port","8000"] ExposedPorts={"8000/tcp":{}} WorkingDir=/appdocker image inspect ticket-api:1.0.0 --format '{{json .Config.Env}}'["PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","PYTHON_VERSION=3.14.8","PYTHON_SHA256=c2215904f02b175596dc49351585104f4bc20341e1c47378b26a2c274360ce73","PYTHONDONTWRITEBYTECODE=1","PYTHONUNBUFFERED=1","PIP_NO_CACHE_DIR=1","PIP_DISABLE_PIP_VERSION_CHECK=1","PIP_ROOT_USER_ACTION=ignore"]-
Usercomes fromUSER app;CmdfromCMD [...];ExposedPortsfromEXPOSE 8000;WorkingDirfromWORKDIR /app; the last five variables inEnvfrom theENVlines. -
No key and no password.
API_KEYis not there, because you give it only with-ewhen a container starts.PATH,PYTHON_VERSIONandPYTHON_SHA256come from the base image. -
docker history(top lines):CMD ["fastapi" "run" "--host" "0.0.0.0" "--p… 0BEXPOSE [8000/tcp] 0BUSER app 0BCOPY ticket_api ./ticket_api # buildkit 49.2kBCOPY pyproject.toml . # buildkit 12.3kBRUN /bin/sh -c pip install -r requirements-r… 84.1MBCOPY requirements-run.txt . # buildkit 12.3kBWORKDIR /app 8.19kBRUN /bin/sh -c useradd --create-home --uid 1… 69.6kBENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFER… 0BCMD,EXPOSE,USERandENVare 0B. They change only the configuration that you printed in step 2, not the files, so their layers are empty.
Module check
Section titled “Module check”Check your understanding
Retrieval: name the six instructions of the first ticket-api Dockerfile, and say for each one whether it makes files in a layer or only a setting.
Show the answer
Four instructions make files: FROM (the base image’s layers), WORKDIR (the folder), COPY (the copied files) and RUN (what the command changed, such as the installed packages). EXPOSE (the documented port) and CMD (the start command) only write settings, with layers of 0B. In the finished Dockerfile, ENV and USER are also settings only.
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
Predict: in the finished Dockerfile, you change requirements-run.txt to add one package. Which of the seven steps run again, and is the user app made again?
Show the answer
Steps 1 to 3 (FROM, RUN useradd, WORKDIR) come from the cache, so the user app is not made again. Step 4, COPY requirements-run.txt ., runs because the file changed. Steps 5 to 7 (pip install, COPY pyproject.toml, COPY ticket_api) run because they come after it. pip install is the slow one: about 13 seconds 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
Completion check: add a comment to ticket_api/models.py, build ticket-api:1.0.0 again, and explain which layers Docker reused and why. Then remove the comment.
Show the answer
The build output (shortened):
[+] Building 0.4s (12/12) FINISHED docker:desktop-linux ... => CACHED [2/7] RUN useradd --create-home --uid 10001 app 0.0s => CACHED [3/7] WORKDIR /app 0.0s => CACHED [4/7] COPY requirements-run.txt . 0.0s => CACHED [5/7] RUN pip install -r requirements-run.txt 0.0s => CACHED [6/7] COPY pyproject.toml . 0.0s => [7/7] COPY ticket_api ./ticket_api 0.0sDocker reused the layers of steps 2 to 6: their instructions and their input files did not change, so their cached layers are still correct. The base image of step 1 is the same image. Only step 7 ran, because ticket_api/models.py, a file it copies, changed. It is the last step, so nothing comes after it. The settings (USER, EXPOSE, CMD) are written again, which takes no time. The build took 0.4 seconds instead of about 16.
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
In this module, you built the application image:
- A Dockerfile with
FROM,WORKDIR,COPY,RUN,EXPOSEandCMDin exec form, andrequirements-run.txtmade in a Linux container. - A fast build: packages before code, so a code change rebuilds in under a second. An allowlist
.dockerignorethat sends three items to the build. - A pinned, small, trusted base image,
python:3.14.8-slim-trixie, and ENV lines that keep the image at 309MB. - A safe image: the app runs as
app(uid 10001) and cannot change its own code, and no secret is in any layer. A file that a later step deletes is still in the image.
Reset. To start Module 3 from a clean state:
- List your containers with
docker ps -a. Stop and remove anyticket-apicontainer:docker rm -f ticket-api. - Remove the test images:
docker image rm hello-image leak-demo:1. If you removed them already, Docker saysNo such image. - Delete the folders
~/projects/hello-imageand~/projects/leak-demo. - Run
docker system dfto see how much space images, containers and the build cache use. Do not run a command that deletes everything: other projects on your computer can use the same Docker. - If your project is broken, copy the snapshot
end-of-m02, and buildticket-api:1.0.0again.
Next, you connect to the container through ports, and you learn why 127.0.0.1 inside a container means something different.
Next: Ports and service access