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.

bash
# .gitignore
.env
.env.*
*.pem
*.key
secrets/
env
# .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=
python
# 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:

  1. commit .env.example
  2. gitignore .env
  3. use one settings module
  4. fail at startup if required values are missing
  5. 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 .env only 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
Local .env
CI Secrets
Env Injection
Startup Validation
Service Ready

Config Flow

Step-by-step implementation

1. Inventory all config

List every value your app needs:

  • app settings
  • database
  • cache
  • auth/session config
  • payments
  • email
  • object storage
  • monitoring
  • third-party APIs

Example inventory:

VariablePurposeSecretRequired
APP_ENVruntime environmentNoYes
APP_SECRET_KEYapp/session signingYesYes
DATABASE_URLdatabase connectionYesYes
REDIS_URLcache/queue backendYesNo
STRIPE_SECRET_KEYStripe API accessYesIf using Stripe
STRIPE_WEBHOOK_SECRETwebhook signature validationYesIf using Stripe webhooks
SMTP_PASSWORDemail authYesIf sending email
SENTRY_DSNmonitoring setupUsually yesNo
namepurposerequired environmentssecret/non-secretand 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:

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

env
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.example whenever config changes

4. Add secret files to .gitignore

gitignore
.env
.env.*
*.pem
*.key
secrets/
service-account.json

Verify ignore rules:

bash
git check-ignore -v .env .env.production

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

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

python
# 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:

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

bash
cp .env.example .env

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

ini
# /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.target

Secret file:

env
# /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:

bash
sudo mkdir -p /etc/yourapp
sudo chown root:root /etc/yourapp
sudo chmod 700 /etc/yourapp
sudo chmod 600 /etc/yourapp/yourapp.env

Reload and restart:

bash
sudo systemctl daemon-reload
sudo systemctl restart yourapp
sudo systemctl status yourapp

This is the common pattern used with Deploy SaaS with Nginx + Gunicorn.

Docker and Docker Compose

Do not bake secrets into images.

yaml
# 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:
      - .env

Or inject at runtime:

bash
docker run --env-file .env yourapp:latest

Check effective config:

bash
docker compose config
docker inspect <container_id> | jq '.[0].Config.Env'

Rules:

  • do not copy .env into 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:

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

  1. create a new secret in the provider
  2. update runtime secret store
  3. deploy or restart services
  4. verify traffic and background jobs
  5. 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 Authorization headers
  • full DSNs
  • full API keys
  • SMTP passwords
  • webhook secrets

Safe log pattern:

python
print(f"STRIPE_SECRET_KEY present={bool(settings.stripe_secret_key)}")
print(f"SENTRY_DSN present={bool(settings.sentry_dsn)}")

Unsafe pattern:

python
print(settings.database_url)
print(settings.stripe_secret_key)

Common causes

  • Committed .env or 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

bash
env | sort
printenv | grep -E 'APP_|DATABASE_|REDIS_|STRIPE_|SMTP_|SENTRY_'

Check from Python

bash
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

bash
systemctl show yourapp --property=Environment
systemctl cat yourapp
sudo journalctl -u yourapp -n 100 --no-pager

Check Docker services

bash
docker compose config
docker inspect <container_id> | jq '.[0].Config.Env'

Search for hardcoded secrets or variable drift

bash
grep -R "SECRET_KEY\|DATABASE_URL\|STRIPE_SECRET_KEY" .

Confirm gitignore behavior

bash
git check-ignore -v .env .env.production

Source a local file manually for testing

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

What type of environment variable problem are you seeing?
Missing env var
Add the variable to the correct .env / secrets manager and restart the process
Wrong value
Update the value in secrets manager or CI environment and redeploy
Wrong process
Ensure the variable is injected into the correct service (not just one worker)
Wrong environment
Confirm APP_ENV / NODE_ENV is set correctly for the target environment

Checklist

  • .env is gitignored.
  • A complete .env.example exists.
  • 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

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