23.2. Preparing an Application for Production
Table of Contents
Why “Production” Is Different
Running code on your laptop is very forgiving. You control the environment, there is no real traffic, and if something crashes you just restart it.
“Production” is different. Real users send requests at unpredictable times, your server runs for weeks or months, and any mistake can cause downtime, data loss, or security issues.
Preparing an application for production means turning “it works on my machine” into “it runs safely and reliably on real servers.”
Key production goals:
- Reliability: the app keeps running and recovers from failures.
- Security: data and secrets are protected.
- Performance: the app is fast enough under realistic load.
- Observability: you can see what is happening when something goes wrong.
- Repeatability: deployments are consistent and reproducible.
In this chapter, we will focus on what you do before deployment so that your app is ready to live in production.
Core idea: Production readiness is not about adding features. It is about making existing features safe, stable, and observable in a real environment.
Separating Configuration from Code
In development, it is tempting to put everything directly in code:
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
DEBUG = True
SECRET_KEY = "super-secret-key"In production, this is dangerous and inflexible.
Why configuration must be separate
Reasons to separate:
- Security: secrets should not be hardcoded in Git.
- Flexibility: the same code can run in dev, staging, and production, with different configurations.
- Automation: deployments become easier, because you only change environment variables, not code.
A common pattern is the twelve-factor app approach: store configuration in environment variables, not in code.
Typical configuration items
Common things you should never hardcode:
| Type | Examples |
|---|---|
| Secrets | API keys, database passwords, JWT secret keys |
| Connection info | Database URLs, Redis URLs, message broker URLs |
| Environment flags | DEBUG, ENV=development/staging/production |
| External services | Email SMTP config, payment gateway credentials |
| URLs / domains | BASE_URL, CDN URL, OAuth callback URLs |
Your code should read these from the environment and provide safe defaults only for development.
Example in Python:
import os
ENV = os.getenv("ENV", "development")
DEBUG = ENV != "production"
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./dev.db")
SECRET_KEY = os.getenv("SECRET_KEY") # no default for secrets!Rule: Never commit real secrets to version control. Always load secrets from environment variables or a dedicated secrets manager.
Enabling Production Settings
Many frameworks and libraries have special production settings. Development defaults are often slow, verbose, and insecure.
Debug vs production mode
Most web frameworks have a debug or development flag.
Examples of typical differences:
| Setting | Development | Production |
|---|---|---|
DEBUG | True (full error pages) | False (generic error messages) |
| Logging | Very verbose | Structured, controlled by log level |
| Auto-reload | Enabled (reload on code change) | Disabled (managed by process manager) |
| Security | Relaxed checks | Strict cookies, HTTPS, headers |
In Python with a typical settings pattern:
import os
ENV = os.getenv("ENV", "development")
DEBUG = ENV == "development"In FastAPI, debug mode is often configured at server level:
# Dev, with auto-reload
uvicorn app.main:app --reload
# Production style, no reload, more processes
uvicorn app.main:app --host 0.0.0.0 --port 8000You should ensure your deployment uses the correct flags and does not rely on development servers that are only meant for local use.
Security-related settings
Before production, you should verify that all relevant security settings are turned on:
- Secure cookies:
Secure,HttpOnly, andSameSitewhere appropriate. - CORS: only allow trusted origins, not
*for everything. - HTTPS: always use HTTPS in production, never plain HTTP for real user data.
- Security headers: content security policy, X-Frame-Options, etc (covered in security chapters, but you must enable them in prod settings).
These are often controlled by configuration flags so you can keep development flexible and production strict.
Handling Secrets Safely
Secrets are anything that should not be visible to users, attackers, or random developers.
Examples:
- Database passwords
- JWT signing keys
- OAuth client secrets
- API keys for payment providers or email services
- SSH private keys
What not to do
Avoid these patterns:
# Bad: hard-coded secrets
SECRET_KEY = "this-is-actually-used-in-production"
DB_PASSWORD = "p@ssword123"# Bad: committing .env.production with real values to Git
DATABASE_URL=postgres://user:real-password@db:5432/prod
SECRET_KEY=real-production-secretAlso avoid:
- Sending secrets in chat tools in plain text.
- Reusing the same secret across dev, staging, and production.
Safer approaches
For beginners, a simple but safer pattern is:
- Store secrets in environment variables on the server.
- Use a
.envfile only locally or in controlled infrastructure. - Ensure
.envis excluded from Git with.gitignore.
Example .env for development only:
ENV=development
DATABASE_URL=postgresql://devuser:devpass@localhost:5432/dev_db
SECRET_KEY=dev-only-secret
Then load it (for local development) with something like python-dotenv or your framework tools, but use real environment variables in production.
In code:
import os
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY is not set")
In production, you set SECRET_KEY at the system or container level, not in code.
For more advanced setups, you will use secrets managers (AWS Secrets Manager, Vault, etc), but the important idea is the same: application code reads secrets, it never contains secrets.
Database and Migration Readiness
Your production database is precious. You must prepare carefully before deploying.
Separate databases per environment
Never share the same database for dev and production.
Typical layout:
| Environment | Database name | Purpose |
|---|---|---|
| Dev | myapp_dev | Local development |
| Staging | myapp_staging | Pre-production tests |
| Production | myapp or myapp_prod | Real users and real data |
Your configuration should point to the right database based on ENV.
Example:
ENV = os.getenv("ENV", "development")
if ENV == "production":
DATABASE_URL = os.getenv("DATABASE_URL") # must be set
elif ENV == "staging":
DATABASE_URL = os.getenv("STAGING_DATABASE_URL")
else:
DATABASE_URL = "sqlite:///./dev.db"Migrations must be tested
If you use an ORM with migrations (for example Alembic with SQLAlchemy), you should:
- Generate and review migrations for your schema changes.
- Apply them to a test or staging database.
- Verify that the application works against the migrated schema.
- Only then apply migrations to production.
Typical production deployment sequence:
- Pull new code.
- Apply database migrations.
- Restart or reload the application.
The key point is: you cannot safely change tables manually in production and hope nothing breaks. Migrations are your version control for the database.
Rule: Always run migrations on a non-production environment first, then on production. Never experiment directly on the production database.
Validating Environment and Dependencies
Your app runs in a context that includes:
- A specific Python version.
- A set of installed packages with exact versions.
- Operating system libraries and tools.
In production, you want this to be predictable.
Locking dependencies
In development, it is common to install packages with:
pip install fastapi uvicorn[standard] sqlalchemyIn production, you want a lock file:
requirements.txt- or
poetry.lock - or
Pipfile.lock
Example requirements.txt:
fastapi==0.111.0
uvicorn[standard]==0.30.0
SQLAlchemy==2.0.31
psycopg2-binary==2.9.9
python-dotenv==1.0.1Now production can run:
pip install -r requirements.txtThis ensures you get exactly the versions you have tested.
Python version compatibility
You should know which Python version your code requires, for example:
- Python 3.10+
- Python 3.11 only
You declare this in:
pyproject.toml, orsetup.cfg, or- simply your deployment documentation / Dockerfile.
Example Dockerfile snippet:
FROM python:3.11-slim
# ...If you rely on features from Python 3.11, do not run on 3.9 in production.
Health checks before deployment
Before sending users to a new version, you can run simple checks:
- Import tests: Can the app start without ImportErrors?
- Configuration tests: Are required environment variables set?
- Dependency tests: Do external services respond?
For example, you might add a simple script:
# scripts/check_env.py
import os
required = ["DATABASE_URL", "SECRET_KEY"]
missing = [name for name in required if not os.getenv(name)]
if missing:
raise SystemExit(f"Missing required env vars: {', '.join(missing)}")
print("Environment looks OK")Then in your deployment pipeline, run:
python scripts/check_env.pyIf it fails, you know configuration is invalid before switching traffic.
Logging and Observability Preparation
In development, you often log to the console and look at stack traces directly. For production, you need logs that are:
- Centralized
- Structured
- Persistent
This is covered in detail in the Logging and Monitoring section, but some preparation is part of production readiness.
Log format and levels
You should decide:
- Where to log: stdout, files, or a logging service.
- What log level:
INFOorWARNINGorERRORfor production. - Log format: plain text or JSON.
Common pattern:
- Development:
DEBUGlevel, pretty output. - Production:
INFOorWARNING, structured logs.
Example setup:
import logging
import os
ENV = os.getenv("ENV", "development")
log_level = logging.DEBUG if ENV == "development" else logging.INFO
logging.basicConfig(
level=log_level,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("myapp")Now in your code:
logger.info("User logged in", extra={"user_id": user.id})
logger.error("Payment failed", extra={"order_id": order.id})Even if you do not yet use a log aggregator, having consistent logs from the start saves you time later.
Health endpoints
A very helpful production preparation is to add a health check endpoint, for example:
GET /healthGET /status
This endpoint should:
- Return
200 OKif the app is ready. - Optionally check connectivity to the database, cache, etc.
- Return simple JSON.
Example in FastAPI:
from fastapi import FastAPI
from sqlalchemy.exc import SQLAlchemyError
app = FastAPI()
@app.get("/health")
async def health():
try:
# try a simple DB query if appropriate
# db_session.execute("SELECT 1")
db_ok = True
except SQLAlchemyError:
db_ok = False
status = "ok" if db_ok else "degraded"
return {"status": status, "database": db_ok}Your load balancer or orchestration platform (Kubernetes, Docker Compose, etc) can use this endpoint to know when the app is ready to receive traffic.
Static Files and Assets
If your backend serves static files (CSS, JS, images), you must think about how this works in production.
Typical options:
- Backend app serves static files directly (simple, but less efficient).
- Reverse proxy (like Nginx) serves static files from disk.
- A CDN serves static files from object storage.
Preparation steps:
- Choose where static files will live in production.
- Make sure your build process (if you have a frontend) produces files into a consistent directory, for example
static/orpublic/. - Configure your application or server to serve from that directory.
Example FastAPI snippet:
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
In production, you might instead configure Nginx to serve /static by itself and let the app process only API routes.
Also think about:
- Cache headers: you usually want static files to be cacheable.
- Versioning: use hashed filenames or a versioned path, such as
/static/v1/....
Proper static file handling is important for performance and for avoiding broken frontend assets after deployment.
Performance Pre-checks
You do not need full performance optimization before you even have users, but you should at least avoid obvious performance problems.
Small load tests
Before production, you can run a simple load test:
- Send a few hundred or thousand requests to your main endpoints using tools like
ab,wrk, ork6. - Observe:
- Do you get errors?
- Does latency explode?
- Does CPU or memory usage spike?
Example ab command:
ab -n 500 -c 20 http://localhost:8000/healthThis sends 500 requests with concurrency 20.
If your app crashes under this small load, it is not ready for production.
CPU bound vs I/O bound
A very basic check:
- Do you have long-running CPU bound operations in request handlers, such as heavy image processing or large PDF generation?
- If yes, consider:
- Moving them to background jobs.
- Offloading to a worker queue.
If you ignore this, a single expensive request can block your server for other users.
You will learn more about performance in later chapters, but at minimum, you should know which operations are heavy and have a plan for them.
Graceful Error Handling
In development, you may let errors crash the app and show full tracebacks in the browser. In production, this is not acceptable.
You must:
- Catch expected errors and return appropriate HTTP responses.
- Hide internal details from users.
- Log enough information for debugging.
Example for FastAPI:
from fastapi import HTTPException
@app.get("/items/{item_id}")
async def get_item(item_id: int):
item = await repo.get_item(item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return itemAnd a global exception handler (simplified):
from fastapi import Request
from fastapi.responses import JSONResponse
import logging
logger = logging.getLogger(__name__)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled error", exc_info=exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)This way:
- The user sees a generic message.
- You see the stack trace and context in logs.
Pre-deployment Checklist
Before sending any backend application to production, you should walk through a checklist.
Here is a simple starting point you can adapt.
Production readiness checklist
Configuration
- [ ] No secrets or production URLs are hardcoded in the codebase.
- [ ] All required environment variables are documented.
- [ ]
ENVor similar flag is set toproductionfor production.
Security - [ ] Debug mode is disabled in production.
- [ ] CORS configuration is restrictive, not
*for everything. - [ ] Cookies and session settings are secure for production.
- [ ] HTTPS will be used for all real user traffic.
Database - [ ] Dev, staging, and production each have their own database.
- [ ] Database migrations are generated and reviewed.
- [ ] Migrations pass on a non-production environment.
- [ ] Backups are configured (covered in a later chapter, but should be planned).
Dependencies - [ ] Python version is defined and supported.
- [ ] Dependencies are pinned in a lock file (
requirements.txtor similar). - [ ] The production environment installs dependencies from the lock file.
Logging and monitoring - [ ] Logging is configured with appropriate level (
INFOorWARNING). - [ ] Logs include timestamps and severity.
- [ ] A
/healthor/statusendpoint exists and returns 200 when the app is ready.
Static files - [ ] Strategy for serving static files is defined (app, reverse proxy, or CDN).
- [ ] Static files build/output directory is consistent between envs.
Performance and robustness - [ ] Basic load test shows the app can handle small concurrent load without crashing.
- [ ] Long-running CPU bound tasks are identified and, if needed, offloaded to background workers.
- [ ] Common error cases are handled gracefully with proper status codes.
You do not need a perfect system to start, but going through a checklist like this greatly reduces the chance of painful surprises after deployment.
Putting It All Together
Preparing an application for production is a mindset: you stop thinking like “a single developer running code” and start thinking like “an operator of a live system used by real people.”
In practical terms, you:
- Separate configuration from code, especially secrets.
- Use environment specific settings and disable debug features in production.
- Prepare, test, and manage your database schema through migrations.
- Freeze dependencies and define supported runtime versions.
- Configure logging and basic observability, including a health endpoint.
- Clarify how static files, performance concerns, and graceful error handling are managed.
- Use a checklist so you do not forget critical steps.
In the following chapters, you will see how to apply these ideas on a real Linux server and with Docker, and how to handle domains and HTTPS for a complete production deployment.
Views: 7
KAHIBARO