Chapter 5 · Ship It
Docker: Packaging Your API
- Page 17 of 22
- 23 min read
Your review analyser works on your laptop. Now it has to run on a server with a different operating system, a different Python and none of your packages, and next month a teammate has to run it too. "It works on my machine" is the oldest problem in deployment, and Docker is how the industry solved it: you package the app together with its Python, its libraries and its start command into an image, and that image runs the same way on your laptop, in CI and on Render, Cloud Run or a Kubernetes cluster.
For AI backends this matters even more than usual. Model-serving code depends on exact versions of NumPy, scikit-learn or PyTorch, and a mismatch does not always crash: sometimes it just predicts differently. An image freezes all of that. In this page you will package the small FastAPI "review analyser" API, run it, read its logs, stop it properly, and make the image smaller, safer and reproducible.
What you will learn
- What containers are, how they differ from virtual machines, and the vocabulary: image, layer, container, registry, tag.
- A production-style Dockerfile for FastAPI on
python:3.14-slimwith uv (multi-stage), and the plain pip variant. - Layer caching,
.dockerignore, build and run with port mapping and environment variables. - Logs, exit codes and signals: why
docker stopsometimes takes 10 seconds and ends with 137. - Running as a non-root user, image size, vulnerability scanning and reproducible builds.
Containers in one picture
A virtual machine emulates a whole computer and boots its own operating system kernel. A container is just a normal process on the host's Linux kernel, fenced off with kernel features (namespaces give it its own view of files, processes and network; cgroups limit its CPU and memory). No second kernel boots, so a container starts in well under a second and adds almost no memory overhead.
Virtual machines Containers
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ app A │ │ app B │ │ app A │ │ app B │
│ libs │ │ libs │ │ Python, │ │ Python, │
│ guest OS │ │ guest OS │ │ libs │ │ libs │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
┌───────────────────────┐ ┌───────────────────────┐
│ hypervisor │ │ Docker Engine │
├───────────────────────┤ ├───────────────────────┤
│ host OS + hardware │ │ host Linux kernel │
└───────────────────────┘ └───────────────────────┘| Virtual machine | Container | |
|---|---|---|
| Isolation | Strong: separate kernel | Process-level: shares the host kernel |
| Start time | Tens of seconds | Milliseconds to a second |
| Size | Gigabytes (a full OS) | Tens to hundreds of MB |
| Typical use | Running different OSes, strong multi-tenant isolation | Packaging and shipping one app or service |
On macOS and Windows, Docker Desktop quietly runs a small Linux VM and your containers run inside it; that is why Linux images work everywhere. In 2026 that means Docker Engine 29 and Docker Desktop 4.9x; the commands on this page are the same on all of them.
The vocabulary
| Term | Meaning |
|---|---|
| Image | A read-only template: a filesystem (Debian, Python, your venv, your code) plus metadata (start command, environment, exposed port). Built from a Dockerfile. |
| Layer | Each filesystem-changing instruction (COPY, RUN) adds a layer. Layers are cached and shared between images. |
| Container | A running (or stopped) instance of an image, with a thin writable layer on top. Delete it and that layer is gone. |
| Registry | A server that stores images: Docker Hub, GitHub Container Registry (ghcr.io), Google Artifact Registry, AWS ECR. |
| Tag | A human name for an image version, after the colon: python:3.14-slim, bitbyte-p17-api:1.0. Tags can move; a digest (@sha256:…) cannot. |
The official Python image is the base for this page. docker run downloads it the first time, starts a container, runs one command, and --rm deletes the container afterwards:
docker run --rm python:3.14-slim python --version
docker run --rm python:3.14-slim grep PRETTY_NAME /etc/os-releasePython 3.14.8
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"slim is Debian 13 "trixie" with only what Python needs. The other flavours: the full python:3.14 (about 1 GB, with compilers, useful when a package must build from source), and python:3.14-alpine (tiny, but it uses musl instead of glibc, so many scientific wheels do not install and must be compiled; avoid it for AI work). Python 3.15 came out in October 2026, but its images and many wheels lag behind, so this course stays on 3.14.
The app to package
Each page in this chapter starts in a fresh folder, so here is the small API again: a health check and one "prediction" endpoint. The analyser is a keyword rule standing in for a real model (the scikit-learn version is in Serving an ML Model); what matters here is the packaging. Notice that it reads its version from an environment variable and logs to standard output, the two habits that make an app container-friendly.
mkdir -p ~/projects/review-api
cd ~/projects/review-api# file: main.py
"""AI review analyser: a tiny API to package with Docker."""
import logging
import os
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("review-api")
APP_VERSION = os.environ.get("APP_VERSION", "dev")
POSITIVE = {"good", "great", "excellent", "clear", "helpful", "love"}
NEGATIVE = {"bad", "boring", "confusing", "slow", "hate", "broken"}
class Review(BaseModel):
text: Annotated[str, Field(min_length=3, max_length=2000)]
class Analysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
score: float
model_version: str
app = FastAPI(title="Review Analyser", version=APP_VERSION)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "version": APP_VERSION}
@app.post("/v1/analyse")
def analyse(review: Review) -> Analysis:
words = [w.strip(".,!?").lower() for w in review.text.split()]
pos = sum(w in POSITIVE for w in words)
neg = sum(w in NEGATIVE for w in words)
score = round((pos - neg) / max(len(words), 1), 2)
sentiment = "positive" if score > 0 else "negative" if score < 0 else "neutral"
log.info("analysed %d words: %s", len(words), sentiment)
return Analysis(sentiment=sentiment, score=score, model_version=f"rules-{APP_VERSION}")The dependencies are declared in pyproject.toml, as in Your First FastAPI App:
# file: pyproject.toml
[project]
name = "review-api"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.143.0",
]
[tool.uv]
# Resolve as of this date, so every reader of this page gets the same versions.
exclude-newer = "2026-10-10T00:00:00Z"exclude-newer tells uv to ignore anything published after that moment. It is there so your lockfile matches this page exactly; in your own projects you can leave it out, because the lockfile itself is what pins versions.
Locking with the same uv the image uses
The image will install exactly what uv.lock says. Normally you run uv lock on your machine. Here is a neat alternative that needs no local uv or Python at all: run uv from its official image, with the project folder mounted into the container. -v "$PWD":/app mounts the current folder at /app, -w /app makes it the working directory, and --user makes the new file belong to you instead of root:
docker run --rm -v "$PWD":/app -w /app --user "$(id -u):$(id -g)" \
-e UV_CACHE_DIR=/tmp/uv-cache \
ghcr.io/astral-sh/uv:0.13.0-python3.14-trixie-slim uv lock
lsUsing CPython 3.14.8 interpreter at: /usr/local/bin/python3
Resolved 58 packages in 763ms
main.py
pyproject.toml
uv.lockBoth uv versions are pinned to 0.13.0 (the image tag here, and the COPY --from line in the Dockerfile below). uv is still 0.x and its lockfile format can change between versions, so the tool that writes the lock and the tool that reads it should match.
The Dockerfile
A Dockerfile is the recipe for an image. This one has two stages: a builder that has uv and creates the virtual environment, and a clean runtime image that receives only the finished .venv and the code. uv itself never ships to production.
# file: Dockerfile
# ---- stage 1: build the virtual environment with uv ----
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Dependencies first: this layer is rebuilt only when the lock changes.
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
# ---- stage 2: the runtime image ----
FROM python:3.14-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY main.py ./
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PORT=8000
USER app
EXPOSE 8000
CMD ["fastapi", "run", "main.py"]| Instruction | What it does here |
|---|---|
FROM python:3.14-slim AS builder | Start from the official image and name this stage. |
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /uvx /bin/ | Copy the uv binary out of uv's own image: the method uv's docs recommend. |
ENV UV_COMPILE_BYTECODE=1 | Pre-compile .pyc files at build time, so the container starts faster. UV_LINK_MODE=copy avoids a warning about hard links across filesystems; UV_PYTHON_DOWNLOADS=0 makes uv use the image's Python, never download one. |
RUN uv sync --locked --no-dev --no-install-project | Create /app/.venv from the lock. --locked fails if uv.lock is out of date instead of silently re-resolving; --no-dev skips test tools. |
RUN useradd … app / USER app | Create an unprivileged user and run as it (more below). |
COPY --from=builder /app/.venv /app/.venv | Take only the result of stage 1. Same base image and same path, so the venv's links to /usr/local/bin/python stay valid. |
ENV PATH=… | Put the venv first on PATH: fastapi and python now mean the venv's. PYTHONUNBUFFERED=1 sends logs out immediately. |
ENV PORT=8000 / EXPOSE 8000 | fastapi run reads the PORT variable (as do Render and Cloud Run, which set it for you). EXPOSE is documentation only; it publishes nothing. |
CMD ["fastapi", "run", "main.py"] | The start command, in exec form (a JSON list). fastapi run listens on 0.0.0.0, which a container needs: 127.0.0.1 inside a container is unreachable from outside it. |
.dockerignore
docker build . sends the folder (the build context) to the Docker engine. Without a .dockerignore, that includes your local .venv (hundreds of MB, built for your OS), .git, and worst of all .env with real keys, which a careless COPY . . would bake into the image for anyone who pulls it:
# file: .dockerignore
.venv/
__pycache__/
*.pyc
.git/
.env
*.log
Dockerfile*Build and run
-t gives the image a name and tag; the final . is the build context:
docker build -t bitbyte-p17-api:1.0 .The first build downloads the packages from PyPI and takes a little while. With BuildKit, the default builder in Docker Desktop and current Docker Engine, you see one line per step, such as => [builder 5/5] RUN uv sync --locked …, and CACHED for steps that were reused; the exact log differs between machines and runs, so it is not shown here. Check the result:
docker run --rm bitbyte-p17-api:1.0 python --version
docker run --rm bitbyte-p17-api:1.0 python -c "import fastapi; print('fastapi', fastapi.__version__)"Python 3.14.8
fastapi 0.143.0Now start the API as a long-running container. -d runs it in the background and prints the new container's ID; --name gives it a name to use instead; -e sets environment variables; -p 8000:8000 publishes it as host port:container port:
docker run -d --name bitbyte-p17-api -e APP_VERSION=1.0.0 -e PORT=8000 -p 8000:8000 bitbyte-p17-api:1.04a2879830e2ba57a2a5efdbb7de8c6471a3f91fc438480165384a595b0a1d422(Your ID will differ.) Passing PORT repeats the image's default here, but it makes the contract visible: the app listens on PORT inside the container, and the right-hand side of -p must match it. The left-hand side is whatever free port you like on your machine.
The app needs a second to start. Docker accepts connections on the published port before the app inside is ready, so a request that arrives too early gets an empty reply rather than "connection refused"; --retry-all-errors retries that too:
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8000/health
echo
curl -s -X POST http://localhost:8000/v1/analyse \
-H "Content-Type: application/json" \
-d '{"text": "Great course, very clear and helpful!"}'
echo
docker ps --filter name=bitbyte-p17-api --format "{{.Names}} {{.Image}} {{.State}}"{"status":"ok","version":"1.0.0"}
{"sentiment":"positive","score":0.5,"model_version":"rules-1.0.0"}
bitbyte-p17-api bitbyte-p17-api:1.0 runningLogs
A container's logs are whatever its main process writes to stdout and stderr. That is why the app logs to standard output and why PYTHONUNBUFFERED=1 matters: never write log files inside a container, because they vanish with it. Every platform (Docker, Render, Cloud Run, Kubernetes) collects stdout for you.
docker logs --tail 20 bitbyte-p17-api ⚡️ Starting FastAPI in production mode
🐍 Using import string: 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)
INFO: 172.17.0.1:49848 - "GET /health HTTP/1.1" 200 OK
INFO review-api: analysed 6 words: positive
INFO: 172.17.0.1:49860 - "POST /v1/analyse HTTP/1.1" 200 OKThe access-log lines include the client's address and port, which change from run to run. Filter for what you need; docker logs -f follows the log live, and --since 10m or --tail 50 limit it:
docker logs bitbyte-p17-api 2>&1 | grep review-apiINFO review-api: analysed 6 words: positiveNotice Started server process [1] in the full log: the server is PID 1 inside the container. That fact decides how the container shuts down.
Exit codes and signals
When a container's main process ends, the container stops, and the process's exit code becomes the container's. Scripts, CI and orchestrators read it, so learn the common values:
docker run --rm bitbyte-p17-api:1.0 python -c "import sys; sys.exit(3)"
echo "exit code: $?"exit code: 3| Exit code | Usually means |
|---|---|
| 0 | Clean exit (for a server: it was asked to stop and did so gracefully) |
| 1 | The app crashed: an unhandled exception, a failed import, a missing setting |
| 125 / 126 / 127 | Docker itself failed / the command cannot be executed / the command was not found |
| 137 | Killed with SIGKILL (128 + 9): out of memory, or it ignored SIGTERM and was killed after the timeout |
| 143 | Terminated by SIGTERM (128 + 15) without handling it |
docker stop sends SIGTERM, waits (10 seconds by default), then sends SIGKILL. Uvicorn handles SIGTERM: it stops accepting connections, finishes the requests in flight, runs the shutdown part of your lifespan, and exits with 0:
docker stop bitbyte-p17-api
docker logs bitbyte-p17-api 2>&1 | grep -iE "shutting|shutdown|finished"
docker inspect -f "{{.State.ExitCode}}" bitbyte-p17-apibitbyte-p17-api
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [1]
0That worked because of the exec-form CMD ["fastapi", "run", "main.py"]: the server itself is PID 1 and receives the signal. Now see the classic mistake. The shell form, CMD fastapi run main.py, is run as /bin/sh -c "fastapi run main.py": the shell becomes PID 1, the server is its child, and the shell does not pass SIGTERM on. PID 1 also ignores signals it has no handler for, so nothing happens until Docker loses patience. The next block simulates that by overriding the command, and waits only 3 seconds instead of 10:
docker run -d --name bitbyte-p17-shell bitbyte-p17-api:1.0 sh -c "fastapi run main.py" > /dev/null
docker stop -t 3 bitbyte-p17-shell
docker inspect -f "{{.State.ExitCode}}" bitbyte-p17-shell
docker rm bitbyte-p17-shell bitbyte-p17-apibitbyte-p17-shell
137
bitbyte-p17-shell
bitbyte-p17-api137: the server was killed mid-flight. Requests in progress were cut off, and the shutdown code never ran (a model was not unloaded cleanly, a queue was not flushed). On every deploy and every scale-down, that is what your users would feel. Use the exec form; if you really need a shell script as the entrypoint, end it with exec fastapi run main.py so the server replaces the shell.
Running as a non-root user
By default, processes in a container run as root. Container root is limited, but if an attacker finds a hole in your app (or one of its 50 dependencies), being root inside makes the next step, escaping to the host or tampering with the image's files, much easier. The USER app line costs nothing:
docker run --rm bitbyte-p17-api:1.0 id
docker run --rm bitbyte-p17-api:1.0 touch /app/main.py || echo "write refused"uid=10001(app) gid=10001(app) groups=10001(app)
touch: cannot touch '/app/main.py': Permission denied
write refusedThe code belongs to root and the app runs as app (UID 10001), so even a compromised app cannot rewrite itself. A fixed, high UID also plays well with platforms (Kubernetes can enforce runAsNonRoot). If the app must write somewhere (uploads, a model cache), create that one folder and chown it to app, or better, use a volume. For extra hardening, docker run --read-only --tmpfs /tmp makes the whole filesystem read-only.
Layer caching: order matters
Docker caches each step. When it rebuilds, it reuses cached layers until it reaches the first step whose inputs changed; that step and every step after it run again. Our Dockerfile copies pyproject.toml and uv.lock and installs dependencies before copying main.py. So when you change only the code, the slow install is reused:
sed -i 's/"dev"/"local"/' main.py
docker build -t bitbyte-p17-api:1.1 .This rebuild takes a second or two: every step up to uv sync is reported as cached, and only COPY main.py and what follows run again. Had the Dockerfile started with COPY . ., every one-character code change would reinstall all 58 packages. The rule: copy what changes rarely first, what changes often last.
With BuildKit you can go further and keep uv's download cache between builds, even when the lock changes. This form of RUN needs BuildKit (on by default in Docker Desktop and current Docker Engine):
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-projectThe pip variant
Some teams and platforms do not use uv. The same lock can be exported to a pinned requirements.txt, and the Dockerfile becomes single-stage and plain pip:
uv export --locked --no-dev --no-hashes --no-header --format requirements-txt -q -o requirements.txt
grep -E "^(fastapi|uvicorn|pydantic)==" requirements.txtfastapi==0.143.0
pydantic==2.14.0
uvicorn==0.54.0# file: Dockerfile.pip
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PORT=8000
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY main.py ./
USER app
EXPOSE 8000
CMD ["fastapi", "run", "main.py"]docker build -f Dockerfile.pip -t bitbyte-p17-api:pip .docker run --rm bitbyte-p17-api:pip python -c "import fastapi; print('fastapi', fastapi.__version__)"
docker run --rm bitbyte-p17-api:pip id -unfastapi 0.143.0
appSame versions, because both came from one lock. -f chooses a Dockerfile with another name. PIP_NO_CACHE_DIR=1 keeps pip's download cache out of the image. The trade-off: pip installs straight into the system site-packages of the final image, so build tools a package might need would stay in the image too; the multi-stage uv build leaves them behind.
Image size
On this machine both images show about 300 MB on disk (69 MB compressed in a registry). The base python:3.14-slim is 180 MB of that, and the venv is about 96 MB. You can see each layer's contribution with docker history:
docker history bitbyte-p17-api:1.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -60B /bin/sh -c #(nop) CMD ["fastapi" "run" "mai…
0B /bin/sh -c #(nop) EXPOSE 8000
0B /bin/sh -c #(nop) USER app
0B /bin/sh -c #(nop) ENV PATH=/app/.venv/bin:/…
12.3kB /bin/sh -c #(nop) COPY file:4c3197cadf436bdd…
95.8MB /bin/sh -c #(nop) COPY dir:18e0cab54c73b870f…This output comes from Docker's classic builder; with BuildKit the second column reads like your Dockerfile lines (COPY /app/.venv /app/.venv # buildkit). Either way, the venv layer is the big one, and the rest is the base image.
Ways to shrink an image, in order of payoff:
- Fewer dependencies.
fastapi[standard]brings the CLI, OpenTelemetry, the FastAPI Cloud CLI and more. A service that only needsfastapianduvicornis much smaller. For ML, CPU-only PyTorch wheels are a fraction of the CUDA ones. - Multi-stage builds, so compilers and caches stay in the builder.
- A slim base, and a good
.dockerignore. - Clean up in the same
RUN:apt-get install … && rm -rf /var/lib/apt/lists/*. A file deleted in a later layer still takes space in the earlier one.
Never put model weights of several GB into the image without thinking: every deploy then pulls them again. Large models usually live in object storage or a model registry and are downloaded once to a volume at startup.
Scanning for vulnerabilities
Your image contains a whole Debian userland plus every Python package, and new vulnerabilities (CVEs) are published for them every week. Scan images in CI and rebuild regularly so you pick up the patched base image. Two common scanners (both need network access to download their vulnerability database, so they are not run here):
# Docker Scout (built into Docker Desktop; needs a Docker login)
docker scout cves bitbyte-p17-api:1.0
docker scout recommendations bitbyte-p17-api:1.0
# Trivy (open source), run from its own image
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --severity HIGH,CRITICAL bitbyte-p17-api:1.0Read the report with judgement: a critical CVE in a library your app never calls is less urgent than a medium one in your web server. pip-audit (from API Security and the OWASP API Top 10) covers the Python side before you even build.
Reproducible builds
A build is reproducible when building the same commit twice gives the same software. Every moving part needs pinning:
| Moving part | How to pin it |
|---|---|
| Python packages | uv.lock + uv sync --locked (or a fully pinned, ideally hashed, requirements file) |
| uv itself | ghcr.io/astral-sh/uv:0.13.0, never :latest |
| Base image | A specific tag (3.14-slim, never latest); for full immutability, its digest |
| Your code | Build from a Git commit, and tag the image with the commit SHA or a release version |
Tags such as python:3.14-slim move when a patch release or a Debian security fix comes out (that is usually what you want). A digest never moves. Find the one you have:
docker image inspect python:3.14-slim --format "{{index .RepoDigests 0}}"python@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170and pin it with FROM python:3.14-slim@sha256:…. Keep the tag in front for humans; Docker uses the digest. Pinned digests do not update themselves, so pair them with Dependabot or Renovate, which open a pull request when a new digest appears (configured in CI/CD with GitHub Actions).
Where this shows up
- Every deployment target. Render, Cloud Run, Railway, Kubernetes and AWS ECS all run images. The same image moves from CI to staging to production; only environment variables change.
- Model serving. Inference servers (vLLM, Text Generation Inference, Triton) ship as images; GPU images add the NVIDIA runtime on top of the same ideas.
- Reproducible research. An image tag next to an experiment's results says exactly which code and libraries produced them.
Common mistakes
COPY . .before installing dependencies. Every code change reinstalls everything, and without a.dockerignoreit copies.venvand.envinto the image. Copy the lockfile, install, then copy the code.- Secrets in the image.
ENV OPENAI_API_KEY=…or a copied.envis readable by anyone who can pull the image, and stays in its layer history even if a later step deletes it. Pass secrets at run time (-e,--env-file, the platform's secret store); for build-time secrets use BuildKit'sRUN --mount=type=secret. - Listening on 127.0.0.1 inside the container. The port is published but every request fails. Servers in containers must listen on
0.0.0.0;fastapi rundoes this by default,uvicornneeds--host 0.0.0.0. - Shell-form
CMD. SIGTERM never reaches the server, so every stop takes the full timeout and ends in SIGKILL (137). Use the JSON exec form, orexecin entrypoint scripts. :latesteverywhere. The build that worked yesterday picks up a new Python or a new uv today. Pin tags (and digests where it matters), and tag your own images with versions, notlatest.
Try it yourself
- Easy: run the 1.0 image as a container named
bitbyte-p17-easywithAPP_VERSION=2.0.0, published on host port 8001 (the container still listens on 8000). Call/healththrough port 8001, then remove the container. - Medium: put
APP_VERSION=1.2.0in a fileapp.envand start the container with--env-file app.envinstead of-e. Show that/healthreports 1.2.0, and that the variable is not part of the image (printenv APP_VERSIONin a fresh container from the image prints nothing). - Hard: give the app a
lifespanthat logsmodel loadedat startup andmodel unloadedat shutdown. Build it asbitbyte-p17-api:1.3, run it, stop it withdocker stop, and prove from the logs and the exit code that the shutdown code ran.
Answers
1. The host port changes; the container port stays what the app listens on:
docker run -d --name bitbyte-p17-easy -e APP_VERSION=2.0.0 -e PORT=8000 -p 8001:8000 bitbyte-p17-api:1.0 > /dev/null
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8001/health
echo
docker rm -f bitbyte-p17-easy{"status":"ok","version":"2.0.0"}
bitbyte-p17-easy2. An env file is one NAME=value per line, no quotes and no export. Keep real ones out of Git, like .env:
echo "APP_VERSION=1.2.0" > app.env
docker run -d --name bitbyte-p17-medium --env-file app.env -e PORT=8000 -p 8000:8000 bitbyte-p17-api:1.0 > /dev/null
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8000/health
echo
docker run --rm bitbyte-p17-api:1.0 printenv APP_VERSION || echo "not in the image"
docker rm -f bitbyte-p17-medium{"status":"ok","version":"1.2.0"}
not in the image
bitbyte-p17-medium3. Add the lifespan (only the changed parts of main.py are new; the endpoints are unchanged), rebuild and stop:
from pathlib import Path
source = Path("main.py").read_text()
source = source.replace(
"from fastapi import FastAPI\n",
"from contextlib import asynccontextmanager\n\nfrom fastapi import FastAPI\n",
)
source = source.replace(
'app = FastAPI(title="Review Analyser", version=APP_VERSION)',
'''@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("model loaded")
yield
log.info("model unloaded")
app = FastAPI(title="Review Analyser", version=APP_VERSION, lifespan=lifespan)''',
)
Path("main.py").write_text(source)
print("lifespan" in source)Truedocker build -t bitbyte-p17-api:1.3 .docker run -d --name bitbyte-p17-hard -e PORT=8000 -p 8000:8000 bitbyte-p17-api:1.3 > /dev/null
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8000/health > /dev/null
docker stop bitbyte-p17-hard > /dev/null
docker logs bitbyte-p17-hard 2>&1 | grep model
docker inspect -f "{{.State.ExitCode}}" bitbyte-p17-hard
docker rm bitbyte-p17-hard > /dev/nullINFO review-api: model loaded
INFO review-api: model unloaded
0Finally, clean up everything this page created (containers first, then images):
docker rm -f bitbyte-p17-api bitbyte-p17-easy bitbyte-p17-medium bitbyte-p17-hard > /dev/null 2>&1
docker image rm bitbyte-p17-api:1.0 bitbyte-p17-api:1.1 bitbyte-p17-api:1.3 bitbyte-p17-api:pip > /dev/null
docker image ls bitbyte-p17-api --format "{{.Repository}}:{{.Tag}}" | wc -l0Summary
- A container is an isolated process on the host kernel; an image is its read-only, layered template, stored in a registry under a tag or an immutable digest.
- A good FastAPI Dockerfile:
python:3.14-slim, dependencies fromuv.lockwithuv sync --lockedin a builder stage, the venv copied into a clean runtime stage, a non-root user,PORT, and an exec-formCMD ["fastapi", "run", …]. The pip variant installs an exported, pinnedrequirements.txt. - Order steps from rarely to often changing for good caching, and keep
.venv,.gitand.envout with.dockerignore. - Run with
-p host:containerand-e/--env-file; readdocker logs; know exit codes 0, 1, 137 and 143, and make sure SIGTERM reaches your server. - Keep images small and scanned, never bake in secrets, and pin everything that moves for reproducible builds.
Next: Docker Compose: API, PostgreSQL and Redis Together runs this kind of image next to a database and a cache, with health checks, volumes and secrets, all from one compose.yaml.