Skip to content

Runtime configuration

  • Lesson 8
  • 45 min
  • Module 3: Networking, configuration and persistence
Lesson 8 of 15 · Docker and Cloud Essentials for AI DevelopersRuntime configuration

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 readyAbout 45 minutes

What you need to know

What you need on your computer

Tested with Docker Desktop 4.81.0 (Docker Engine 29.6.1) on macOS, Apple silicon. The Windows and Linux steps are not tested.

Amira starts two containers from the same image. Only one setting is different:

Terminal
docker run -d --name docs-on -p 127.0.0.1:8000:8000 ticket-api:1.0.0
docker run -d --name docs-off -p 127.0.0.1:8001:8000 -e SHOW_DOCS=false ticket-api:1.0.0
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/docs
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8001/docs

The output, after the two container IDs:

200
404

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

Terminal
docker rm -f docs-on docs-off

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 key

On 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_DOCS is true, LOG_LEVEL is INFO;
  • 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.

Two files change. In ticket_api/config.py, the new lines start with +, and the removed lines start with -:

ticket_api/config.py
import os
from dataclasses import dataclass, field
from 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:

ticket_api/main.py
from ticket_api.config import Settings, load_settings
from ticket_api.config import Settings, SettingsError, load_settings
ticket_api/main.py (the last lines)
app = create_app()
try:
app = create_app()
except SettingsError as error:
raise SystemExit(f"Settings error: {error}") from None

SystemExit 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.19s

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

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

Only the code layer is new, as in Build efficiently: The layer cache.

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:

.env
# Settings for the container on this computer. Never commit this file.
SHOW_DOCS=false
REQUIRE_API_KEY=true
LOG_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?

Terminal
docker run --rm --env-file .env ticket-api:1.1.0-dev

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

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

The key could go into .env too. But first, look at what Docker keeps about a container that gets its key with -e:

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

Terminal
docker rm -f ticket-api

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

  1. Make a folder secrets and the key file:

    Terminal window
    mkdir -p secrets
    printf 'local-dev-key' > secrets/api_key.txt
  2. Open .gitignore. Check that it has the line secrets/. If the line is missing, add it.

  3. Run git status. Check that secrets/ and .env are not in the list.

Amira starts the API with the settings file, the key file and one more setting:

Terminal window
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-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:

Terminal
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.json
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 @requests/billing.json

The output:

401
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

Then she looks again at what Docker keeps:

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

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

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

Terminal
docker rm -f ticket-api
Three docker run options on the left: -e NAME=value for one setting, --env-file .env for a file of NAME=value lines, and -v with a path ending in :/run/secrets/api_key:ro for a read-only secret file. The first two become environment variables in the container (SHOW_DOCS, REQUIRE_API_KEY, API_KEY_FILE). The third becomes the file /run/secrets/api_key. Both go to config.py, load_settings and read_secret. If every required value is there, the app starts with these settings. If a required value is missing, it stops with Settings error and exit code 1.
Where the API's settings come from when the container starts, and what happens when one is missing.
Text 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.

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”
  1. Change ticket_api/config.py and ticket_api/main.py as in Change config.py and main.py.
  2. Run python -m pytest in your virtual environment. Expect 17 passed.
  3. Build the image: docker build -t ticket-api:1.1.0-dev .
  4. Make .env as in A file of settings.
  5. Run docker run --rm --env-file .env ticket-api:1.1.0-dev. Expect Settings error: REQUIRE_API_KEY is true, but API_KEY is not set.
  6. Make secrets/api_key.txt, and check .gitignore, as in the steps of Secret files.
  7. Start the API with --env-file, API_KEY_FILE and the read-only mount, as in Secret files.
  8. Send requests/billing.json to /v1/classify without the key. Expect 401.
  9. Send it with X-API-Key: local-dev-key. Expect the billing result.
  10. Run docker inspect ticket-api --format "{{json .Config.Env}}". Check that the key is not in the output.
  11. 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.

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:

  1. Read the end of the log: docker logs ticket-api. Look for Settings error:.
  2. REQUIRE_API_KEY is true, but API_KEY is not set: give the key, with -e API_KEY=... or with API_KEY_FILE and a mount. Check also the name: with a typing error such as API_KEY_FLE, the app does not see the variable, and you get this same message.
  3. API_KEY_FILE: cannot read /run/secrets/api_key: No such file or directory: the path in API_KEY_FILE and the right side of -v must be the same. Check that the host file exists.
  4. 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.

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.

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.

Recap

  • Settings belong to the container, not to the image: one tested image runs everywhere with different settings.
  • -e NAME=value sets one environment variable. --env-file .env sets many, with no quotes. .env stays out of Git and out of the image.
  • docker inspect shows every environment variable. A secret goes into a read-only file, mounted with -v ...:ro, and the app reads the path from API_KEY_FILE.
  • REQUIRE_API_KEY=true makes a missing key stop the app with Settings error: ... and exit code 1. Server started comes 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