Development vs Production Environments
The essential playbook for implementing development vs production environments in your SaaS.
Development and production must behave differently in controlled ways. Local development should optimize for speed and debugging. Production should optimize for security, reliability, performance, and repeatability.
The goal is not to make them identical everywhere. The goal is to make configuration explicit so your app runs predictably in both environments.
Quick Fix / Quick Setup
Use separate env files, separate databases, and explicit startup validation. Never share development secrets, sessions, or data stores with production.
# .env.development
APP_ENV=development
DEBUG=true
LOG_LEVEL=DEBUG
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=dev-only-insecure-key
ALLOWED_HOSTS=localhost,127.0.0.1
# .env.production
APP_ENV=production
DEBUG=false
LOG_LEVEL=INFO
DATABASE_URL=postgresql://user:pass@db:5432/myapp_prod
REDIS_URL=redis://redis:6379/0
SECRET_KEY=replace-with-strong-secret
ALLOWED_HOSTS=app.example.com# Python settings loader example
import os
from pydantic import BaseSettings
class Settings(BaseSettings):
APP_ENV: str = "development"
DEBUG: bool = False
LOG_LEVEL: str = "INFO"
DATABASE_URL: str
REDIS_URL: str | None = None
SECRET_KEY: str
ALLOWED_HOSTS: str = "localhost"
class Config:
env_file = f".env.{os.getenv('APP_ENV', 'development')}"
settings = Settings()
if settings.APP_ENV == "production" and settings.DEBUG:
raise RuntimeError("DEBUG must be false in production")Minimum rules:
- Set
APP_ENVexplicitly. - Keep real secrets out of git.
- Use different DB, Redis, storage, and webhook endpoints per environment.
- Fail startup if production config is unsafe.
- Centralize config loading in one module.
What’s happening
Most environment problems come from implicit defaults:
- The app silently uses local DB credentials.
- Debug mode stays enabled after deploy.
- Cookies were configured only for
localhost. - Production containers write to ephemeral local disk.
- OAuth, email, or Stripe keys point to the wrong environment.
Development usually runs on a laptop with hot reload, verbose logs, and relaxed security settings. Production usually runs behind a process manager, reverse proxy, managed database, and stricter network rules.
If the boundary is weak, you get config drift: code works locally but fails after deploy because services, domains, paths, SSL, queues, or storage are different.
A safe setup does three things:
- Separates config from code
- Uses different infrastructure per environment
- Validates critical settings at startup
| Aspect | local dev | production stack |
|---|---|---|
| — | — | — |
side-by-side diagram of local dev vs production stack showing app config, database, Redis, storage, reverse proxy, and external services.
Step-by-step implementation
1. Define environment names once
Use a small fixed set:
developmentstagingproduction
Keep the same names across:
- app config
- Docker Compose
- CI/CD
- hosting provider variables
- worker services
Example:
APP_ENV=developmentDo not mix variants like prod, live, prd, local-dev unless you have a very specific reason.
2. Centralize configuration loading
Do not scatter os.getenv() calls throughout the app.
Bad:
db_url = os.getenv("DATABASE_URL")
debug = os.getenv("DEBUG") == "true"Good:
from pydantic import BaseSettings
class Settings(BaseSettings):
APP_ENV: str = "development"
DEBUG: bool = False
DATABASE_URL: str
REDIS_URL: str | None = None
SECRET_KEY: str
LOG_LEVEL: str = "INFO"Then import one config object everywhere.
For secret handling patterns, see Environment Variables and Secrets Management.
3. Use env-specific files for local development only
Commit examples, not secrets.
.env.development
.env.staging.example
.env.production.exampleExample .gitignore:
.env
.env.*
!.env.example
!.env.development.example
!.env.production.exampleRecommended layout:
# committed
.env.development.example
.env.production.example
# uncommitted
.env.development
.env.productionFor production, prefer your host’s env var system or secret store over a checked-in file.
4. Add startup validation
Fail fast before serving traffic.
if settings.APP_ENV == "production":
if settings.DEBUG:
raise RuntimeError("DEBUG must be false in production")
if len(settings.SECRET_KEY) < 32:
raise RuntimeError("SECRET_KEY is too short")
if "localhost" in settings.ALLOWED_HOSTS:
raise RuntimeError("ALLOWED_HOSTS is unsafe for production")Validate at minimum:
APP_ENVDEBUGSECRET_KEYDATABASE_URLALLOWED_HOSTS- storage backend config
- required API keys for enabled features
5. Separate infrastructure by environment
Do not share these between development and production:
- database
- Redis
- object storage bucket or prefix
- queue namespace
- webhook endpoints
- OAuth apps
- email provider domains/senders
- payment credentials
Good:
DATABASE_URL=postgresql://localhost:5432/myapp_dev
DATABASE_URL=postgresql://user:pass@prod-db:5432/myapp_prodBad:
- local app uses production DB
- production worker uses staging Redis
- all environments write to the same S3 bucket root
6. Configure logging by environment
Local development:
- human-readable logs
- debug enabled
- stack traces visible
Production:
- structured logs
INFO/WARN/ERROR- no sensitive data
- logs written to stdout/stderr or journal
Example:
# development
LOG_LEVEL=DEBUG
# production
LOG_LEVEL=INFOFor runtime visibility, pair this with Logging Setup for Application Server Monitoring.
7. Define storage behavior explicitly
Local development may use disk. Production often needs object storage or mounted persistent volumes.
Example:
# development
STORAGE_BACKEND=local
MEDIA_ROOT=./media
# production
STORAGE_BACKEND=s3
S3_BUCKET=myapp-production
S3_REGION=us-east-1Common mistake: uploads work locally, then disappear in production because the container filesystem is ephemeral.
8. Enable production-safe security settings
Typical production requirements:
DEBUG=false- secure cookies
- HTTPS redirects
- trusted CSRF origins
- correct cookie domain
- explicit CORS allowlist
- rate limits where needed
Example:
SESSION_COOKIE_SECURE=true
CSRF_COOKIE_SECURE=true
CSRF_TRUSTED_ORIGINS=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com9. Reproduce production where it matters
You do not need a perfect clone locally. You do need parity for critical runtime dependencies:
- database engine
- Redis
- reverse proxy behavior
- file storage behavior
- worker queue behavior
If production uses PostgreSQL, test against PostgreSQL before release. If production uses Docker, run your app in Docker locally at least once before shipping.
See:
10. Add deployment checks in CI/CD
Before deploy, verify required variables exist.
Example shell check:
required_vars=(
APP_ENV
DATABASE_URL
SECRET_KEY
ALLOWED_HOSTS
)
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "Missing required env var: $var"
exit 1
fi
doneFor production:
if [ "$APP_ENV" = "production" ] && [ "$DEBUG" = "true" ]; then
echo "DEBUG cannot be true in production"
exit 1
fi11. Split third-party integrations by environment
Use separate apps, projects, test/live keys, or webhook endpoints for:
- auth providers
- Stripe
- email providers
- webhooks
- analytics
- error tracking
Examples:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
# production
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_live_...Mixing test and live credentials is a common release bug.
12. Document every environment variable
Keep a copy-paste friendly table in your repo or ops docs.
Recommended fields:
- variable name
- required or optional
- example value
- default behavior
- environments used
- sensitive or not
Example:
| Variable | Required | Example | Notes |
|---|---|---|---|
| APP_ENV | yes | production | development/staging/prod |
| DATABASE_URL | yes | postgresql://user:pass@db/app | separate per env |
| SECRET_KEY | yes | long-random-string | never commit |
| REDIS_URL | no | redis://redis:6379/0 | required for workers |
| ALLOWED_HOSTS | yes | app.example.com | no localhost in prod |
Config Flow
Common causes
DEBUGleft enabled in production- production app pointing to development database or Redis
- missing or incorrect environment variables after deploy
- secrets committed locally but not configured on server
- local filesystem storage assumed in production container or VPS
- OAuth, email, or payment credentials using the wrong environment
- cookie, session, CSRF, or CORS settings built for localhost only
- background workers using different env vars than the web app
- different dependency versions between local and production
- database engine differences causing migration or query failures
Debugging tips
Check the active environment first.
printenv | sort
env | grep -E 'APP_ENV|DEBUG|DATABASE_URL|REDIS_URL|SECRET|HOST'
python -c "import os; print(os.getenv('APP_ENV')); print(os.getenv('DATABASE_URL'))"
python -c "from app.config import settings; print(settings.APP_ENV, settings.DEBUG, settings.LOG_LEVEL)"Compare dependency/runtime versions:
pip freeze
python --versionIf using Docker:
docker compose config
docker compose exec web env | sortIf using systemd on a VPS:
systemctl status myapp
systemctl cat myapp
journalctl -u myapp -n 200 --no-pager
ps aux | grep gunicornCheck health endpoints:
curl -I http://127.0.0.1:8000/health
curl -I https://yourdomain.com/health
nslookup yourdomain.comUseful debugging process:
- Print effective config in startup logs, excluding secrets.
- Verify app and worker use the same environment source.
- Confirm database, Redis, and storage targets are the expected ones.
- Verify domain, HTTPS, cookie, and CSRF settings in production.
- Compare package versions between local and deployed environments.
If deploy issues are involved, also review:
Checklist
- ✓
APP_ENVis explicitly set in every environment - ✓ production has its own database, Redis, storage, and credentials
- ✓
DEBUGis false in production - ✓
SECRET_KEYis unique and strong in production - ✓
ALLOWED_HOSTSor trusted domains are set correctly - ✓ secure cookies and HTTPS settings are enabled in production
- ✓ static and media storage paths/backends are verified
- ✓ background workers point to the correct broker and queue
- ✓ webhook, OAuth, and email credentials match the environment
- ✓ a staging environment exists for pre-production testing
- ✓ startup validation rejects unsafe production config
- ✓ environment variable documentation is up to date
Related guides
- Environment Variables and Secrets Management
- Docker Production Setup for SaaS
- Deploy SaaS with Nginx + Gunicorn
- Logging Setup for Application Server Monitoring
- SaaS Production Checklist
FAQ
Should I keep separate config files for development and production?
Yes, but keep them thin. Centralize configuration loading in one place and use env-specific files or secret scopes only for values that differ.
Is staging required for a small SaaS?
Not always on day one. It becomes important once you add payments, background jobs, OAuth, file uploads, or custom domain behavior.
Can I use the same third-party account for all environments?
Use separate projects, apps, webhook endpoints, or at minimum separate test and live credentials. Mixing environments increases risk and makes debugging harder.
What should the app validate on startup?
At minimum:
APP_ENVDEBUGSECRET_KEYpresence and strength- database URL
- allowed hosts
- storage backend settings
- required API keys for enabled features
Why does the app work locally but fail in production?
Usually because production has stricter networking, different paths, real domains, HTTPS, background workers, or a different database/storage backend than local development.
Should development match production exactly?
No. Match critical runtime dependencies and infrastructure behavior, but keep local development fast.
Should I use one .env file for everything?
No. Use separate files or secret scopes per environment.
Can I use SQLite in development and PostgreSQL in production?
Yes for early MVPs, but test against PostgreSQL before release to catch SQL and migration differences.
Where should production secrets live?
In your host, CI/CD secret store, or secret manager. Not in the repository.
Final takeaway
Development and production should share code, not assumptions.
Make environment-specific behavior explicit, validated, and documented.
Most deployment issues come from weak separation between:
- secrets
- services
- domains
- storage
- security flags
If you define those boundaries clearly, small SaaS deployments become much more predictable.