Hello. In the previous lesson, you created a private Azure Container Registry and pushed traceable backend and frontend image versions. The FastAPI image is now a deployable artifact, but a deployable image should not contain environment-specific configuration or credentials. This lesson makes the backend configurable at runtime and gives the deployment platform two deliberately different answers to the question “is this container healthy?”
You will implement:
- typed runtime configuration with Pydantic Settings;
- a safe local
.envworkflow that does not commit or bake configuration into the image; - a liveness endpoint that checks only whether the process can respond;
- a readiness endpoint that checks whether the application’s required dependencies, initially PostgreSQL, are usable.
These endpoint contracts will be used directly when configuring Azure Container Apps health probes in a later module, and the same paths will work in AKS.
Runtime configuration: one image, several environments
A container image should be portable: the same image tag should run locally, in a test environment, and in Azure. Values that vary by environment belong outside that image.
Examples include:
| Configuration category | Example | Put it in the image? |
|---|---|---|
| Stable application default | API title, default log format | Usually acceptable, but runtime configuration is often clearer |
| Environment-specific setting | ENVIRONMENT=lab, CORS origin | No |
| Operational tuning | timeout, log level, feature flag | No |
| Secret | database password, API key | Never |
At container startup, the runtime supplies environment variables. The application reads and validates them once, then uses a typed settings object. This has two advantages over scattering os.getenv() calls throughout route handlers:
- Configuration is documented in one place.
- Invalid values fail early, at startup, rather than producing an obscure failure during a request.
Pydantic Settings is designed for exactly this pattern. It reads operating-system environment variables, converts strings to declared Python types, and validates constraints.
Settings and Environment Variables - FastAPI
Read FastAPI’s official guide to Pydantic Settings. It establishes the configuration pattern used in the implementation below: typed fields, environment-variable loading, a local dotenv file, and cached dependency-based settings.
In “Pydantic Settings”, read the typed-settings introduction. Focus on the fact that values enter as strings but are parsed and validated against your declared field types. Then read “Settings in a dependency”, especially the get_settings() function and dependency override example. This is the basis for keeping configuration testable. Continue with “Reading a .env file” from the dotenv explanation, and finish “Creating the Settings only once with lru_cache”. Notice why a cached settings factory avoids repeatedly reading a dotenv file.
Add the required Python packages
From the backend directory, add the configuration library and dotenv support. This lesson also uses asyncpg for a minimal, direct PostgreSQL readiness query; it will become useful when the database is introduced.
uv add pydantic-settings python-dotenv asyncpg
uv add --dev pytest
If your project uses pip and a requirements file rather than uv, install the same packages using that existing workflow.
Cost of this practical action: This is entirely local package management and has no Azure cost. It creates no Azure resources.
Define one typed settings object
The following assumes a conventional package layout:
backend/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── health.py
│ └── main.py
├── tests/
│ └── test_health.py
├── .env
├── .env.example
└── pyproject.toml
If your FastAPI module currently has another name, such as src/main.py, keep its existing layout and place the code in equivalent modules.
Create app/config.py:
from typing import Literal
from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
app_name: str = "Grasp API"
environment: Literal["local", "test", "lab", "production"] = "local"
# This remains optional until a database becomes a required dependency.
# repr=False prevents accidental exposure when a Settings object is logged.
database_url: PostgresDsn | None = Field(default=None, repr=False)
database_required: bool = False
healthcheck_timeout_seconds: float = Field(
default=2.0,
gt=0,
le=10,
)
This class is your service’s configuration contract.
environmentaccepts only the four stated values. A typo such asprodutionstops the process rather than silently enabling an unknown configuration.database_requiredis a real Boolean in Python, even though its environment value is text.healthcheck_timeout_secondsmust be positive and no greater than ten seconds. A health probe should not pile up long-running checks while a dependency is unavailable.database_urlis optional for now, but when supplied it must have a PostgreSQL DSN format.repr=Falsereduces the chance of writing the connection string to logs accidentally. It does not make a secret safe to commit or expose in a response.
Pydantic uses field names to resolve environment variables case-insensitively by default, so the field database_required is supplied by DATABASE_REQUIRED. In normal use, deployment-provided environment variables take precedence over the fallback .env file. The .env file is therefore a local-development convenience, not a deployment mechanism.
Create a local .env file:
APP_NAME=Grasp API
ENVIRONMENT=local
DATABASE_REQUIRED=false
HEALTHCHECK_TIMEOUT_SECONDS=2
Do not put a real database URL, API key, or password in a file that Git tracks. Commit an .env.example file instead:
APP_NAME=Grasp API
ENVIRONMENT=local
DATABASE_REQUIRED=false
HEALTHCHECK_TIMEOUT_SECONDS=2
# Set only when PostgreSQL is a required runtime dependency.
# DATABASE_URL=postgresql://username:password@host:5432/database
Ensure both source control and the Docker build exclude local dotenv files:
# .gitignore
.env
.env.*
!.env.example
# .dockerignore
.env
.env.*
!.env.example
The .dockerignore entry is essential. Git ignoring a file prevents a commit, but it does not prevent docker build from copying that file into the build context if your Dockerfile contains a broad instruction such as COPY . ..
Health is not a single state
A platform needs to distinguish a container that should be restarted from one that should merely receive no traffic until a dependency recovers.

The distinction is operationally important:
| Endpoint | Question answered | Dependency checks? | Appropriate failure effect |
|---|---|---|---|
/health/live | “Can this FastAPI process respond?” | No | Restart may be justified if it repeatedly fails |
/health/ready | “Can this replica serve its normal workload now?” | Yes, for critical dependencies | Keep the replica out of traffic until it recovers |
Liveness should be deliberately boring
A liveness endpoint should do almost no work. It proves that the web process can accept and complete a request. It should not check PostgreSQL, another HTTP API, a queue, DNS, or disk space.
Why? Suppose PostgreSQL has a five-minute outage. Restarting every backend replica because the database is unavailable neither fixes PostgreSQL nor improves availability. It can make recovery worse by creating restart loops, losing useful logs, and increasing connection pressure once the database returns.
A failed liveness check should mean: “this particular process is unhealthy enough that restarting it is a reasonable recovery action.”
Readiness reflects ability to serve
Readiness can include dependencies required for normal requests. For this application, PostgreSQL will become one such dependency. While the database is optional during the present local-development stage, the code already has a clear rule:
- If
DATABASE_REQUIRED=false, the application is ready without PostgreSQL. - If
DATABASE_REQUIRED=true, the application is ready only if a database URL exists and a small PostgreSQL query succeeds. - A database failure returns HTTP
503 Service Unavailable, without exposing the connection string or raw database error to callers.
This allows the same image to start now without a database and later become correctly unready when its required database is unavailable.
Health probes in Azure Container Apps
Read Microsoft’s Container Apps probe documentation for the platform-level meaning of the endpoints you are about to create. The code is framework-independent; the important point is how Container Apps interprets a successful or failed endpoint response.
In “Health probes in Azure Container Apps”, read the probe overview and the table immediately following it. Distinguish Startup, Liveness, and Readiness before continuing. Next, in “HTTP probes”, read the HTTP success rule. In “Examples”, inspect the YAML example showing separate Liveness, Readiness, and Startup entries. Finally, in “Default configuration”, note that Container Apps can create default TCP probes when ingress is enabled; custom HTTP probes are preferable here because they represent the application’s actual state.
Implement liveness and readiness endpoints
Create app/health.py:
import asyncio
import logging
import asyncpg
from .config import Settings
logger = logging.getLogger(__name__)
async def database_is_reachable(settings: Settings) -> bool:
"""Return True only when a lightweight PostgreSQL query succeeds."""
if settings.database_url is None:
return False
connection = None
try:
connection = await asyncio.wait_for(
asyncpg.connect(str(settings.database_url)),
timeout=settings.healthcheck_timeout_seconds,
)
await asyncio.wait_for(
connection.execute("SELECT 1"),
timeout=settings.healthcheck_timeout_seconds,
)
return True
except (OSError, TimeoutError, asyncpg.PostgresError):
logger.warning(
"PostgreSQL readiness check failed",
exc_info=True,
)
return False
finally:
if connection is not None:
await connection.close()
async def is_ready(settings: Settings) -> bool:
"""Check only dependencies required by the current application mode."""
if not settings.database_required:
return True
return await database_is_reachable(settings)
A readiness check must be bounded. The asyncio.wait_for() calls ensure that an unavailable database does not make every health-probe request wait indefinitely. The SELECT 1 query is intentionally small: it validates a connection and query round trip without changing application state.
Do not add an endpoint that returns database_url, environment-variable values, or exception details. Operational diagnostics belong in structured logs and restricted monitoring systems, not in a public health response.
Now add the settings factory and endpoints to app/main.py. Preserve your existing routes, routers, middleware, and WebSocket handling; add the following pieces around them.
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from .config import Settings
from .health import is_ready
@lru_cache
def get_settings() -> Settings:
return Settings()
SettingsDependency = Annotated[Settings, Depends(get_settings)]
app = FastAPI()
@app.get("/health/live", include_in_schema=False)
async def liveness() -> dict[str, str]:
return {"status": "alive"}
@app.get("/health/ready", include_in_schema=False)
async def readiness(
settings: SettingsDependency,
) -> dict[str, str]:
if not await is_ready(settings):
raise HTTPException(
status_code=503,
detail={"status": "not_ready"},
)
return {"status": "ready"}
The absence of SettingsDependency from liveness() is intentional. Liveness remains independent of configuration and external services. Readiness receives settings through FastAPI dependency injection, which gives you a clean production implementation and a simple test seam.
The @lru_cache decorator creates one Settings instance per application process. A running process does not automatically absorb a changed .env file or changed container environment variable. In Azure, a configuration change normally creates or restarts a revision; a fresh process then reads its updated environment at startup. That is a desirable, explicit deployment behavior.
Cost of this practical action: Editing code and running tests locally have no Azure cost. Do not push a new image merely to verify these endpoints locally. If you later rebuild and push it to the Basic ACR created in the previous lesson, its ongoing registry baseline remains approximately USD 5/month at typical regional pricing, with usually small additional image-storage usage for a normal test image.
Verify the contracts locally
Start the application using the command appropriate to your project. For the layout used here:
uv run uvicorn app.main:app --reload
In a second terminal, verify both endpoints:
curl -i http://127.0.0.1:8000/health/live
curl -i http://127.0.0.1:8000/health/ready
With the provided local .env, both requests should return HTTP 200 and small JSON bodies:
{"status":"alive"}
{"status":"ready"}
Now stop the process and start it with PostgreSQL declared mandatory but without providing DATABASE_URL:
DATABASE_REQUIRED=true uv run uvicorn app.main:app
Retest:
curl -i http://127.0.0.1:8000/health/live
curl -i http://127.0.0.1:8000/health/ready
The expected behavior is:
/health/liveremains HTTP200./health/readyreturns HTTP503.
That asymmetric result is correct. The application process is alive, but it has declared a database dependency that is not configured, so it should not be considered eligible for application traffic.
For a Docker Compose local run, supply non-secret values as container environment variables rather than relying on a dotenv file copied into the image:
services:
backend:
environment:
APP_NAME: Grasp API
ENVIRONMENT: local
DATABASE_REQUIRED: "false"
HEALTHCHECK_TIMEOUT_SECONDS: "2"
Do not put DATABASE_URL directly in a committed Compose file. The next lesson will establish a secure Azure-side secret workflow before the database deployment uses it.
Cost of this practical action: Local Uvicorn, curl, and Docker Compose verification have no Azure cost. They do not create Azure compute, networking, or database resources.
Test the behavior without PostgreSQL
A health endpoint is infrastructure-facing code. It deserves automated tests, particularly because a future refactor could accidentally make liveness depend on the database.
Create tests/test_health.py:
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import app, get_settings
client = TestClient(app)
def test_liveness_is_independent_of_database() -> None:
response = client.get("/health/live")
assert response.status_code == 200
assert response.json() == {"status": "alive"}
def test_readiness_succeeds_when_database_is_not_required() -> None:
app.dependency_overrides[get_settings] = (
lambda: Settings(database_required=False)
)
response = client.get("/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}
app.dependency_overrides.clear()
def test_readiness_fails_when_required_database_is_missing() -> None:
app.dependency_overrides[get_settings] = (
lambda: Settings(database_required=True, database_url=None)
)
response = client.get("/health/ready")
assert response.status_code == 503
assert response.json()["detail"] == {"status": "not_ready"}
app.dependency_overrides.clear()
Run the tests:
uv run pytest -q
These tests use explicit Settings objects through FastAPI’s dependency override mechanism. They require neither PostgreSQL nor Azure. The test suite confirms the key operational boundary:
- liveness does not care whether the database is configured;
- readiness does care when the current service mode declares the database mandatory.
When a real database exists later in the course, add an integration test separately. Keep it outside the fast local unit-test path because it needs a running dependency and credentials.
Cost of this practical action: These automated tests run locally and have no Azure cost. They create no Azure resources and make no calls to your Container Registry.
Prepare the updated backend image, but do not publish blindly
After the local endpoints and tests work, inspect your Dockerfile before building. The container must listen on the port expected by your later deployment configuration, and it must not copy .env into the image.
A typical command section looks like this:
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
The exact module path must match your repository. Do not modify a working Dockerfile solely to match this example.
You may rebuild locally to ensure the dependencies are present in the container:
docker build -t grasp-backend:healthcheck-local .
docker run --rm -p 8000:8000 \
-e APP_NAME="Grasp API" \
-e ENVIRONMENT="local" \
-e DATABASE_REQUIRED="false" \
grasp-backend:healthcheck-local
Then call the two local URLs again. Only after this succeeds should you create a new versioned ACR image tag using the workflow from the previous lesson. Never overwrite the prior release tag; use a new Git-derived tag or explicit version.
Cost of this practical action: A local Docker build and local container run have no Azure cost. Pushing the resulting image to your existing ACR creates only image-storage usage in addition to the registry’s existing Basic-tier charge. For a small backend image, the incremental storage cost is normally minor, but remove obsolete large image versions when they are no longer useful.
Key takeaways
You have made the FastAPI backend more cloud-ready without coupling it to Azure-specific code:
- Pydantic Settings centralizes runtime configuration, parses environment strings into typed values, and rejects invalid settings early.
- A local
.envis useful for development, but.envfiles must be excluded from both Git and Docker build contexts. Commit only a non-secret.env.example. - Liveness checks whether the application process can respond. It must not depend on PostgreSQL or other external services.
- Readiness checks whether this replica can serve its real workload. When PostgreSQL is required, a missing or unreachable database produces HTTP
503. - The same health paths will be configured as HTTP probes in Azure Container Apps and later in AKS.
- Tests based on FastAPI dependency overrides verify the health semantics without requiring a database or any Azure spend.
Next, you will establish a secure Azure secret workflow so that a future DATABASE_URL is available to the running application without being committed to Git, stored in a container image, or written into Terraform state.
Can't find a good explanation? Sign up and we'll make it for you
Sign up