Persistent data
- Lesson 9
- 50 min
- Module 3: Networking, configuration and persistence
Lesson 9 of 15 · Docker and Cloud Essentials for AI DevelopersPersistent data
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
Keep the data that matters when containers are replaced, and lose only what does not matter. You add a history of the classifications in PostgreSQL, and you run the API and its database together with one command.
Before you startWhat to know and to have ready
What you need to know
- Containers on a user-defined network find each other by name, from Ports and service access: Container to container.
- Secret files and
API_KEY_FILE, from Runtime configuration: Secret files. - Liveness and readiness (
/healthand/ready), from Publish safely: Liveness and readiness. - The reviewed contract and
python -m ticket_api.export_contract, from Keep the documentation true: Worked example: the reviewed contract. requirements-run.txt, made in a container, from Dockerfile essentials: Make requirements-run.txt in a container.- Optional: a SQL
SELECT, from Select and filter: A query on six rows. The lesson explains the one query that it uses.
What you need on your computer
- The project with the new
config.pyandmain.py,secrets/api_key.txtand the imageticket-api:1.1.0-dev, from Runtime configuration: Guided practice: settings and a secret file. - The virtual environment
.venvin the project, from Build and Deploy Your First API (Environment and first endpoint: Step 1: make the project), to run the tests. - An internet connection: the lesson downloads one Python file and the PostgreSQL image (about 167 MB).
- If your project is missing or broken, copy the snapshot
end-of-m02, as Use a snapshot says. Then do steps 1 to 6 of the guided practice of Runtime configuration.
Tested with Docker Desktop 4.81.0 (Docker Engine 29.6.1, Compose v5.2.0) on macOS, Apple silicon. The Windows and Linux steps are not tested.
Container files are disposable
Section titled “Container files are disposable”Amira starts the API, writes a file in the container, and looks at it:
docker run -d --name ticket-api ticket-api:1.1.0-devdocker exec ticket-api sh -c "echo hello > /tmp/note.txt"docker exec ticket-api cat /tmp/note.txtdocker diff ticket-apiThe output (after the container ID):
helloC /tmpA /tmp/note.txtdocker diff lists what changed in the container’s writable layer, the thin layer on top of the image (Images and running containers): C is changed, A is added. Now she replaces the container with a new one from the same image, as every update does:
docker rm -f ticket-apidocker run -d --name ticket-api ticket-api:1.1.0-devdocker exec ticket-api cat /tmp/note.txtThe output (after the container name and the new ID):
cat: /tmp/note.txt: No such file or directoryThe file was in the old container’s writable layer, and that layer was deleted with the container. A container is disposable: you replace it at every new version, every change of a setting and every restart on a new server. Anything that must survive must be stored outside it.
docker rm -f ticket-apiWhere data belongs
Section titled “Where data belongs”| Storage | What it is | Survives a new container? | Use it for |
|---|---|---|---|
| The writable layer | Files that a container writes in its own file system | No | Temporary files |
| Named volume | Storage that Docker manages, with a name, outside every container | Yes, until you delete the volume | A database’s files on your computer |
| Bind mount | A file or folder of your computer, seen by the container | Yes: it is your file | A secret file, as in the last lesson |
| External database | A database server outside the app’s container, often a managed cloud service | Yes | Records, such as the history, in production |
| Object storage | A cloud service for files, such as Azure Blob Storage | Yes | Uploaded files, reports, trained model files |
A volume is storage for containers that lives outside them. A container mounts it at a path, and the files at that path go into the volume. A named volume stays on one computer, though. A cloud service that starts your container on any of many servers cannot use it. In the cloud, records go into a managed database, and files go into object storage (Module 4).
This lesson adds a history to the API: one row in a database for each classification. The database is PostgreSQL, in its own container, with its files in a named volume.
The history keeps the result (category, priority, confidence, model version, time and request ID), not the ticket’s text. A ticket can contain names, addresses or order numbers. The history is for counting and for following up a request ID, and neither needs the text. Data that you do not store cannot leak.
Add the history
Section titled “Add the history”The code for the history is given: the course is about containers, not about Python. You add one module and connect it in four files.
Step 1: get history.py
Section titled “Step 1: get history.py”Download the file into ticket_api:
curl -fsSL -o ticket_api/history.py https://raw.githubusercontent.com/Nextia-Intelligence/nextia-learning-labs/main/C18/files/history.pycurl.exe -fsSL -o ticket_api\history.py https://raw.githubusercontent.com/Nextia-Intelligence/nextia-learning-labs/main/C18/files/history.pycurl -fsSL -o ticket_api/history.py https://raw.githubusercontent.com/Nextia-Intelligence/nextia-learning-labs/main/C18/files/history.py-f makes curl fail on an HTTP error instead of saving the error page. If the download does not work, copy the code below into ticket_api/history.py.
Show ticket_api/history.py
"""Keep a record of the classifications in PostgreSQL.
The record has the result of each classification, never the ticket's text.History is optional: without DATABASE_URL the API classifies tickets andkeeps no record."""
import psycopgfrom psycopg.rows import dict_row
CREATE_TABLE = """CREATE TABLE IF NOT EXISTS classifications ( id bigserial PRIMARY KEY, request_id text NOT NULL, category text NOT NULL, priority smallint NOT NULL, confidence real NOT NULL, model_version text NOT NULL, created_at timestamptz NOT NULL DEFAULT now())"""
class HistoryUnavailable(Exception): """History is off, or the database does not answer."""
class History: def __init__(self, url: str): self.url = url self.table_ready = False
def connect(self) -> psycopg.Connection: try: return psycopg.connect(self.url, connect_timeout=3, row_factory=dict_row) except psycopg.OperationalError as error: raise HistoryUnavailable("the database does not answer") from error
def setup(self) -> None: """Make the table if it does not exist.""" with self.connect() as conn: conn.execute(CREATE_TABLE) self.table_ready = True
def check(self) -> None: with self.connect() as conn: conn.execute("SELECT 1")
def add(self, request_id: str, result: dict) -> None: if not self.table_ready: self.setup() with self.connect() as conn: conn.execute( "INSERT INTO classifications" " (request_id, category, priority, confidence, model_version)" " VALUES (%s, %s, %s, %s, %s)", ( request_id, result["category"], result["priority"], result["confidence"], result["model_version"], ), )
def recent(self, limit: int) -> list[dict]: if not self.table_ready: self.setup() with self.connect() as conn: return conn.execute( "SELECT request_id, category, priority, confidence, model_version," " created_at FROM classifications ORDER BY id DESC LIMIT %s", (limit,), ).fetchall()What it does: it connects to PostgreSQL with psycopg, the PostgreSQL driver for Python, and waits at most 3 seconds. It makes the table classifications if it does not exist. add writes one row, recent reads the newest rows, and check is for /ready. Every connection problem becomes HistoryUnavailable, so the rest of the app needs to know only one error.
Step 2: the new package and the version
Section titled “Step 2: the new package and the version”Add psycopg to requirements.txt, under the packages that the API needs to run:
# Needed to run the APIfastapi[standard]==0.142.2psycopg[binary]==3.3.6In pyproject.toml, change the version and add the same package:
version = "1.0.0"version = "1.1.0"description = "Classifies support tickets into a category and a priority."requires-python = ">=3.12"dependencies = ["fastapi[standard]==0.142.2"]dependencies = ["fastapi[standard]==0.142.2", "psycopg[binary]==3.3.6"][binary] installs a ready-made version of psycopg with the PostgreSQL client library inside, so the slim image needs no extra system packages.
Step 3: the models and the error
Section titled “Step 3: the models and the error”In ticket_api/models.py, add an import at the top, and two models after CategoryList:
from datetime import datetimefrom typing import Literalclass HistoryItem(BaseModel): """One classification that the API made. It has no ticket text."""
request_id: str = Field(description="The request that made the classification.") category: Category priority: int = Field(ge=1, le=3) confidence: float = Field(ge=0, le=1) model_version: str created_at: datetime = Field(description="When the API made it (UTC).")
class HistoryList(BaseModel): items: list[HistoryItem] = Field(description="The newest classification first.")In ticket_api/errors.py, import the error, and add a handler after the one for ClassifierUnavailable:
from ticket_api.classifier import ClassifierUnavailablefrom ticket_api.history import HistoryUnavailablefrom ticket_api.models import ErrorDetail, ErrorResponse @app.exception_handler(HistoryUnavailable) async def history_unavailable(request: Request, exc: HistoryUnavailable): logger.warning("history unavailable: %s id=%s", exc, request.state.request_id) return error_response( request, 503, "history_unavailable", "The history is not available. Try again later.", )Step 4: save each result, and list them
Section titled “Step 4: save each result, and list them”In ticket_api/routes.py, three changes: the imports, the end of classify, and a new endpoint at the end of the file:
import asynciofrom typing import Annotated, get_args
from fastapi import APIRouter, Depends, Requestimport logging
from fastapi import APIRouter, Depends, Query, Requestfrom fastapi.concurrency import run_in_threadpool
from ticket_api.classifier import ClassifierUnavailable, KeywordClassifierfrom ticket_api.history import HistoryUnavailablefrom ticket_api.models import ( Category, CategoryList, Classification, ErrorResponse, HistoryList, TicketIn,)from ticket_api.security import require_api_key
logger = logging.getLogger("ticket_api")
router = APIRouter(prefix="/v1", tags=["tickets"]) return Classification( result = Classification( category=prediction.category, priority=prediction.priority, confidence=prediction.confidence, model_version=classifier.version, ) history = request.app.state.history if history is not None: try: await run_in_threadpool( history.add, request.state.request_id, result.model_dump() ) except HistoryUnavailable: logger.warning("history not saved id=%s", request.state.request_id) return result
@router.get( "/history", tags=["history"], summary="List recent classifications", description=( "Returns the newest classifications first. History is off when the " "server has no DATABASE_URL." ), responses={ 401: { "model": ErrorResponse, "description": "The API key is missing or wrong.", }, 503: { "model": ErrorResponse, "description": "History is off, or the database does not answer.", }, }, dependencies=[Depends(require_api_key)],)async def list_history( request: Request, limit: Annotated[int, Query(ge=1, le=100, description="How many to return.")] = 10,) -> HistoryList: history = request.app.state.history if history is None: raise HistoryUnavailable("history is off: DATABASE_URL is not set") items = await run_in_threadpool(history.recent, limit) return HistoryList(items=items)Look at the try in classify. If the database does not answer, the classification still returns 200, and the log gets a warning. This is a design choice. Classifying is the API’s main job. The history is a side record, so its failure must not stop the main job. But the failure must be visible: in the log, and in /ready (next step). /v1/history needs the key, like /v1/classify.
Step 5: connect it in main.py
Section titled “Step 5: connect it in main.py”from ticket_api.config import Settings, SettingsError, load_settingsfrom ticket_api.errors import add_error_handlers, error_responsefrom ticket_api.history import History, HistoryUnavailablefrom ticket_api.models import ErrorResponse, Health, Readinessapp = FastAPI( title="Ticket Classifier API", version="1.0.0", version="1.1.0", description=DESCRIPTION, openapi_tags=[ {"name": "tickets", "description": "Classify support tickets."}, {"name": "history", "description": "See recent classifications."}, {"name": "health", "description": "Check that the service runs."}, ],app.state.classifier = make_classifier(settings.classifier_mode)app.state.history = Noneif settings.database_url: app.state.history = History(settings.database_url) try: app.state.history.setup() except HistoryUnavailable: logger.warning("the database does not answer yet: history is not ready")else: logger.info("DATABASE_URL is not set: history is off")def ready() -> Readiness: app.state.classifier.predict("readiness check") if app.state.history is not None: app.state.history.check() return Readiness(status="ready")Without DATABASE_URL, the API works as before, with no history. If the database is not ready when the API starts, the API still starts, and makes the table later, at the first request.
Step 6: the tests
Section titled “Step 6: the tests”Add the file tests/test_history.py. It has 8 tests: 4 for the history and 4 for the settings of the last lesson. FakeHistory keeps the rows in a Python list, so the tests need no database.
Show tests/test_history.py
from datetime import datetime, timezone
import pytest
from ticket_api.config import SettingsError, load_settingsfrom ticket_api.history import HistoryUnavailable
class FakeHistory: """Keeps the history in a list, so the tests need no database."""
def __init__(self, down=False): self.rows = [] self.down = down
def check(self): if self.down: raise HistoryUnavailable("down")
def add(self, request_id, result): self.check() created_at = datetime.now(timezone.utc) self.rows.append({"request_id": request_id, **result, "created_at": created_at})
def recent(self, limit): self.check() return list(reversed(self.rows))[:limit]
@pytest.fixturedef history_client(make_client): def make(down=False): client = make_client() client.app.state.history = FakeHistory(down) return client
return make
def test_history_is_off_without_database_url(client): response = client.get("/v1/history") assert response.status_code == 503 assert response.json()["error"]["code"] == "history_unavailable"
def test_classification_is_recorded_without_the_text(history_client): client = history_client() response = client.post("/v1/classify", json={"subject": "Parcel lost"}) items = client.get("/v1/history").json()["items"] assert len(items) == 1 assert items[0]["category"] == "shipping" assert items[0]["request_id"] == response.headers["X-Request-ID"] assert "subject" not in items[0]
def test_history_limit(history_client): client = history_client() for _ in range(3): client.post("/v1/classify", json={"subject": "Refund"}) assert len(client.get("/v1/history?limit=2").json()["items"]) == 2 assert client.get("/v1/history?limit=0").status_code == 422
def test_database_down_still_classifies_but_is_not_ready(history_client): client = history_client(down=True) assert client.post("/v1/classify", json={"subject": "Refund"}).status_code == 200 assert client.get("/ready").status_code == 503 assert client.get("/v1/history").status_code == 503
def test_api_key_from_a_file(tmp_path, monkeypatch): key_file = tmp_path / "api_key" key_file.write_text("from-a-file\n") monkeypatch.setenv("API_KEY_FILE", str(key_file)) assert load_settings().api_key == "from-a-file"
def test_missing_secret_file_stops_the_app(monkeypatch): monkeypatch.setenv("API_KEY_FILE", "/no/such/file") with pytest.raises(SettingsError): load_settings()
def test_required_api_key(monkeypatch): monkeypatch.delenv("API_KEY", raising=False) monkeypatch.delenv("API_KEY_FILE", raising=False) monkeypatch.setenv("REQUIRE_API_KEY", "true") with pytest.raises(SettingsError): load_settings()
def test_secrets_are_not_in_the_settings_repr(monkeypatch): monkeypatch.setenv("API_KEY", "do-not-print") monkeypatch.setenv("DATABASE_URL", "postgresql://u:do-not-print@db/x") assert "do-not-print" not in repr(load_settings())In the virtual environment, Amira installs the new package, makes the lock file again, and runs the tests:
python -m pip install -r requirements.txtpython -m pip freeze > requirements-lock.txtpython -m pytestThe output (the end):
FAILED tests/test_contract.py::test_openapi_matches_the_reviewed_contract - A...1 failed, 24 passed in 0.24sPredict: why does the contract test fail? The API has a new endpoint and a new version, so the OpenAPI document changed. The change is intended, so she exports the contract again, as in the API course, and runs the tests again:
python -m ticket_api.export_contractpython -m pytestThe output (the end):
Wrote contract/openapi.json...25 passed in 0.30sStep 7: the packages of the image
Section titled “Step 7: the packages of the image”The image installs from requirements-run.txt, so that file must have psycopg too. Make it again in a container, as in Dockerfile essentials: Make requirements-run.txt in a container, now with psycopg in the command:
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" "psycopg[binary]==3.3.6" && pip freeze' > requirements-run.txtCheck the result: wc -l requirements-run.txt and grep psycopg requirements-run.txt (PowerShell: (Get-Content requirements-run.txt).Count and Select-String psycopg requirements-run.txt). The output:
52 requirements-run.txtpsycopg==3.3.6psycopg-binary==3.3.652 lines: the 50 of Module 2, and the two psycopg packages.
Write compose.yaml
Section titled “Write compose.yaml”Now the project needs two containers that work together: the API and PostgreSQL. You could start them with two docker run commands, a network, a volume and many options. Docker Compose does all of that from one file. Compose is a part of Docker that starts a group of containers from a file called compose.yaml. Each container in the file is a service.
First, the database password. It goes into .env, which stays out of Git. A template that has no real secret goes into Git, so that others know which values to set. Make .env.example:
# Copy this file to .env and change the value. Never commit .env.POSTGRES_PASSWORD=change-me-local-onlyThen make .env from it. This replaces the .env of the last lesson: the API’s settings are now in compose.yaml.
cp .env.example .envCopy-Item .env.example .envcp .env.example .envOpen .env and change the password to a new one. The password protects only the database on your computer, but use one that you do not use anywhere else.
Then make compose.yaml in the project folder:
# The ticket API and its database, on your computer.# Start: docker compose up -d --build# Stop: docker compose down (keeps the data)# Reset: docker compose down --volumes (deletes the data)
services: api: build: . image: ticket-api:1.1.0 ports: - "127.0.0.1:8000:8000" environment: API_KEY_FILE: /run/secrets/api_key DATABASE_URL: postgresql://tickets:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/tickets REQUIRE_API_KEY: "true" secrets: - api_key depends_on: db: condition: service_healthy
db: image: postgres:18.6-trixie environment: POSTGRES_USER: tickets POSTGRES_DB: tickets POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} volumes: - pgdata:/var/lib/postgresql healthcheck: test: ["CMD", "pg_isready", "-U", "tickets", "-d", "tickets"] interval: 2s timeout: 3s retries: 15
secrets: api_key: file: ./secrets/api_key.txt
volumes: pgdata:Line by line:
| Lines | Meaning |
|---|---|
build: . and image: ticket-api:1.1.0 |
Build the image from the Dockerfile in this folder, and tag it ticket-api:1.1.0. |
ports: "127.0.0.1:8000:8000" |
The same as -p 127.0.0.1:8000:8000: only this computer can connect. The quotes stop YAML from reading the value as a number. |
environment: |
The API’s settings, as with -e. REQUIRE_API_KEY: "true": here the key is required. |
DATABASE_URL: ...@db:5432/tickets |
The database’s address. The host name is db, the service’s name. Compose puts both services on one network, where each service has its name, as in Ports and service access. |
${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} |
Compose replaces this with the value from .env. :? means required: without the value, Compose stops with that message. |
secrets: - api_key |
Mount the Compose secret api_key in the container, read-only, at /run/secrets/api_key. API_KEY_FILE points there. |
depends_on: db: condition: service_healthy |
Start the API only after the database’s health check passes. |
image: postgres:18.6-trixie |
The official PostgreSQL image, PostgreSQL 18.6 on Debian 13, pinned to an exact tag. |
POSTGRES_USER, POSTGRES_DB, POSTGRES_PASSWORD |
The image makes this user and this database the first time that it starts with an empty volume. |
volumes: - pgdata:/var/lib/postgresql |
Mount the named volume pgdata at /var/lib/postgresql, where PostgreSQL 18 keeps its files. See The PostgreSQL 18 mount path. |
healthcheck: |
A health check is a command that Docker runs in the container again and again, to see if the service works. pg_isready asks PostgreSQL if it accepts connections: every 2 s, at most 15 times. |
secrets: api_key: file: |
The Compose secret comes from ./secrets/api_key.txt, the file of the last lesson. |
volumes: pgdata: |
Declare the named volume. Compose makes it the first time. |
The .env file is for Compose, not for the containers. Compose reads it to fill in ${...} in compose.yaml. The containers get only what environment: lists, so the API container does not get POSTGRES_PASSWORD, only the URL that contains it.
Predict: what happens if .env is missing? Amira tried it before she made the file:
error while interpolating services.api.environment.DATABASE_URL: required variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .envCompose stopped before it made anything.
Start, check and stop
Section titled “Start, check and stop”Text description of the diagram
Your computer is the host. curl or a browser on it calls http://127.0.0.1:8000, the published port. Docker forwards it to port 8000 of the api container. A phone on the Wi-Fi cannot connect, because the port is published only on 127.0.0.1. The api container (ticket-api:1.1.0, listening on 0.0.0.0:8000) and the db container (postgres:18.6-trixie) are on the Compose network ticket-api_default. The API calls the database at db:5432. Port 5432 is not published, so only containers on this network can reach it. Two things are outside the containers: the host file secrets/api_key.txt, mounted read-only in api at /run/secrets/api_key, and the named volume ticket-api_pgdata, mounted in db at /var/lib/postgresql. Containers are replaced. The volume and the file on the host stay.
Amira starts both services. --build builds the API’s image first:
docker compose up -d --buildThe output (the build lines shortened):
Image ticket-api:1.1.0 Building... Image ticket-api:1.1.0 Built Network ticket-api_default Creating Network ticket-api_default Created Volume ticket-api_pgdata Creating Volume ticket-api_pgdata Created Container ticket-api-db-1 Creating Container ticket-api-db-1 Created Container ticket-api-api-1 Creating Container ticket-api-api-1 Created Container ticket-api-db-1 Starting Container ticket-api-db-1 Started Container ticket-api-db-1 Waiting Container ticket-api-db-1 Healthy Container ticket-api-api-1 Starting Container ticket-api-api-1 StartedThe first time, Compose also downloads postgres:18.6-trixie. The names come from the project name, which is the folder’s name: ticket-api. So the network is ticket-api_default, the volume is ticket-api_pgdata, and the containers are ticket-api-db-1 and ticket-api-api-1. Look at the order: the database first, then Waiting until it is Healthy, then the API.
Text description of the diagram
You run docker compose up -d --build. Compose builds ticket-api:1.1.0 and makes the network and the volume. It starts db with the volume at /var/lib/postgresql. Then, every 2 seconds and at most 15 times, it runs the health check pg_isready in db, which answers “not ready” and then “ready”. When db is healthy, Compose starts api, with the secret at /run/secrets/api_key. The API connects to db:5432 and makes the table. Compose prints that ticket-api-api-1 started. Your curl 127.0.0.1:8000/ready gets {"status":"ready"}.
docker compose psThe output:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTSticket-api-api-1 ticket-api:1.1.0 "fastapi run --host …" api 3 seconds ago Up Less than a second 127.0.0.1:8000->8000/tcpticket-api-db-1 postgres:18.6-trixie "docker-entrypoint.s…" db 3 seconds ago Up 2 seconds (healthy) 5432/tcpThe database’s port 5432 is not published: only the API, on the same network, can reach it. docker compose logs api shows the API’s log, with the same start lines as before. She checks readiness and sends two tickets, then reads the history:
curl -s http://127.0.0.1:8000/readycurl -s -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 -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Parcel lost"}'curl -s http://127.0.0.1:8000/v1/history -H "X-API-Key: local-dev-key"The output (one line per command; the last one is long):
{"status":"ready"}{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}{"category":"shipping","priority":1,"confidence":0.7,"model_version":"keywords-1.0"}{"items":[{"request_id":"1fe5dfeafae4","category":"shipping","priority":1,"confidence":0.7,"model_version":"keywords-1.0","created_at":"2026-10-07T16:58:28.691060Z"},{"request_id":"d2ef963e6b97","category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0","created_at":"2026-10-07T16:58:28.663288Z"}]}The newest first, with the time in UTC, and no ticket text. PowerShell can change the quotes in -d '{"subject":"Parcel lost"}' before curl.exe gets them. If you get 422, save the JSON in a file outside the project, for example $HOME\parcel.json, and use -d "@$HOME\parcel.json". (Not tested on Windows.)
To look into the database, she runs psql, PostgreSQL’s command-line client, in the db container. docker compose exec is docker exec with the service’s name:
docker compose exec db psql -U tickets -d tickets -c "SELECT id, request_id, category, priority, created_at FROM classifications;"The output:
id | request_id | category | priority | created_at----+--------------+----------+----------+------------------------------- 1 | d2ef963e6b97 | billing | 1 | 2026-10-07 16:58:28.663288+00 2 | 1fe5dfeafae4 | shipping | 1 | 2026-10-07 16:58:28.69106+00(2 rows)The same two rows, in the order they were written. The SELECT asks for five columns of every row in the table classifications.
Last, what Docker keeps about the API container:
docker inspect ticket-api-api-1 --format "{{json .Config.Env}}"The output (shortened):
["REQUIRE_API_KEY=true","API_KEY_FILE=/run/secrets/api_key","DATABASE_URL=postgresql://tickets:change-me-local-only@db:5432/tickets",...]The key is a file, as planned. But the database password is in DATABASE_URL, in plain text. Here it is a local password that protects only a database without a published port. In the cloud, the password would be a platform secret, or the app would use an identity instead of a password (Module 4). The API can also read the URL from a file with DATABASE_URL_FILE, in the same way as API_KEY_FILE.
To stop everything:
docker compose downdocker volume ls --filter name=ticket-apiThe output:
Container ticket-api-api-1 Stopping Container ticket-api-api-1 Stopped Container ticket-api-api-1 Removing Container ticket-api-api-1 Removed Container ticket-api-db-1 Stopping Container ticket-api-db-1 Stopped Container ticket-api-db-1 Removing Container ticket-api-db-1 Removed Network ticket-api_default Removing Network ticket-api_default RemovedDRIVER VOLUME NAMElocal ticket-api_pgdatadown removes the containers and the network, but not the volume. The next docker compose up -d makes new containers, and the history is still there.
The PostgreSQL 18 mount path
Section titled “The PostgreSQL 18 mount path”Many older tutorials mount PostgreSQL’s volume at /var/lib/postgresql/data. Since PostgreSQL 18, the official image keeps its files in /var/lib/postgresql/18/docker, a folder for each major version. A later upgrade can then keep the old and the new files side by side. The image declares the folder above them, /var/lib/postgresql, as its volume:
docker image inspect postgres:18.6-trixie --format "{{json .Config.Volumes}}"docker compose exec db ls /var/lib/postgresql/18The output:
{"/var/lib/postgresql":{}}dockerPredict: what happens with the old path, pgdata:/var/lib/postgresql/data? Amira tried it in a copy of compose.yaml, with a new, empty volume:
Container ticket-api-db-1 Waiting Container ticket-api-db-1 Error dependency db failed to startdependency failed to start: container ticket-api-db-1 exited (1)The database container stopped, and the API was never started (docker compose ps -a shows it as Created). The database’s log:
db-1 | Error: in 18+, these Docker images are configured to store database data in adb-1 | format which is compatible with "pg_ctlcluster" (specifically, usingdb-1 | major-version-specific directory names). This better reflects howdb-1 | PostgreSQL itself works, and how upgrades are to be performed....db-1 | Counter to that, there appears to be PostgreSQL data in:db-1 | /var/lib/postgresql/data (unused mount/volume)...db-1 | The suggested container configuration for 18+ is to place a single mountdb-1 | at /var/lib/postgresql which will then place PostgreSQL data in adb-1 | subdirectory, allowing usage of "pg_upgrade --link" without mount pointdb-1 | boundary issues.The message talks about old data, but the volume was empty: the image refuses any mount at the old path. Mount the volume at /var/lib/postgresql, as in this course’s compose.yaml. The image fails loudly, which is good: a database that started and wrote its files outside the volume would lose them without a message.
Guided practice: the API and its database
Section titled “Guided practice: the API and its database”- Do steps 1 to 7 of Add the history. Expect
25 passedand 52 lines inrequirements-run.txt. - Make
.env.example,.envandcompose.yamlas in Write compose.yaml. Change the password in.env. - Run
git status. Check that.envandsecrets/are not in the list, and that.env.exampleandcompose.yamlare. - Start the services:
docker compose up -d --build. - Run
docker compose ps. Wait untildbshows(healthy)andapishowsUp. - Call
/ready. Expect{"status":"ready"}. If curl shows(52) Empty reply from server, the API is still starting: wait a few seconds. - Send two tickets to
/v1/classifywith the key, then call/v1/historywith the key. Expect two items, the newest first. - Run the
psqlquery in thedbservice. Expect the same two rows. - Replace the project’s
README.mdwith the version below. - Leave the services running for the module practice.
Show README.md
# ticket-api
A small web API that classifies support tickets. It runs in a container, alone or with a PostgreSQL database that keeps a history of the classifications.
## Run it in a container
You need Docker. The API listens on port 8000 in the container.
```shdocker build -t ticket-api:1.1.0 .docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.1.0```
Check it: `curl http://127.0.0.1:8000/health` gives `{"status":"ok"}`.
## Run it with the database
```shcp .env.example .env # then change the password in .envmkdir -p secretsprintf 'local-dev-key' > secrets/api_key.txtdocker compose up -d --build```
- `docker compose down` stops and removes the containers. The data stays in the volume `ticket-api_pgdata`.- `docker compose down --volumes` also deletes the data.
## Settings
| Setting | Required | Default | What it does ||---|---|---|---|| `API_KEY` or `API_KEY_FILE` | When `REQUIRE_API_KEY` is `true` | none | The key that clients send in `X-API-Key`. `API_KEY_FILE` is the path of a file that holds it. || `REQUIRE_API_KEY` | No | `false` | `true`: the API does not start without a key. || `DATABASE_URL` or `DATABASE_URL_FILE` | No | none | A PostgreSQL URL. Without it, history is off. || `ALLOWED_ORIGINS` | No | none | Sites that may call the API from a browser, separated by commas. || `SHOW_DOCS` | No | `true` | `false` hides `/docs`, `/redoc` and `/openapi.json`. || `CLASSIFIER_MODE`, `CLASSIFIER_TIMEOUT`, `LOG_LEVEL` | No | `keywords`, `2.0`, `INFO` | As in the API course. |
Never put a key or a password in the Dockerfile, in `compose.yaml` or in Git. `.env` and `secrets/` are in `.gitignore` and `.dockerignore`.
## Work on the code
```shpython3 -m venv .venv # Windows: python -m venv .venvsource .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1python -m pip install -r requirements-lock.txtpython -m pytest```
`requirements-run.txt` is the exact list of packages in the image. Make it again inside a container, so that it is the same on every computer:
```shdocker 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" "psycopg[binary]==3.3.6" && pip freeze' > requirements-run.txt```Your turn: the database stops
Section titled “Your turn: the database stops”Change one condition: stop only the database, with docker compose stop db. Before you test, predict the status code of /health, /ready and POST /v1/classify (with the key). Then test, read the last lines of docker compose logs api, and start the database again with docker compose start db.
Show the answer
{"status":"ok"}200{"error":{"code":"history_unavailable","message":"The history is not available. Try again later.","request_id":"57da74011ff3","fields":[]}}503{"category":"billing","priority":1,"confidence":0.7,"model_version":"keywords-1.0"}200These are /health, /ready and a classification of {"subject":"Charged twice for March"}, each with -w "\n%{http_code}\n". The process runs (/health 200). It is not ready, because the history does not answer (/ready 503). It still classifies (200). The log shows the side effect:
WARNING ticket_api: history unavailable: the database does not answer id=57da74011ff3INFO ticket_api: GET /ready 503 18ms id=57da74011ff3...WARNING ticket_api: history not saved id=5ecc59a5fbbfINFO ticket_api: POST /v1/classify 200 8ms id=5ecc59a5fbbfAfter docker compose start db, /ready gives {"status":"ready"} again. The classification that was made during the outage is not in the history: the API does not keep it for later. This is why /ready must show the problem. In Module 5, the cloud platform reads /ready and sends no traffic to a copy that is not ready, but it does not restart it.
Common mistake
The history is empty after docker compose down and up
Symptom: the history worked, and it even survived a restart of the database container. After docker compose down and docker compose up -d, /v1/history gives {"items":[]}. docker volume ls shows a volume with a long name of numbers and letters, and no ticket-api_pgdata.
Cause: the data was never in a named volume. In this test, Amira removed the two volumes: parts from compose.yaml. The PostgreSQL image declares /var/lib/postgresql as a volume, so Docker made an anonymous volume: a volume without a name, for that one container. docker compose up -d --force-recreate db reuses it, so the data seemed safe. docker compose down removes the container, and the next up makes a new container with a new anonymous volume. The old one stays on your computer, unused. With no volume at all, the data goes even earlier. Files in a container’s writable layer go with the container.
Fix:
- Give every piece of data that must survive a named volume, in both places:
- pgdata:/var/lib/postgresqlunder the service, andpgdata:undervolumes:at the end of the file. - Check after
docker compose up -d:docker volume ls --filter name=ticket-apimust showticket-api_pgdata. - Find the old anonymous volume with
docker inspect <container> --format "{{json .Mounts}}"before you remove the container. Remove it withdocker volume rm <its name>. Remove only volumes that you know are yours.
For a real AI service: the same mistake loses more than a history. Think of a service that writes feedback, labels or a vector index into its container’s files. It works perfectly in a demonstration, and loses everything at the first update. Decide for each file: disposable, a volume on one computer, or a database or object storage that outlives every container.
Check your understanding
Which commands remove the containers, and which command also deletes the history?
Show the answer
docker compose down stops and removes the containers and the network. The named volume ticket-api_pgdata stays, so the history survives. docker compose down --volumes also removes the volume, and with it the history. docker compose up -d --force-recreate api replaces only the API’s container. Neither the volume nor the database is touched.
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 does DATABASE_URL use the host name db, and not localhost or 127.0.0.1?
Show the answer
The URL is used inside the API container. There, localhost and 127.0.0.1 mean the API container itself, which has no database. Compose puts both services on the network ticket-api_default, where each service can be found by its name, so db:5432 reaches the database container. Port 5432 is not published, so programs outside the network cannot reach the database.
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.
Module practice
Section titled “Module practice”Worked example. Amira has the API and its database running from the guided practice. She proves which data survives when the API’s container is replaced:
docker compose exec api sh -c "echo hello > /tmp/note.txt"docker compose exec api cat /tmp/note.txtdocker compose up -d --force-recreate apidocker compose exec api cat /tmp/note.txtcurl -s http://127.0.0.1:8000/v1/history -H "X-API-Key: local-dev-key"The output (the history shortened):
hello Container ticket-api-db-1 Running Container ticket-api-api-1 Recreate Container ticket-api-api-1 Recreated Container ticket-api-db-1 Waiting Container ticket-api-db-1 Healthy Container ticket-api-api-1 Starting Container ticket-api-api-1 Startedcat: /tmp/note.txt: No such file or directory{"items":[{"request_id":"1fe5dfeafae4","category":"shipping",...},{"request_id":"d2ef963e6b97","category":"billing",...}]}--force-recreate api makes a new API container even though nothing changed. The file in the old container is gone. The two classifications are still there, because they are in the database, in the volume. She does the same with docker compose down, then docker compose up -d: still two items. Then, on purpose, she deletes the data:
docker compose down --volumesdocker compose up -dcurl -s http://127.0.0.1:8000/v1/history -H "X-API-Key: local-dev-key"The output (the last lines of down, then the new history):
Volume ticket-api_pgdata Removing Network ticket-api_default Removing Volume ticket-api_pgdata Removed Network ticket-api_default Removed...{"items":[]}Your task. Run the API with its dependency, and recreate both containers. Then show with evidence which data survived:
- Start the services, and send three tickets with different subjects.
- Write a file in the API container.
- Recreate both containers with
docker compose up -d --force-recreate api db. - Show that the file is gone, that the history still has three items, and that the database has three rows (
SELECT count(*) FROM classifications;). - Write two sentences: which state is disposable, which state survives, and why.
Show a model answer
docker compose up -dcurl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Refund for order 1182"}'curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Cannot log in"}'curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Parcel lost"}'docker compose exec api sh -c "echo hello > /tmp/note.txt"docker compose up -d --force-recreate api dbdocker compose exec api cat /tmp/note.txtcurl -s "http://127.0.0.1:8000/v1/history?limit=5" -H "X-API-Key: local-dev-key"docker compose exec db psql -U tickets -d tickets -c "SELECT count(*) FROM classifications;"The results in the test run: the three tickets gave billing (priority 2), login and shipping. Both containers were recreated (Recreate, Recreated, then Healthy for db). cat: /tmp/note.txt: No such file or directory. The history had three items, shipping first. The count was:
count------- 3(1 row)The file was in the API container’s writable layer, which is deleted with the container: disposable. The classifications were in PostgreSQL’s files, in the named volume ticket-api_pgdata, which is outside every container: they survive until docker compose down --volumes.
Module check
Section titled “Module check”Check your understanding
Name the -p option that lets only your own computer reach port 8000 of a container, and the setting that makes the ticket API refuse to start without a key.
Show the answer
-p 127.0.0.1:8000:8000 (in Compose: "127.0.0.1:8000:8000" under ports:). REQUIRE_API_KEY=true: with it, a missing key gives Settings error: REQUIRE_API_KEY is true, but API_KEY is not set and exit code 1.
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
You run docker compose down, then docker compose up -d. Predict what is still there: the file /tmp/note.txt in the API container, the history, the network, the containers.
Show the answer
The history is still there: it is in the named volume ticket-api_pgdata, which down does not remove. The file is gone, because it was in the old container’s writable layer. The network and the containers are new: down removed them, and up made them again with the same names. Only down --volumes deletes the history.
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: confirm, with commands and their results, that the required data survives while the disposable container state does not.
Show the answer
Evidence for a reviewer, from the module practice:
- Before:
/v1/history(with the key) lists the classifications, anddocker compose exec api cat /tmp/note.txtprintshello. docker compose up -d --force-recreate api dbreplaces both containers.- After:
cat /tmp/note.txtgivesNo such file or directory(disposable state is gone)./v1/historyhas the same items, andSELECT count(*)gives the same count (required data survived). docker volume ls --filter name=ticket-apishowsticket-api_pgdata: the place where the data survives.- The explanation: the writable layer belongs to one container, and the volume belongs to no container.
Demonstrated: all five, with real output. Partially demonstrated: the commands without the before-and-after outputs, or no explanation.
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:
- published the API’s port on
127.0.0.1only, found the0.0.0.0bind address problem, and let containers find each other by name on a network; - gave one image different settings at start, kept the key in a read-only secret file (
API_KEY_FILE), and made a missing key stop the app (REQUIRE_API_KEY); - added the history in PostgreSQL (version 1.1.0, 25 tests), and ran the API and its database with Compose. The data is in the named volume
ticket-api_pgdata; - proved that the history survives new containers, and that files in a container do not.
Reset. To stop everything and delete the practice data:
docker compose down --volumesdocker image rm ticket-api:1.1.0-devdocker volume lsdocker system dfKeep ticket-api:1.1.0: Module 5 uses it. Check in docker volume ls that ticket-api_pgdata is gone. If your project is broken, copy the snapshot end-of-m03, then make .env and secrets/api_key.txt again, as its README.md says.
Next, you leave your computer: the building blocks that every cloud offers, and how the API’s needs map to them on Azure.