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).
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.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 serviceRailway 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 listRender
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.
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:
- Crash-early validation: if
database_urlis missing, the app fails immediately with a clear error, not 10 minutes later when the first database query runs - 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.:
debugis automatically parsed from the string"true"to the booleanTrue - Single source of truth: every config value is defined in one class, not scattered across files
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.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."
Secret rotation and access control
Secrets are not set-and-forget. They need maintenance.
| Practice | Why |
|---|---|
| Rotate secrets periodically | If a secret leaks, the damage window is smaller |
| Use platform-generated secrets | Platforms generate strong random values, humans do not |
| Limit access to production secrets | Not everyone on the team needs to see the DB password |
| Never log secrets | Structured logging might accidentally serialize the Settings object |
| Use different secrets per environment | If 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}")Quick reference
| Pattern | Do | Do not |
|---|---|---|
| Config source | pydantic-settings or os.environ[] | Hardcoded strings |
| Missing vars | Crash at startup | Silent fallback to dev values |
.env file | In .gitignore, always | Committed to git, ever |
| Documentation | .env.example with placeholders | Comments in README |
| Secrets | Platform-managed, rotated | Shared in Slack, never rotated |
| Logging | Log config keys, not values | logger.info(settings.dict()) |