Earlier topics covered the parts separately: a Dockerfile, compose with PostgreSQL and Redis, gunicorn and static files and Nginx with HTTPS. This topic assembles them into one repeatable release process: configuration from the environment, security settings behind a proxy, WhiteNoise, a multi-stage image, a compose file where migrations run once per release, health probes, safe schema changes and a CI/CD pipeline with rollback.
From dev to prod
| Concern | Development | Production |
|---|---|---|
DEBUG | True | False |
| Secrets | in settings.py | environment variables, never in the image |
| Server | runserver | gunicorn behind Nginx |
| Static files | runserver serves them | collectstatic + WhiteNoise |
| Database | SQLite or a local container | PostgreSQL with a named volume |
| HTTPS | none | Nginx (or another proxy) ends TLS |
| Migrations | by hand | one job per release |
The rule behind most decisions: the image holds code, the environment holds configuration, volumes hold data. The same image must run in staging and production with only the environment changed.
Configuration from the environment
Read every environment-specific value from environment variables. A missing secret should stop the process immediately: os.environ["NAME"] raises KeyError, which is what you want in production. For optional values write small helpers, because bool("False") is True and the string "0" is truthy too:
import os
def env_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name, str(default))
return raw.strip().lower() in {"1", "true", "yes", "on"}
def env_list(name: str, default: str = "") -> list[str]:
raw = os.environ.get(name, default)
return [x.strip() for x in raw.split(",") if x.strip()]
os.environ["DJANGO_DEBUG"] = "0"
os.environ["DJANGO_ALLOWED_HOSTS"] = "example.com, www.example.com"
print("DEBUG =", env_bool("DJANGO_DEBUG"))
print("ALLOWED_HOSTS =", env_list("DJANGO_ALLOWED_HOSTS"))
print("USE_TLS =", env_bool("USE_TLS", True))DEBUG = False
ALLOWED_HOSTS = ['example.com', 'www.example.com']
USE_TLS = TrueSplit settings into base.py, dev.py and prod.py (settings and environments); production imports the base and overrides:
# config/settings/prod.py
import os
import dj_database_url
from .base import * # noqa: F401,F403
from .env import env_bool, env_list
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # KeyError = fail fast
DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
DATABASES = {
"default": dj_database_url.config(
conn_max_age=600, # reuse connections
conn_health_checks=True, # drop dead ones
)
}The .env file lives only on the server (permissions 600, never in git or in the image) and is passed to containers with env_file:
# .env (on the server, chmod 600, never in git or in the image)
DJANGO_SECRET_KEY=replace-with-a-long-random-value
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=shop.example.com,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=https://shop.example.com
DATABASE_URL=postgres://shop:change-me@db:5432/shop
POSTGRES_USER=shop
POSTGRES_PASSWORD=change-me
POSTGRES_DB=shop⚠️ Add
localhosttoDJANGO_ALLOWED_HOSTSif the container health check callshttp://localhost:8000/...; otherwise Django answers400 DisallowedHostto your own probe.