Shipping Python APIs/
Lesson

This is the lesson that saves you from the most common deployment failure. It is not a code bug, not a platform issue, not a network problem. It is a missing environment variableWhat is environment variable?A value stored outside your code that configures behavior per deployment, commonly used for secrets like API keys and database URLs.. Your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. starts, tries to connect to the database, reads DATABASE_URL from the environment, gets None, and crashes. AI generates code that works locally because there is a .env file sitting in your project directory. Production has no such file.

Why environment variables exist

Environment variables solve a fundamental problem: the same code needs to behave differently in different environments.

# Bad - hardcoded configuration
DATABASE_URL = "postgresql://admin:password123@localhost:5432/myapp"
JWT_SECRET = "super-secret-key-do-not-share"

# Good - read from environment
import os
DATABASE_URL = os.environ["DATABASE_URL"]
JWT_SECRET = os.environ["JWT_SECRET"]

The hardcoded version has three problems: the password is in your source code (visible to anyone with repo access), it always points to localhost (breaks in production), and the JWTWhat is jwt?JSON Web Token - a self-contained, signed token that carries user data (like user ID and role). The server can verify it without a database lookup. secret is predictable (anyone who reads your code can forge tokens).

AI pitfall
AI almost always hardcodes a fallback value: os.getenv("DATABASE_URL", "sqlite:///./dev.db"). This feels safe, if the env var is missing, use SQLite locally. But in production, if you forget to set DATABASE_URL, your API silently uses a SQLite file inside the container, which gets destroyed on every deploy. No error, just data loss.
02

Setting env vars per platform

Each platform has its own approach.

Railway

# CLI
railway variables set DATABASE_URL="postgresql://..."
railway variables set JWT_SECRET="$(openssl rand -hex 32)"

# Or use the Railway dashboard - Variables tab on your service

Railway also supports reference variables: if you create a Postgres database on Railway, it automatically injects DATABASE_URL into your app service.

Fly.io

# Fly secrets are encrypted and never visible after being set
fly secrets set DATABASE_URL="postgresql://..."
fly secrets set JWT_SECRET="$(openssl rand -hex 32)"

# List secrets (shows names only, not values)
fly secrets list

Render

Render supports environment groups, named collections of env vars you can share across services. This is useful when your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. and your worker process need the same database URL.

03

Validating config at startup with pydantic-settings

Raw os.getenv() calls scatter your configuration across the codebase and provide no validation. pydantic-settings centralizes everything.

from pydantic_settings import BaseSettings
from pydantic import Field

class Settings(BaseSettings):
    database_url: str
    jwt_secret: str = Field(min_length=32)
    debug: bool = False
    allowed_origins: list[str] = ["https://myapp.com"]

    model_config = {"env_file": ".env"}

# This crashes at import time if DATABASE_URL is missing
settings = Settings()

This gives you three things os.getenv() does not:

  1. Crash-early validation: if database_url is missing, the app fails immediately with a clear error, not 10 minutes later when the first database query runs
  2. Type coercionWhat is type coercion?JavaScript automatically converting a value from one type to another during an operation, like turning a string into a number for subtraction.: debug is automatically parsed from the string "true" to the boolean True
  3. Single source of truth: every config value is defined in one class, not scattered across files
Good to know
pydantic-settings reads from environment variables first, then falls back to the .env file. This means in production (where env vars are set by the platform), the .env file is ignored. Locally, the .env file fills in what is missing.
04

The .env.example pattern

Your .env file is in .gitignore (it must be). But new developers need to know which variables to set. The solution is .env.example:

# .env.example - committed to git, documents required variables
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
JWT_SECRET=generate-a-32-char-secret-here
DEBUG=true
ALLOWED_ORIGINS=http://localhost:3000
SENTRY_DSN=

This file uses placeholder values, not real secrets. It serves as documentation: "here are the variables this app needs."

05

Secret rotation and access control

Secrets are not set-and-forget. They need maintenance.

PracticeWhy
Rotate secrets periodicallyIf a secret leaks, the damage window is smaller
Use platform-generated secretsPlatforms generate strong random values, humans do not
Limit access to production secretsNot everyone on the team needs to see the DB password
Never log secretsStructured logging might accidentally serialize the Settings object
Use different secrets per environmentIf staging is compromised, production is still safe
# Danger - logging the settings object exposes secrets
logger.info(f"Starting with config: {settings}")

# Safe - log only non-sensitive fields
logger.info(f"Starting with debug={settings.debug}, origins={settings.allowed_origins}")
06

Quick reference

PatternDoDo not
Config sourcepydantic-settings or os.environ[]Hardcoded strings
Missing varsCrash at startupSilent fallback to dev values
.env fileIn .gitignore, alwaysCommitted to git, ever
Documentation.env.example with placeholdersComments in README
SecretsPlatform-managed, rotatedShared in Slack, never rotated
LoggingLog config keys, not valueslogger.info(settings.dict())