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

Browser, Nginx, the web container, PostgreSQL and a one-off migrate job
Browser, Nginx, the web container, PostgreSQL and a one-off migrate job
ConcernDevelopmentProduction
DEBUGTrueFalse
Secretsin settings.pyenvironment variables, never in the image
Serverrunservergunicorn behind Nginx
Static filesrunserver serves themcollectstatic + WhiteNoise
DatabaseSQLite or a local containerPostgreSQL with a named volume
HTTPSnoneNginx (or another proxy) ends TLS
Migrationsby handone 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:

python
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))
text
DEBUG = False
ALLOWED_HOSTS = ['example.com', 'www.example.com']
USE_TLS = True

Split settings into base.py, dev.py and prod.py (settings and environments); production imports the base and overrides:

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

bash
# .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 localhost to DJANGO_ALLOWED_HOSTS if the container health check calls http://localhost:8000/...; otherwise Django answers 400 DisallowedHost to your own probe.

HTTPS and the deployment checks