Environment Variables and Secrets Management
The essential playbook for implementing environment variables and secrets management in your SaaS.
Environment variables are the standard way to configure a SaaS app without hardcoding credentials, API keys, database URLs, or per-environment settings.
The goal:
- keep secrets out of source control
- load config consistently in local, staging, CI/CD, and production
- validate required values at startup
- rotate sensitive values safely without breaking deployments
This page covers a practical setup for small SaaS apps running web, worker, and scheduled jobs.
Quick Fix / Quick Setup
Use a committed .env.example, keep real secret files out of git, and load production secrets from your runtime platform or service manager.
# .gitignore
.env
.env.*
*.pem
*.key
secrets/# .env.example
APP_ENV=development
APP_DEBUG=true
APP_SECRET_KEY=
DATABASE_URL=postgresql://user:pass@localhost:5432/app
REDIS_URL=redis://localhost:6379/0
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SENTRY_DSN=# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_env: str = "development"
app_debug: bool = False
app_secret_key: str
database_url: str
redis_url: str | None = None
stripe_secret_key: str | None = None
stripe_webhook_secret: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_username: str | None = None
smtp_password: str | None = None
sentry_dsn: str | None = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
settings = Settings()
print("Config loaded for:", settings.app_env)Minimum safe setup:
- commit
.env.example - gitignore
.env - use one settings module
- fail at startup if required values are missing
- inject production secrets from your host, container runtime, or CI/CD secret store
What’s happening
- Environment variables decouple code from runtime configuration.
- Secrets include:
- database credentials
- API keys
- JWT/session secrets
- webhook signing secrets
- SMTP passwords
- cloud access keys
- Non-secret config includes:
APP_ENV- domain
- region
- feature flags
- log level
- The most common failures are:
- missing variables
- wrong variable names
- loading
.envonly in development - assuming production reads config from the same place as local
- A reliable setup has:
- one typed config loader
- one
.env.example - startup validation
- one documented source of truth per environment
Config Flow
Step-by-step implementation
1. Inventory all config
List every value your app needs:
- app settings
- database
- cache
- auth/session config
- payments
- object storage
- monitoring
- third-party APIs
Example inventory:
| Variable | Purpose | Secret | Required |
|---|---|---|---|
| APP_ENV | runtime environment | No | Yes |
| APP_SECRET_KEY | app/session signing | Yes | Yes |
| DATABASE_URL | database connection | Yes | Yes |
| REDIS_URL | cache/queue backend | Yes | No |
| STRIPE_SECRET_KEY | Stripe API access | Yes | If using Stripe |
| STRIPE_WEBHOOK_SECRET | webhook signature validation | Yes | If using Stripe webhooks |
| SMTP_PASSWORD | email auth | Yes | If sending email |
| SENTRY_DSN | monitoring setup | Usually yes | No |
| name | purpose | required environments | secret/non-secret | and owner |
|---|---|---|---|---|
| — | — | — | — | — |
table with variable name, purpose, required environments, secret/non-secret, and owner.
2. Define canonical variable names
Pick stable names and keep them consistent.
Good examples:
APP_ENV=production
APP_SECRET_KEY=...
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
STRIPE_SECRET_KEY=...
SMTP_PASSWORD=...
SENTRY_DSN=...Bad pattern:
- local uses
SECRET_KEY - production uses
APP_SECRET - worker uses
SESSION_SECRET
That causes drift and silent failures.
3. Commit .env.example
Your .env.example should include every variable name your app expects, with safe placeholders only.
APP_ENV=development
APP_DEBUG=true
APP_SECRET_KEY=
DATABASE_URL=postgresql://user:pass@localhost:5432/app
REDIS_URL=redis://localhost:6379/0
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SENTRY_DSN=Rules:
- commit
.env.example - never commit real
.env - never put production values in example files
- update
.env.examplewhenever config changes
4. Add secret files to .gitignore
.env
.env.*
*.pem
*.key
secrets/
service-account.jsonVerify ignore rules:
git check-ignore -v .env .env.productionIf a secret was committed before gitignore existed, remove it from history and rotate it.
5. Load config in one place
Do not scatter os.getenv() across the codebase. Centralize config.
# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_env: str = "development"
app_debug: bool = False
app_secret_key: str
database_url: str
redis_url: str | None = None
stripe_secret_key: str | None = None
stripe_webhook_secret: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_username: str | None = None
smtp_password: str | None = None
sentry_dsn: str | None = None
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)
settings = Settings()Benefits:
- types are parsed once
- required values fail automatically
- code imports one settings object
- less config drift
This fits cleanly with a structured app layout described in Structuring a Flask/FastAPI SaaS Project.
6. Validate at startup
Fail before serving requests.
# startup.py
from app.config import settings
allowed_envs = {"development", "staging", "production"}
if settings.app_env not in allowed_envs:
raise RuntimeError(f"Invalid APP_ENV: {settings.app_env}")
print("Starting app")
print(f"APP_ENV={settings.app_env}")
print(f"DATABASE_URL present={bool(settings.database_url)}")
print(f"REDIS_URL present={bool(settings.redis_url)}")
print(f"STRIPE_SECRET_KEY present={bool(settings.stripe_secret_key)}")Do not print raw secret values.
Validate:
- required presence
- allowed values
- integer/boolean parsing
- URL shape
- environment-specific requirements
Example:
if settings.app_env == "production" and not settings.sentry_dsn:
print("Warning: SENTRY_DSN missing in production")7. Use correct patterns by environment
Local development
A local .env is fine if it stays on your machine.
cp .env.example .envUse separate local/test credentials. Never copy production secrets into local development.
This should line up with your environment separation strategy in Development vs Production Environments.
Staging
Use isolated credentials:
- separate database
- separate Redis
- test Stripe keys
- separate email domain or sandbox
- separate S3 bucket if needed
Staging should mimic production setup, but not share production secrets.
Production on a VPS with systemd
Prefer service-level env injection over repo-side .env files.
Example unit:
# /etc/systemd/system/yourapp.service
[Unit]
Description=YourApp
After=network.target
[Service]
User=www-data
WorkingDirectory=/srv/yourapp
EnvironmentFile=/etc/yourapp/yourapp.env
ExecStart=/srv/yourapp/.venv/bin/gunicorn -w 3 -k uvicorn.workers.UvicornWorker app.main:app
Restart=always
[Install]
WantedBy=multi-user.targetSecret file:
# /etc/yourapp/yourapp.env
APP_ENV=production
APP_DEBUG=false
APP_SECRET_KEY=replace-me
DATABASE_URL=postgresql://user:pass@db:5432/app
REDIS_URL=redis://127.0.0.1:6379/0
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
SMTP_HOST=smtp.provider.com
SMTP_PORT=587
SMTP_USERNAME=user
SMTP_PASSWORD=secret
SENTRY_DSN=https://...Restrict permissions:
sudo mkdir -p /etc/yourapp
sudo chown root:root /etc/yourapp
sudo chmod 700 /etc/yourapp
sudo chmod 600 /etc/yourapp/yourapp.envReload and restart:
sudo systemctl daemon-reload
sudo systemctl restart yourapp
sudo systemctl status yourappThis is the common pattern used with Deploy SaaS with Nginx + Gunicorn.
Docker and Docker Compose
Do not bake secrets into images.
# docker-compose.yml
services:
web:
image: yourapp:latest
env_file:
- .env
ports:
- "8000:8000"
worker:
image: yourapp:latest
command: celery -A app.worker worker --loglevel=info
env_file:
- .envOr inject at runtime:
docker run --env-file .env yourapp:latestCheck effective config:
docker compose config
docker inspect <container_id> | jq '.[0].Config.Env'Rules:
- do not copy
.envinto the image - do not hardcode secrets in
Dockerfile - ensure web and worker receive the same required env set
CI/CD
Store secrets in the CI provider secret store.
Examples:
- GitHub Actions secrets
- GitLab CI variables
- Render/Fly/Railway/Vercel secrets
- cloud secret manager
Only pass secrets to jobs that need them.
Example GitHub Actions usage:
env:
APP_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}Do not:
- echo secrets in logs
- write long-lived secrets into artifacts
- assume CI variables are available in production runtime unless explicitly injected there too
8. Separate backend and frontend config
Only public client-safe values belong in frontend bundles.
Safe examples:
- public API base URL
- public Stripe publishable key
- feature flags intended for the browser
Unsafe examples:
- database URL
- SMTP password
- private API keys
- webhook secrets
- app signing keys
If server-only values appear in frontend code, rotate them immediately.
9. Handle workers, cron, and migrations explicitly
A common mistake: web has correct config, worker does not.
Check every process type:
- web
- worker
- queue consumer
- scheduler/cron
- migration task
- one-off admin scripts
If using systemd, each unit needs access to the same required env source.
If using containers, each service must receive the same required variables.
If jobs fail with connection issues, cross-check with Database Connection Errors.
10. Rotate secrets safely
Basic rotation flow:
- create a new secret in the provider
- update runtime secret store
- deploy or restart services
- verify traffic and background jobs
- revoke old secret
Do not revoke the old secret before confirming all processes use the new one.
11. Mask secrets in logs and debug pages
Never log:
- raw
Authorizationheaders - full DSNs
- full API keys
- SMTP passwords
- webhook secrets
Safe log pattern:
print(f"STRIPE_SECRET_KEY present={bool(settings.stripe_secret_key)}")
print(f"SENTRY_DSN present={bool(settings.sentry_dsn)}")Unsafe pattern:
print(settings.database_url)
print(settings.stripe_secret_key)Common causes
- Committed
.envor secret files to git by mistake. - Missing required environment variable in production service definition.
- Different variable names between local code and deployment config.
- Web process has correct env vars but worker or cron process does not.
- Secrets updated in dashboard but service was not restarted or reloaded.
- Using Stripe test keys in one process and live keys in another.
- Quoted or multiline secrets parsed incorrectly in shell, Docker, or CI.
- Frontend bundle accidentally includes server-only environment variables.
- Old secret still cached in CI/CD or in deployment assumptions.
- Incorrect file permissions on VPS secret files or
EnvironmentFile.
Debugging tips
Start by checking the exact running environment, not your shell assumptions.
Inspect environment variables
env | sort
printenv | grep -E 'APP_|DATABASE_|REDIS_|STRIPE_|SMTP_|SENTRY_'Check from Python
python -c "import os; print('DATABASE_URL' in os.environ, os.environ.get('APP_ENV'))"
python -c "from app.config import settings; print(settings.app_env)"Check systemd services
systemctl show yourapp --property=Environment
systemctl cat yourapp
sudo journalctl -u yourapp -n 100 --no-pagerCheck Docker services
docker compose config
docker inspect <container_id> | jq '.[0].Config.Env'Search for hardcoded secrets or variable drift
grep -R "SECRET_KEY\|DATABASE_URL\|STRIPE_SECRET_KEY" .Confirm gitignore behavior
git check-ignore -v .env .env.productionSource a local file manually for testing
. ./.env && echo "$APP_ENV"Debugging rules:
- print presence, not full values
- compare web vs worker vs scheduler
- verify restart actually reloaded new env vars
- check quoting and newline handling for multiline secrets
- verify test/live key consistency for payments and external APIs
troubleshooting flowchart for missing env var vs wrong value vs wrong process vs wrong environment.
Checklist
- ✓
.envis gitignored. - ✓ A complete
.env.exampleexists. - ✓ All required variables are validated at startup.
- ✓ Production secrets are injected by platform or service manager.
- ✓ Web, worker, scheduler, and migration processes share the correct env set.
- ✓ Secrets are never logged or exposed to the frontend.
- ✓ Staging and production use separate credentials.
- ✓ Secret rotation process is documented.
- ✓ Leaked or old keys are revoked.
- ✓ CI/CD uses a secure secret store.
For final release validation, pair this with the SaaS Production Checklist.
Related guides
- Development vs Production Environments
- Structuring a Flask/FastAPI SaaS Project
- Deploy SaaS with Nginx + Gunicorn
- SaaS Production Checklist
- Database Connection Errors
FAQ
Is a .env file enough for an MVP?
Yes for local development. For production, use your host or process manager secret injection so secrets are not tied to the repo or deploy artifact.
Should I use one secret for everything?
No. Use separate secrets for:
- sessions
- JWT signing
- password reset tokens
- webhook verification
- third-party APIs
How do I share env vars with Celery or RQ workers?
Inject the same required variables into the worker service definition or container runtime. Do not assume the worker inherits your shell environment.
What is the minimum safe setup?
- gitignored
.envfor local use - committed
.env.example - typed config loader
- startup validation
- platform-managed secrets in production
What should never be exposed to the browser?
- database URLs
- private API keys
- webhook secrets
- SMTP passwords
- cloud secret keys
- any server signing secret
Should I store secrets in a .env file in production?
Usually no. Prefer your host, process manager, container platform, or secret manager. A restricted EnvironmentFile on a VPS is acceptable if managed carefully.
Should I commit .env.example?
Yes. It documents required variables without storing real secrets.
Can frontend apps use environment variables?
Only build-time public values intended for the client. Private keys must stay on the server.
How do I rotate a secret safely?
Add the new secret in the provider and app runtime, deploy, verify traffic, then revoke the old secret.
What should be validated at startup?
- required presence
- type parsing
- allowed values for
APP_ENV - URL format
- app-specific constraints
Final takeaway
Treat configuration as a deployment dependency, not an afterthought.
For a small SaaS, most secrets-management mistakes are prevented by:
- one typed config loader
- one committed
.env.example - startup validation
- secure runtime injection for production
- consistent config across web, worker, and scheduled processes
If those are in place, deployments become easier to reason about and much harder to break.