Когда Django-проект только начинается, стандартный settings.py вполне устраивает. В нём помещаются база, шаблоны, статика и десяток приложений. Потом появляются Redis, Celery, объектное хранилище, несколько внешних API, отдельные настройки для тестов — и файл превращается в простыню, где изменение SMTP может случайно задеть production-базу.
Я недавно ещё раз прошёл этот путь на живом проекте и собрал схему, которая не разваливается после первых трёх окружений. В статье покажу, как устроить настройки через django-split-settings, где библиотека действительно помогает, а где легко создать проблему, которой не было в одном файле.
Материал рассчитан на разработчиков, которые уже запускали Django, но отдельные решения я буду объяснять с нуля.
Что делает django-split-settings
Обычные настройки Django — это Python-модуль. Django импортирует его по пути из DJANGO_SETTINGS_MODULE и забирает переменные, чьи имена написаны в верхнем регистре.
django-split-settings не создаёт новую систему конфигурации. Функция include() последовательно читает несколько Python-файлов и выполняет их в общем namespace. Поэтому следующий файл видит всё, что объявили предыдущие:
from split_settings.tools import include include( "components/apps.py", "components/database.py", "components/templates.py", )
Это одновременно главное достоинство и главный источник сюрпризов. Мы можем разнести большой файл по смыслу и по-прежнему получить обычный settings-модуль. Но порядок подключения становится частью контракта.
Установка:
pip install django-split-settings
На момент написания статьи стабильная версия — 1.3.2. В опубликованной таблице совместимости официально перечислены Django 4.2–5.1; поддержка Django 6.0 уже есть в Unreleased. Если проект работает на Django 6.0, я бы не ставил пакет вслепую: закрепил версию, прогнал startup-тесты и следил за следующим релизом. Сама библиотека маленькая и почти не связана с внутренностями Django, но «скорее всего работает» и «официально поддерживается» — разные утверждения.
Структура, к которой будем идти
config/ ├── env.py ├── settings/ │ ├── __init__.py │ ├── _base.py │ ├── project.py │ ├── dev.py │ ├── prod.py │ ├── test.py │ └── components/ │ ├── apps.py │ ├── auth.py │ ├── cache.py │ ├── celery.py │ ├── database.py │ ├── logging.py │ ├── security.py │ ├── storage.py │ └── templates.py ├── asgi.py ├── celery.py └── wsgi.py
Здесь есть два измерения:
components/отвечает на вопрос «какую подсистему настраиваем?»;dev.py,prod.py,test.pyотвечают на вопрос «где запускаемся?».
Не стоит делать отдельные файлы dev_database.py, prod_database.py, test_database.py для каждого компонента. Такая матрица быстро разрастается, и понять итоговую настройку становится сложнее, чем в исходном settings.py.
Правило №1: никакого glob для обязательных компонентов
Очень хочется написать так:
include("components/*.py")
Но документация прямо предупреждает: порядок соответствует результату glob, а он не обязан быть алфавитным. На ноутбуке apps.py может загрузиться до middleware.py, а внутри другого образа — наоборот. Пока файлы независимы, мы ничего не замечаем. Затем один компонент начинает дополнять словарь из другого, и получаем плавающий startup-баг.
Я предпочитаю скучный явный список:
# config/settings/_base.py from split_settings.tools import include from config.settings import project as project_settings BASE_DIR = project_settings.BASE_DIR DEBUG = project_settings.DEBUG ENVIRONMENT = project_settings.ENVIRONMENT include( "components/apps.py", "components/auth.py", "components/cache.py", "components/celery.py", "components/database.py", "components/logging.py", "components/security.py", "components/storage.py", "components/templates.py", )
А в config/settings/project.py остаются только базовые константы, которые нужны нескольким компонентам:
from pathlib import Path from django.core.exceptions import ImproperlyConfigured from config.env import env_bool, get_environment BASE_DIR = Path(__file__).resolve().parent.parent.parent ENVIRONMENT = get_environment() DEBUG = env_bool("DEBUG", default=ENVIRONMENT == "dev") if ENVIRONMENT == "prod" and DEBUG: raise ImproperlyConfigured( "DEBUG must be disabled when DJANGO_ENV=prod" )
Да, при добавлении компонента придётся изменить ещё одну строку. Зато порядок виден на code review и одинаков на любой файловой системе.
Полноценный модуль на каждое окружение
Общий набор сам по себе не является точкой запуска. Его подключают окружения.
# config/settings/dev.py import os from split_settings.tools import include from config.env import ( env_list, get_environment, validate_settings_module_environment, ) include("_base.py") validate_settings_module_environment( os.getenv("DJANGO_SETTINGS_MODULE"), get_environment(), ) DEBUG = True ALLOWED_HOSTS = env_list("ALLOWED_HOSTS") or ["localhost", "127.0.0.1"] CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "project-development", } }
# config/settings/prod.py import os from split_settings.tools import include from config.env import ( env_list, get_environment, validate_settings_module_environment, ) include("_base.py") validate_settings_module_environment( os.getenv("DJANGO_SETTINGS_MODULE"), get_environment(), ) DEBUG = False ALLOWED_HOSTS = env_list("ALLOWED_HOSTS")
# config/settings/test.py from split_settings.tools import include include("_base.py") DEBUG = False PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "project-tests", } }
Теперь любой модуль можно проверить независимо:
DJANGO_ENV=dev python manage.py check --settings=config.settings.dev DJANGO_ENV=dev python manage.py check --settings=config.settings.test DJANGO_ENV=prod python manage.py check --deploy --settings=config.settings.prod
Последняя команда особенно важна. Обычный check не включает полный набор проверок развёртывания.
Окружение и DEBUG — не одно и то же
Распространённая конструкция выглядит так:
if DEBUG: include("dev.py") else: include("prod.py")
Она удобная, но смешивает два разных понятия. DEBUG управляет диагностическим режимом Django. Окружение определяет базу, cache, очереди, внешние адреса и политику хранения файлов.
Если разработчик временно запустит локальный проект с DEBUG=false, он не должен внезапно получить production-настройки. И наоборот, DEBUG=true на сервере не должен переключать инфраструктуру в dev — такой запуск нужно сразу запретить.
Я использую явный DJANGO_ENV. Ниже не псевдокод, а содержимое отдельного файла config/env.py, которое можно проверить обычными unit-тестами:
import os from django.core.exceptions import ImproperlyConfigured SETTINGS_MODULES = { "dev": "config.settings.dev", "prod": "config.settings.prod", } def get_environment() -> str: value = os.getenv("DJANGO_ENV", "dev").strip().casefold() if value not in SETTINGS_MODULES: raise ImproperlyConfigured(f"Unknown DJANGO_ENV: {value!r}") return value def get_settings_module() -> str: return SETTINGS_MODULES[get_environment()] def validate_settings_module_environment( selected_module: str | None, environment: str, ) -> None: if selected_module not in SETTINGS_MODULES.values(): return expected_module = SETTINGS_MODULES[environment] if selected_module != expected_module: raise ImproperlyConfigured( f"{selected_module!r} conflicts with {environment!r}; " f"expected {expected_module!r}" )
Последняя проверка закрывает неочевидный случай: разработчик запускает --settings=config.settings.prod, но забывает задать DJANGO_ENV=prod. Без защиты приложение может выбрать production-модуль и при этом прочитать dev env-файл. Это хуже явного падения, потому что итоговая конфигурация выглядит правдоподобно.
И использую один helper во всех точках входа:
# config/wsgi.py; в asgi.py и celery.py принцип тот же import os from django.core.wsgi import get_wsgi_application from config.env import get_settings_module os.environ.setdefault("DJANGO_SETTINGS_MODULE", get_settings_module()) application = get_wsgi_application()
В контейнере лучше быть ещё прямолинейнее:
ENV DJANGO_ENV=prod \ DJANGO_SETTINGS_MODULE=config.settings.prod
Дублирование здесь полезно: неверная комбинация должна обнаруживаться при старте, а не после первого запроса пользователя.
Типизированные переменные окружения и fail-fast
os.environ возвращает строки. Вот этот код почти всегда ошибочен:
DEBUG = bool(os.getenv("DEBUG", False))
bool("false") равен True, потому что строка непустая. Не менее неприятен helper, который считает любое неизвестное значение ложью. Опечатка DEBUG=flase тогда выглядит как корректная конфигурация.
Минимальный строгий parser:
from django.core.exceptions import ImproperlyConfigured TRUE_VALUES = {"1", "true", "yes", "on"} FALSE_VALUES = {"0", "false", "no", "off"} def env_bool(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: return default value = raw.strip().casefold() if value in TRUE_VALUES: return True if value in FALSE_VALUES: return False raise ImproperlyConfigured(f"{name} must be a boolean") def env_int(name: str, default: int) -> int: raw = os.getenv(name) if raw is None or not raw.strip(): return default try: return int(raw) except ValueError as error: raise ImproperlyConfigured(f"{name} must be an integer") from error
Почему падение при старте лучше? Потому что конфигурационная ошибка одна, а последствия бывают распределены по десяткам запросов и фоновых задач. Оркестратор не пропустит нездоровый контейнер, а лог сразу назовёт проблемную переменную.
Для списков тоже стоит централизовать правила:
def env_list(name: str) -> list[str]: return [ item.strip() for item in os.getenv(name, "").split(",") if item.strip() ]
ALLOWED_HOSTS = env_list("ALLOWED_HOSTS") CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS")
Не объединяйте эти параметры в одну переменную. Host header и браузерный origin решают разные задачи безопасности.
Секреты: env для совместимости, файл для production
django-split-settings не является менеджером секретов. Он только организует Python-код. Пароли, токены и SECRET_KEY не должны попадать в компоненты или в репозиторий.
Практичный переходный контракт — поддерживать две формы:
DATABASE_PASSWORD=secret DATABASE_PASSWORD_FILE=/run/secrets/database_password
И запрещать задавать обе одновременно:
from pathlib import Path def read_secret(name: str) -> str: direct = os.getenv(name) file_name = os.getenv(f"{name}_FILE") if direct and file_name: raise ImproperlyConfigured( f"Set only one of {name} and {name}_FILE" ) if file_name: path = Path(file_name) if not path.is_file(): raise ImproperlyConfigured(f"Secret file for {name} is missing") return path.read_text(encoding="utf-8").rstrip("\r\n") if direct: return direct raise ImproperlyConfigured(f"Secret {name} is required")
Файловая форма удобна для Docker secrets, systemd credentials и Vault Agent: приложению всё равно, кто положил значение в файл.
Есть маленький, но реальный подводный камень. Если раньше значение проходило через env_file, а затем его начали самостоятельно копировать в secret-файл, нужно сохранить правила разбора кавычек. В строке PASSWORD="secret" кавычки обычно являются синтаксисом env-файла, а не частью пароля. Самописный генератор, который перенесёт их буквально, получит другой секрет и загадочный 401 от внешнего API.
Компоненты должны оставаться скучными
Хороший компонент описывает конфигурацию и не выполняет работу приложения.
# components/database.py import os from config.env import read_secret DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["POSTGRES_DB"], "USER": os.environ["POSTGRES_USER"], "PASSWORD": read_secret("POSTGRES_PASSWORD"), "HOST": os.environ["POSTGRES_HOST"], "PORT": os.getenv("POSTGRES_PORT", "5432"), "CONN_MAX_AGE": 0, "CONN_HEALTH_CHECKS": True, } }
Чего не должно происходить при импорте settings:
сетевых запросов к внешнему API;
SQL-запросов;
создания bucket, очереди или пользователя;
чтения большого каталога;
«починки» отсутствующей инфраструктуры.
Проверить наличие небольшого secret-файла нормально. Пойти при старте в Vault по HTTP — уже спорно: временная недоступность Vault превратится в падение всех web- и worker-процессов. Обычно секрет доставляет отдельный агент, а Django читает локальный файл.
Как переопределять составные настройки
Поскольку файлы выполняются в общем namespace, окружение может заменить словарь целиком:
# dev.py CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "local-development", } }
Или точечно дополнить значение из предыдущего файла:
# dev.py INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar"] MIDDLEWARE = [*MIDDLEWARE, "debug_toolbar.middleware.DebugToolbarMiddleware"]
Я предпочитаю создавать новый список, а не вызывать append(). Так меньше шансов нечаянно изменить общий mutable-объект при повторных импортах в тестах.
В прикладном коде не надо импортировать собственный settings-модуль:
# Плохо from config.settings.components.storage import MEDIA_BUCKET # Хорошо from django.conf import settings bucket = settings.MEDIA_BUCKET
django.conf.settings — ленивый proxy к выбранной конфигурации. Прямой импорт компонента создаёт второй, частично независимый способ получать настройки и ломает override_settings() в тестах.
Нужен ли optional(“local.py”)
Библиотека умеет подключать необязательные файлы:
from split_settings.tools import include, optional include( "_base.py", optional("local.py"), )
Это удобно для личных экспериментов, если local.py находится в .gitignore. Но я не использую его для обязательной конфигурации. Два разработчика с разными невидимыми local.py способны неделями воспроизводить разные ошибки. Всё, без чего проект не работает, должно быть либо в отслеживаемом settings-компоненте, либо в документированной переменной окружения.
Тестируем не файлы, а контракт
Тест assert DEBUG is False полезен, но не доказывает, что приложение вообще соберётся. Я использую несколько уровней.
1. Django system checks
python manage.py check --settings=config.settings.dev python manage.py check --settings=config.settings.test python manage.py check --deploy --settings=config.settings.prod
Production-команду нужно выполнять с безопасным тестовым набором production переменных, а не с настоящими секретами на ноутбуке.
2. Тесты критических инвариантов
def test_production_never_enables_debug(settings): assert settings.DEBUG is False def test_database_connection_has_timeout(settings): options = settings.DATABASES["default"]["OPTIONS"] assert options["connect_timeout"] > 0 def test_celery_accepts_only_json(settings): assert settings.CELERY_ACCEPT_CONTENT == ["json"]
3. Импорт всех entrypoint
ASGI, WSGI, Celery и manage.py должны выбирать одну конфигурацию. Это легко сломать, обновив только один файл.
def test_wsgi_uses_production_settings(monkeypatch): monkeypatch.setenv("DJANGO_ENV", "prod") monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False) import config.wsgi # noqa: F401 assert os.environ["DJANGO_SETTINGS_MODULE"] == "config.settings.prod"
4. Проверка собранного контейнера
Локальный Python не видит права на mounted secrets, внутренние DNS-имена и различия системных библиотек. Поэтому финальный smoke-тест запускается внутри того же image, который поедет на сервер.
Что делать с предупреждениями check --deploy
Не надо добиваться зелёного вывода любой ценой.
Например, Django справедливо предупреждает об отключённых HSTS и secure cookie. Но если внутренний стенд пока работает по HTTP, включение SESSION_COOKIE_SECURE=True просто сломает авторизацию: браузер не отправит cookie по незащищённому соединению.
Правильная реакция:
зафиксировать предупреждение как известное ограничение;
не скрывать его без объяснения;
сначала поднять TLS на reverse proxy;
затем включить redirect, secure cookie и HSTS поэтапно;
повторить
check --deployи smoke-тест входа.
То же касается USE_TZ. Для нового проекта я выберу timezone-aware datetime. Но переключать старую систему с USE_TZ=False одной строкой нельзя: нужно проверить данные, интеграции, raw SQL, расписания и сравнения дат.
Миграция с большого settings.py без остановки разработки
Я бы делал её маленькими шагами.
Создать пакет
settings/и временную совместимую точку входа.Переносить по одному независимому компоненту, каждый раз выполняя
check.Явно перечислить компоненты в
_base.py.Сделать самостоятельные
dev.py,prod.py,test.py.Перевести
manage.py, ASGI, WSGI и Celery на общий выбор entrypoint.Задать production-модуль явно в контейнере и CI/CD.
Добавить строгие parser-ы env и проверку обязательных значений.
Прогнать
check --deploy, тесты и smoke собранного image.Только после этого удалить старый совместимый маршрут.
Такой порядок позволяет в любой момент остановиться с работающим проектом. Это важнее красивого финального diff.
Вместо вывода
Хорошие настройки не обязаны быть умными. Их задача — одинаково собираться на ноутбуке, в тестах и в контейнере, а при ошибке завершаться рано и с понятным сообщением.
django-split-settings хорошо решает проблему размера и структуры, если помнить три правила:
порядок
include()— часть архитектуры;окружение выбирается явно, а не через
DEBUG;библиотека делит Python-файлы, но не заменяет валидацию env и управление секретами.
После этого settings перестают быть местом, куда страшно заходить перед релизом, и становятся обычным, проверяемым кодом.
P.S. Примеры кода взяты из реального некоммерческого продукта закрытой информационной системы на основе Django 6.0.8 по состоянию кодовой базы на 4 сентября 2026 года.

