Skip to content

Runtime safety

  • Lesson 6
  • 50 min
  • Module 2: Build the application image
Lesson 6 of 15 · Docker and Cloud Essentials for AI DevelopersRuntime safety

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

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:

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

Ask the container which user runs it:

Terminal
docker exec ticket-api id

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

Terminal
docker exec ticket-api touch /app/ticket_api/x.py
docker 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:

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

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:

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
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 8000
CMD ["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:

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

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

Start the new image, with the same command as before:

Terminal
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
docker exec ticket-api id

The output (after the container ID):

uid=10001(app) gid=10001(app) groups=10001(app)

Try the same changes as root did:

Terminal
docker exec ticket-api touch /app/x
docker exec ticket-api sh -c 'echo "print(1)" > /app/ticket_api/classifier.py'

The output:

touch: cannot touch '/app/x': Permission denied
sh: 1: cannot create /app/ticket_api/classifier.py: Permission denied

The files belong to root, and other users can only read them:

Terminal
docker exec ticket-api ls -l /app

The 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.txt
drwxr-xr-x 2 root root 4096 Oct 7 17:03 ticket_api

rw-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 CMD
10001 20466 20442 25 17:10 ? 00:00:00 /usr/local/bin/python3.14 /usr/local/bin/fastapi run --host 0.0.0.0 --port 8000
Two containers from the same code. Without a USER line, fastapi runs as root, uid 0: it can read the code in /app, change the code in /app and write in /usr/local where Python and the packages are. The last two are marked as what an attacker can use. With USER app, fastapi runs as app, uid 10001: it can read the code in /app, but cannot change it and cannot write in /usr/local.
The same app. As root, a taken-over process can change the code and the packages. As app, it can only read them.
Text 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.

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

~/projects/leak-demo/Dockerfile
FROM python:3.14.8-slim-trixie
WORKDIR /app
COPY .env .
RUN rm .env

The folder has a .env file with one line, API_KEY=demo-key-7731-not-real, and no .dockerignore. The build works:

Terminal, in ~/projects/leak-demo
docker build -t leak-demo:1 .
docker run --rm leak-demo:1 ls -a /app

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

Terminal
docker history leak-demo:1
IMAGE CREATED CREATED BY SIZE COMMENT
54aae749bcd1 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:

Terminal window
docker image save leak-demo:1 -o leak-demo.tar
mkdir layers && tar -xf leak-demo.tar -C layers
for f in layers/blobs/sha256/*; do tar -tf "$f" 2>/dev/null | grep -qx app/.env && echo "$f"; done

The output (the image file is 42 MB):

layers/blobs/sha256/ed3d3b3e5e58ff6e9574e76ff47278683ebdfd02bf7f54b38b1953fc56b04942

Read the file from that layer. Use the name from your own output:

Terminal
tar -xOf layers/blobs/sha256/ed3d3b3e5e58ff6e9574e76ff47278683ebdfd02bf7f54b38b1953fc56b04942 app/.env
API_KEY=demo-key-7731-not-real

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

The image as a stack of layers, newest on top. Layer 7, from RUN rm .env, has only a marker, .wh..env, that hides .env. Layer 6, from COPY .env ., has app/.env with the API key. Layer 5, from WORKDIR /app, has the folder. Layers 1 to 4 are the base image. In a container, ls -a /app shows no .env, but in the saved image, layer 6 still has the file and the key.
RUN rm adds a layer that hides the file. The layer below still has it, and anyone with the image can read it.
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:

  1. 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.
  2. Remove the image everywhere: docker image rm leak-demo:1 on your computer, and in every registry that you pushed it to.
  3. Change the Dockerfile so that the secret never enters a build: no COPY of secret files, no ENV or ARG with 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.

  1. Add RUN useradd --create-home --uid 10001 app after the ENV lines of your Dockerfile.
  2. Add USER app after COPY ticket_api ./ticket_api.
  3. Compare your file with the finished Dockerfile in Run as a user that is not root. It must be the same.
  4. Build with docker build -t ticket-api:1.0.0 .. The build has seven steps.
  5. 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.
  6. Run docker exec ticket-api id. The output starts with uid=10001(app).
  7. Run docker exec ticket-api touch /app/x. The output is Permission denied.
  8. Call /health. It gives {"status":"ok"}.
  9. 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 denied

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

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.

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.

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.

Terminal
docker image inspect ticket-api:1.0.0 --format '{{.Config.User}}'
app
  1. Build the image from the finished Dockerfile, as ticket-api:1.0.0, if you have not done it yet.
  2. 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}}.
  3. For each field, write one line: which Dockerfile line set it.
  4. Look at Env. Is there a key or a password in it? Which variables come from the base image?
  5. 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:

Terminal window
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=/app
Terminal window
docker 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"]
  1. User comes from USER app; Cmd from CMD [...]; ExposedPorts from EXPOSE 8000; WorkingDir from WORKDIR /app; the last five variables in Env from the ENV lines.

  2. No key and no password. API_KEY is not there, because you give it only with -e when a container starts. PATH, PYTHON_VERSION and PYTHON_SHA256 come from the base image.

  3. docker history (top lines):

    CMD ["fastapi" "run" "--host" "0.0.0.0" "--p… 0B
    EXPOSE [8000/tcp] 0B
    USER app 0B
    COPY ticket_api ./ticket_api # buildkit 49.2kB
    COPY pyproject.toml . # buildkit 12.3kB
    RUN /bin/sh -c pip install -r requirements-r… 84.1MB
    COPY requirements-run.txt . # buildkit 12.3kB
    WORKDIR /app 8.19kB
    RUN /bin/sh -c useradd --create-home --uid 1… 69.6kB
    ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFER… 0B

    CMD, EXPOSE, USER and ENV are 0B. They change only the configuration that you printed in step 2, not the files, so their layers are empty.

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.

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.

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

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

Recap

In this module, you built the application image:

  • A Dockerfile with FROM, WORKDIR, COPY, RUN, EXPOSE and CMD in exec form, and requirements-run.txt made in a Linux container.
  • A fast build: packages before code, so a code change rebuilds in under a second. An allowlist .dockerignore that 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:

  1. List your containers with docker ps -a. Stop and remove any ticket-api container: docker rm -f ticket-api.
  2. Remove the test images: docker image rm hello-image leak-demo:1. If you removed them already, Docker says No such image.
  3. Delete the folders ~/projects/hello-image and ~/projects/leak-demo.
  4. Run docker system df to 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.
  5. If your project is broken, copy the snapshot end-of-m02, and build ticket-api:1.0.0 again.

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