ProcureYard lists Docker as a core skill, and nearly every AI engineering team ships containers. Model servers, vector databases, workers and your FastAPI app all run as images on some cloud platform. This section covers the parts you will use every day: writing a good Dockerfile, running and debugging containers, keeping data in volumes, and running a multi-service stack with Compose. By the end you will have the compose.yaml that runs the app and Postgres you ship in this chapter.
1. The mental model
An image is a read-only, layered package containing your code, runtime, system libraries and settings. Each Dockerfile instruction adds a layer. Images are immutable, and you identify them by name:tag.
A container is a running instance of an image plus a thin writable layer on top. Containers are disposable. When you remove one, everything written to its writable layer is gone.
A volume is storage that lives outside any container's lifecycle. Anything you need to keep, such as Postgres data or uploaded files, belongs in a volume.
A registry stores and distributes images. Examples are Docker Hub, GitHub Container Registry, AWS ECR and Google Artifact Registry.
The programming analogy: an image is a class and a container is an instance. A volume is the database the instances share.
2. A good Dockerfile for a FastAPI app
code
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# Dependencies first: this layer is cached until requirements.txt changes
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-slim base. It is a fraction of the size of the full python image and has a smaller attack surface. Pin the version so builds stay reproducible.
Copy dependencies before code. Docker reuses a cached layer until its inputs change. With this order, a one-line code change rebuilds in seconds instead of reinstalling every package.
PYTHONUNBUFFERED=1. Logs appear in docker logs immediately instead of sitting in a buffer.
Non-root . If the app is compromised, the attacker does not get root inside the container.
--host 0.0.0.0. Inside a container, 127.0.0.1 is only the container itself. A server bound there cannot be reached through a published port. This is the same bind problem as in the Linux section.
exec in CMD. This makes uvicorn PID 1, so it receives SIGTERM from docker stop and shuts down cleanly. The JSON-array exec form of CMD achieves the same thing and is the more common idiom.
Docker reuses layers until one's inputs change, then rebuilds that layer and everything after it, which is why requirements go in before the code.
Add a .dockerignore so the build context stays small and secrets stay out of the image:
code
.git
.env
__pycache__/
.venv/
node_modules/
*.log
3. Build, run and publish
code
docker build -t notes-api:0.1 .
docker run -d --name api -p 8000:8000 --env-file .env notes-api:0.1
curl localhost:8000/health
-p 8000:8000 maps host port : container port. -p 9000:8000 would make the app reachable on port 9000 of your machine.
-d runs the container in the background. --rm removes it automatically when it exits, which is handy for one-off runs.
--env-file .env injects configuration at runtime, so it never gets baked into the image.
To publish to a registry:
code
docker tag notes-api:0.1 ghcr.io/you/notes-api:0.1
docker push ghcr.io/you/notes-api:0.1
Tag with a version or a git SHA. Avoid relying on latest in deployments, because it does not tell you which code is actually running.
4. Debugging containers
code
docker ps -a # all containers, including exited ones, with status
docker logs -f --tail 100 api # follow the app's stdout and stderr
docker exec -it api sh # open a shell inside the running container
docker inspect api # full config: env, mounts, network, exit code
docker stats # live CPU and memory per container
docker run --rm -it notes-api:0.1 sh # start a throwaway shell in the image to poke around
Read the exit code in docker ps -a:
Exited (0) means the main process finished. For a server this is usually wrong, for example a CMD that runs a script and returns.
Exited (1) means the app crashed. The traceback is in docker logs.
Exited (137) means the container was killed with SIGKILL, very often by the OOM killer. Check docker inspect api for OOMKilled: true. Loading ML models in a memory-limited container is a common trigger.
5. Persisting data: volumes and bind mounts
Named volumes are managed by Docker. Use them for database data.
code
docker volume create pgdata
docker run -d --name db -e POSTGRES_PASSWORD=devpassword -v pgdata:/var/lib/postgresql/data postgres:16
You can remove and recreate the db container and the data survives in pgdata.
Bind mounts map a host directory into the container. Use them in development so code edits show up without a rebuild:
Rule of thumb: use named volumes for data and bind mounts for source code in development. In production, do neither for code, because the code should be in the image.
Inspect and clean up volumes with docker volume ls, docker volume inspect pgdata and docker volume rm pgdata. Removing a volume deletes the data permanently.
6. Container networking
Containers on the same user-defined network reach each other by name. Compose creates such a network automatically.
The classic bug is a DATABASE_URL pointing at localhost:5432 from the API container. Inside a container, localhost is that container, not your laptop and not the database container. Use the service name:
Only publish ports (-p) that need to be reached from outside Docker. The database usually does not need a published port in production.
7. Docker Compose: the whole stack in one file
compose.yaml:
code
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U app -d app
interval: 5s
retries: 10
api:
build: .
ports:
- '8000:8000'
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
Key points:
depends_on with condition: service_healthy waits until Postgres actually accepts connections, not merely until its container has started. Without it, the API often crashes on the first boot.
build: . builds the API image from your Dockerfile. image: pulls a prebuilt image.
The top-level volumes: block declares the named volume so the data persists.
Daily commands:
code
docker compose up -d --build # build images and start everything
docker compose ps # status of each service
docker compose logs -f api # follow one service's logs
docker compose exec db psql -U app # open a Postgres shell in the db container
docker compose restart api
docker compose down # stop and remove containers, keep volumes
docker compose down -v # also delete volumes, which wipes the database
Be careful with down -v. It is the Docker equivalent of reset --hard for your data.
8. Image hygiene
Never bake secrets into an image.ENV API_KEY=... and COPY .env both end up in a layer, and anyone who can pull the image can read them. docker history shows the layers. Inject secrets at runtime instead.
Use multi-stage builds for compiled assets. For example, build a React frontend in a node stage, then copy only the built static files into a small final image.
Reclaim disk space.docker system df shows usage. docker image prune removes dangling images. docker system prune removes stopped containers, unused networks and dangling images. Check what it will remove before running it on a shared box.
9. Reviewing AI-generated Docker files
Claude Code and Cursor write plausible Dockerfiles quickly. Review what they produce against this checklist:
Does it run as root?
Does it COPY . . before installing dependencies, which breaks the layer cache?
Is there a .dockerignore? Could .env end up in the image?
Does it use an unpinned latest base image?
Does the server bind to 0.0.0.0?
Does Compose use a healthcheck, or only a bare depends_on?
Does the database use a named volume?
Practice
Write the Dockerfile and .dockerignore for your FastAPI app. Build it twice, changing only one line of code in between, and confirm the dependency layer comes from cache.
Run the Compose stack. Insert a row with docker compose exec db psql -U app, then run docker compose down followed by up -d. Confirm the row is still there. Then run down -v and confirm it is gone.
Deliberately set DATABASE_URL to localhost. Observe the failure in docker compose logs api, then fix it.