Runtime configuration
- Lesson 8
- 45 min
- Module 3: Networking, configuration and persistence
Lesson 8 of 15 · Docker and Cloud Essentials for AI DevelopersRuntime configuration
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
Run one image in many places, each with its own settings and secrets, given when the container starts. Keep the API key out of the container’s configuration, and make the API refuse to start without a key that it needs.
Before you startWhat to know and to have ready
What you need to know
- Settings from environment variables in
config.py, from Configuration and logging: One place for every setting. - The API key in
X-API-Key, from Basic protection: The API key. - Published ports, from Ports and service access: Publish a port.
- Why credentials never go into an image, from Runtime safety: Keep credentials out of the image.
What you need on your computer
- The project
~/projects/ticket-apiwith itsDockerfileand.dockerignore, from Runtime safety: Module practice. - 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. If it is missing, make it as the project’sREADME.mdsays. - If your project is missing or broken, copy the snapshot
end-of-m02, as Use a snapshot says.
Tested with Docker Desktop 4.81.0 (Docker Engine 29.6.1) on macOS, Apple silicon. The Windows and Linux steps are not tested.
One image, different settings
Section titled “One image, different settings”Amira starts two containers from the same image. Only one setting is different:
docker run -d --name docs-on -p 127.0.0.1:8000:8000 ticket-api:1.0.0docker run -d --name docs-off -p 127.0.0.1:8001:8000 -e SHOW_DOCS=false ticket-api:1.0.0curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/docscurl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8001/docsThe output, after the two container IDs:
200404-w "%{http_code}\n" tells curl to print only the status code. Same image, same code, two behaviours. -e NAME=value sets an environment variable in the container: a named value that the operating system gives to a program when it starts. The app reads it in config.py, as in the API course.
This is the main reason to keep settings out of the image. You build one image, test it, and then run that exact image on your computer, in Compose and in the cloud. Only the settings change. If a setting were inside the image, each place would need its own build, and you would deploy something that you did not test.
docker rm -f docs-on docs-offRequired and optional settings
Section titled “Required and optional settings”The two containers above had no API key. After the start lines, the log of docs-on (docker logs docs-on) had a warning:
WARNING ticket_api: API_KEY is not set: /v1/classify accepts requests without a keyOn your computer, that is useful. In the cloud, it is a risk: a missing secret makes an open API, and the only sign is one log line. A setting can be:
- optional: the app has a safe default, for example
SHOW_DOCSistrue,LOG_LEVELisINFO; - required: the app must not start without it.
The key is optional on your computer and required in the cloud. So this lesson adds a setting that says which one it is: REQUIRE_API_KEY. With REQUIRE_API_KEY=true, the API stops at start if it has no key. A deployment with a missing secret then fails, where you can see it, instead of running without protection.
The settings of the API at the end of this module:
| 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 (next lesson). |
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. |
Change config.py and main.py
Section titled “Change config.py and main.py”Two files change. In ticket_api/config.py, the new lines start with +, and the removed lines start with -:
import osfrom dataclasses import dataclass, fieldfrom pathlib import Path
class SettingsError(Exception): """A required setting is missing or not valid. The app must not start."""
@dataclass(frozen=True)class Settings: api_key: str | None = field(repr=False) allowed_origins: list[str] classifier_mode: str classifier_timeout: float log_level: str show_docs: bool database_url: str | None = field(default=None, repr=False)
def read_secret(name: str) -> str | None: """Return the value of NAME, or the contents of the file in NAME_FILE.""" path = os.environ.get(f"{name}_FILE") if path: try: return Path(path).read_text(encoding="utf-8").strip() or None except OSError as error: raise SettingsError(f"{name}_FILE: cannot read {path}: {error.strerror}") return os.environ.get(name) or None
def load_settings() -> Settings: """Read the settings from environment variables, with safe defaults.""" origins = os.environ.get("ALLOWED_ORIGINS", "") return Settings( api_key=os.environ.get("API_KEY") or None, settings = Settings( api_key=read_secret("API_KEY"), allowed_origins=[o.strip() for o in origins.split(",") if o.strip()], classifier_mode=os.environ.get("CLASSIFIER_MODE", "keywords"), classifier_timeout=float(os.environ.get("CLASSIFIER_TIMEOUT", "2.0")), log_level=os.environ.get("LOG_LEVEL", "INFO").upper(), show_docs=os.environ.get("SHOW_DOCS", "true").lower() == "true", database_url=read_secret("DATABASE_URL"), ) if os.environ.get("REQUIRE_API_KEY", "false").lower() == "true" and not settings.api_key: raise SettingsError("REQUIRE_API_KEY is true, but API_KEY is not set") return settings| Part | Why |
|---|---|
SettingsError |
One kind of error for “the settings are wrong”. main.py catches it and stops with a clear message. |
read_secret("API_KEY") |
If API_KEY_FILE is set, it reads the key from that file. If not, it uses API_KEY, as before. .strip() removes a line break at the end of the file. |
| A file that cannot be read | Raises SettingsError with the path and the reason. A wrong path must stop the app, not give it no key. |
database_url |
For the next lesson. It is read the same way, so a password in the URL can also come from a file. |
repr=False |
The key and the URL do not appear when the settings are printed or logged. |
REQUIRE_API_KEY |
The check at the end: true and no key means SettingsError. |
In ticket_api/main.py, import the error, and catch it where the app is made:
from ticket_api.config import Settings, load_settingsfrom ticket_api.config import Settings, SettingsError, load_settingsapp = create_app()try: app = create_app()except SettingsError as error: raise SystemExit(f"Settings error: {error}") from NoneSystemExit stops Python with exit code 1 and prints only the message. from None hides the traceback, which would not help the reader.
Amira runs the tests in her virtual environment, with python -m pytest. The output ends with:
17 passed in 0.19sNo test changed yet. The next lesson adds the tests for these settings.
Then she builds a new image. The code is not finished for version 1.1.0, so she tags it 1.1.0-dev:
docker build -t ticket-api:1.1.0-dev .The output (shortened):
#9 [5/7] RUN pip install -r requirements-run.txt#9 CACHED
#10 [6/7] COPY pyproject.toml .#10 CACHED
#11 [7/7] COPY ticket_api ./ticket_api#11 DONE 0.0s...#12 naming to docker.io/library/ticket-api:1.1.0-dev doneOnly the code layer is new, as in Build efficiently: The layer cache.
A file of settings
Section titled “A file of settings”Many -e options make a long command. --env-file reads them from a file instead: one NAME=value per line. Amira makes the file .env in the project folder:
# Settings for the container on this computer. Never commit this file.SHOW_DOCS=falseREQUIRE_API_KEY=trueLOG_LEVEL=INFO.env is in .gitignore, so Git never sends it. The allowlist .dockerignore keeps it out of the image too.
Predict: this file says that a key is required, and it has no key. What happens?
docker run --rm --env-file .env ticket-api:1.1.0-devThe output:
⚡️ Starting FastAPI in production mode
🐍 Using import string: ticket_api.main:app
🌐 Server started at http://0.0.0.0:8000 Documentation at http://0.0.0.0:8000/docs
Logs:
Settings error: REQUIRE_API_KEY is true, but API_KEY is not setThe container stops at once, with exit code 1. Read the whole output: Server started comes before the error. The fastapi command prints that line before it imports the app, so it does not mean that the app started.
Secret files
Section titled “Secret files”The key could go into .env too. But first, look at what Docker keeps about a container that gets its key with -e:
docker run -d --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key -e SHOW_DOCS=false ticket-api:1.1.0-devdocker inspect ticket-api --format "{{json .Config.Env}}"The output (after the container ID):
["API_KEY=local-dev-key","SHOW_DOCS=false","PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","PYTHON_VERSION=3.14.8",...]The key is there in plain text. Anyone who can run docker inspect on this computer can read it. Environment variables also appear in other places: crash reports, docker exec ... printenv, and some monitoring tools. A secret is a value that gives access, such as a key or a password. It needs more care than a setting.
docker rm -f ticket-apiA secret file keeps the secret out of the container’s configuration. The secret stays in a file on your computer. You mount the file into the container, read-only, and the app reads it from there. Mounting a host file or folder into a container is called a bind mount: the container sees the host’s file, not a copy.
-
Make a folder
secretsand the key file:Terminal window mkdir -p secretsprintf 'local-dev-key' > secrets/api_key.txtTerminal window New-Item -ItemType Directory -Force secretsSet-Content -NoNewline -Encoding ascii secrets\api_key.txt 'local-dev-key'Terminal window mkdir -p secretsprintf 'local-dev-key' > secrets/api_key.txt -
Open
.gitignore. Check that it has the linesecrets/. If the line is missing, add it. -
Run
git status. Check thatsecrets/and.envare not in the list.
Amira starts the API with the settings file, the key file and one more setting:
docker run -d --name ticket-api -p 127.0.0.1:8000:8000 --env-file .env -e API_KEY_FILE=/run/secrets/api_key -v "$PWD/secrets/api_key.txt:/run/secrets/api_key:ro" ticket-api:1.1.0-devdocker run -d --name ticket-api -p 127.0.0.1:8000:8000 --env-file .env -e API_KEY_FILE=/run/secrets/api_key -v "${PWD}/secrets/api_key.txt:/run/secrets/api_key:ro" ticket-api:1.1.0-devdocker run -d --name ticket-api -p 127.0.0.1:8000:8000 --env-file .env -e API_KEY_FILE=/run/secrets/api_key -v "$PWD/secrets/api_key.txt:/run/secrets/api_key:ro" ticket-api:1.1.0-dev| Part | Meaning |
|---|---|
-v "$PWD/secrets/api_key.txt:/run/secrets/api_key:ro" |
Mount the host file (left, a full path: $PWD is the current folder) at /run/secrets/api_key in the container (right). ro means read-only. |
-e API_KEY_FILE=/run/secrets/api_key |
Tells read_secret where the key is. This is a path, not a secret. |
/run/secrets/ |
The usual folder for secret files in a container. Compose uses it too (next lesson). |
She checks the API, without and with the key:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -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 @requests/billing.jsonThe output:
401{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}Then she looks again at what Docker keeps:
docker inspect ticket-api --format "{{json .Config.Env}}"The output:
["SHOW_DOCS=false","REQUIRE_API_KEY=true","LOG_LEVEL=INFO","API_KEY_FILE=/run/secrets/api_key","PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",...]The configuration has the path of the key, not the key. The file is read-only in the container. The app cannot change it, and neither can an attacker who gets into the app:
docker exec ticket-api sh -c "echo changed > /run/secrets/api_key"The output:
sh: 1: cannot create /run/secrets/api_key: Read-only file systemA secret file is not encrypted. Anyone who can read the file on your computer, or run docker exec in the container, can still read the key. It removes the key from the places that are easy to leak: the configuration, docker inspect, the image and Git.
docker rm -f ticket-apiText description of the diagram
Settings reach the container in three ways. -e NAME=value sets one environment variable. --env-file .env sets many, from a file of NAME=value lines. -v ...:/run/secrets/api_key:ro mounts a read-only secret file. In the container, config.py reads the environment variables (SHOW_DOCS, REQUIRE_API_KEY, API_KEY_FILE) and, through read_secret, the file. If every required value is there, the app starts. If a required value is missing, the app stops with Settings error: ... and exit code 1.
One image, many environments
Section titled “One image, many environments”The image ticket-api:1.1.0 that you build in the next lesson runs in three places in this course. Only the settings change:
| Where | How it gets its settings | The key |
|---|---|---|
| Your computer, alone | -e, --env-file .env |
-e API_KEY=... or a secret file |
| Your computer, with a database (next lesson) | compose.yaml and .env |
a Compose secret from secrets/api_key.txt |
| Azure Container Apps (Module 5) | the platform’s environment variables | a platform secret (secretref:) |
Guided practice: settings and a secret file
Section titled “Guided practice: settings and a secret file”- Change
ticket_api/config.pyandticket_api/main.pyas in Change config.py and main.py. - Run
python -m pytestin your virtual environment. Expect17 passed. - Build the image:
docker build -t ticket-api:1.1.0-dev . - Make
.envas in A file of settings. - Run
docker run --rm --env-file .env ticket-api:1.1.0-dev. ExpectSettings error: REQUIRE_API_KEY is true, but API_KEY is not set. - Make
secrets/api_key.txt, and check.gitignore, as in the steps of Secret files. - Start the API with
--env-file,API_KEY_FILEand the read-only mount, as in Secret files. - Send
requests/billing.jsonto/v1/classifywithout the key. Expect401. - Send it with
X-API-Key: local-dev-key. Expect the billing result. - Run
docker inspect ticket-api --format "{{json .Config.Env}}". Check that the key is not in the output. - Remove the container:
docker rm -f ticket-api.
Show the expected results
Step 5: the start lines, then Settings error: REQUIRE_API_KEY is true, but API_KEY is not set, and the command ends. Step 8: 401. Step 9: {"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}. Step 10: API_KEY_FILE=/run/secrets/api_key is in the list, and local-dev-key is not. If step 7 stops with Settings error: API_KEY_FILE: cannot read /run/secrets/api_key: No such file or directory, the -v part is missing or has a typing error.
Your turn: change the key
Section titled “Your turn: change the key”Change one condition: the key. Start the API as in step 7. Then write a new key into the file, without stopping the container: printf 'second-key' > secrets/api_key.txt (Windows: Set-Content -NoNewline -Encoding ascii secrets\api_key.txt 'second-key').
Predict, then test with curl: which key does the API accept now? And after docker restart ticket-api? At the end, write local-dev-key back into the file, and remove the container.
Show the answer
docker exec ticket-api cat /run/secrets/api_key shows second-key at once, because the container sees the host’s file. But the API still accepts only local-dev-key: second-key gets 401. config.py reads the key once, when the app starts. After docker restart ticket-api, second-key gets 200, and local-dev-key gets 401. This is why cloud platforms start a new copy of the app when you change a secret (Module 5).
Common mistake
The container stops right after 'Server started'
Symptom: docker run -d prints an ID, but the API does not answer. docker ps does not list the container. docker ps -a shows it as Exited (1). The log has Server started at http://0.0.0.0:8000, so the app seems to start.
Cause: a required setting is missing or wrong, and the app stopped. The fastapi command prints Server started before it imports the app, so that line comes first. The real message is at the end of the log.
Fix:
- Read the end of the log:
docker logs ticket-api. Look forSettings error:. REQUIRE_API_KEY is true, but API_KEY is not set: give the key, with-e API_KEY=...or withAPI_KEY_FILEand a mount. Check also the name: with a typing error such asAPI_KEY_FLE, the app does not see the variable, and you get this same message.API_KEY_FILE: cannot read /run/secrets/api_key: No such file or directory: the path inAPI_KEY_FILEand the right side of-vmust be the same. Check that the host file exists.- Remove the stopped container,
docker rm ticket-api, and start it again.
For a real AI service: a service that calls a model provider needs that provider’s key. Without a check at start, it starts, passes a simple health check, and fails on the first real request of a user. With a required setting, the deployment fails at once, and the platform keeps the old version running.
Check your understanding
Why does the course give the API key as a file with API_KEY_FILE, and not with -e API_KEY=...?
Show the answer
Docker keeps every environment variable in the container’s configuration. docker inspect shows -e API_KEY=local-dev-key in plain text, and environment variables can also appear in crash reports and in printenv. With a secret file, the configuration has only the path (API_KEY_FILE=/run/secrets/api_key). The file is mounted read-only, so the app cannot change it. The key is still readable by whoever can read the file, so it is safer, not secret from everyone.
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
A container starts with REQUIRE_API_KEY=true and API_KEY_FILE=/run/secrets/api_key, but with no -v option. Predict what happens.
Show the answer
The app stops at start with Settings error: API_KEY_FILE: cannot read /run/secrets/api_key: No such file or directory, and the container exits with code 1. read_secret raises SettingsError when it cannot read the file. It does not fall back to “no key”, because that would start an API without protection.
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
Which settings of the ticket API are required, and which are optional? Why is the API key optional by default?
Show the answer
Only the key is required, and only when REQUIRE_API_KEY is true. Every other setting has a safe default (SHOW_DOCS=true, LOG_LEVEL=INFO, no database, no allowed origins). The key is optional by default so that the API runs on your computer and in the tests without setup. Where it must be protected, in Compose and in the cloud, REQUIRE_API_KEY=true makes it required.
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
- Settings belong to the container, not to the image: one tested image runs everywhere with different settings.
-e NAME=valuesets one environment variable.--env-file .envsets many, with no quotes..envstays out of Git and out of the image.docker inspectshows every environment variable. A secret goes into a read-only file, mounted with-v ...:ro, and the app reads the path fromAPI_KEY_FILE.REQUIRE_API_KEY=truemakes a missing key stop the app withSettings error: ...and exit code 1.Server startedcomes before that message.
Next, you add a database that keeps a history of the classifications, and you run the API and the database together with Docker Compose.
Next: Persistent data