Прошлые темы разбирали части по отдельности: Dockerfile, compose с PostgreSQL и Redis, gunicorn и статику и Nginx с HTTPS. Эта тема собирает их в один повторяемый процесс релиза: конфигурация из окружения, настройки безопасности за прокси, WhiteNoise, многоэтапный образ, compose-файл, где миграции выполняются один раз за релиз, health-пробы, безопасные изменения схемы и конвейер CI/CD с откатом.

От dev к prod

Браузер, Nginx, контейнер web, PostgreSQL и разовая задача migrate
Браузер, Nginx, контейнер web, PostgreSQL и разовая задача migrate
ВопросРазработкаProduction
DEBUGTrueFalse
Секретыв settings.pyпеременные окружения, никогда не в образе
Серверrunservergunicorn за Nginx
Статикараздаёт runservercollectstatic + WhiteNoise
БазаSQLite или локальный контейнерPostgreSQL с именованным volume
HTTPSнетTLS завершает Nginx (или другой прокси)
Миграциивручнуюодна задача за релиз

Правило за большинством решений: образ хранит код, окружение — конфигурацию, volume — данные. Один и тот же образ должен работать в staging и production, меняется только окружение.

Конфигурация из окружения

Все значения, зависящие от среды, читайте из переменных окружения. Отсутствующий секрет должен сразу останавливать процесс: os.environ["NAME"] бросает KeyError, и в production это именно то, что нужно. Для необязательных значений напишите небольшие помощники, потому что bool("False") равно True, а строка "0" тоже истинна:

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

Разделите настройки на base.py, dev.py и prod.py (настройки и окружения); production импортирует базу и переопределяет:

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
    )
}

Файл .env лежит только на сервере (права 600, не в git и не в образе) и передаётся контейнерам через 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

⚠️ Добавьте localhost в DJANGO_ALLOWED_HOSTS, если health check контейнера обращается к http://localhost:8000/...; иначе Django ответит вашей же пробе 400 DisallowedHost.

HTTPS и проверки перед деплоем