Settings organisieren

Normalerweise haben wir mehrere Umgebungen, in denen das Projekt (später) betrieben wird: die lokale Umgebung, eine Staging-Umgebung, Produktion usw. Jede Umgebung kann ihre eigenen spezifischen Einstellungen haben (zum Beispiel: DEBUG = True, ausführlichere Protokollierung, zusätzliche Apps usw.) . Um das zu ermöglichen, brauchen wir eine bessere Organisation der Settings.

Zusätzlich haben wir noch verschiedene Passwörter für Datenbanken, Secret-Keys und sonstige Einstellungen.

12 Factors

12 Factors ist eine Sammlung von Empfehlungen zum Erstellen verteilter Web-Apps, die in der Cloud einfach bereitzustellen und zu skalieren sind.

Es behandelt neben Themen wie Codebase, Dependencies, Logs, auch das Thema Configuration. Eine der Hauptregeln ist, dass Konfiguration im Environment zu liegen hat, um Konfiguration von Code zu trennen. Passwörter werden also nicht hardcodiert in die Settings geschrieben, sondern als Umgebungsvariable abgelegt.

mehr Infos dazu hier: https://12factor.net/

Auslagern von sensitiven Daten in Umgebungsvariablen

Wir verwenden hierfür Umgebungsvariablen in Kombination mit dem Paket django-environ. Alternativ könnten Werte auch direkt über os.environ ausgelesen werden, jedoch bietet django-environ eine deutlich komfortablere API, z. B. durch Typkonvertierung und sinnvollere Fehlerbehandlung bei fehlenden Variablen.

django-environ

django-environ ist ein Python-Paket, das das Auslesen und Verarbeiten von Umgebungsvariablen vereinfacht und sich an den Prinzipien der Twelve-Factor App orientiert.

Zusätzlich unterstützt es das Einlesen einer .env-Datei, in der Konfigurationswerte hinterlegt werden können. Diese kann sowohl in der lokalen Entwicklung als auch in Deployment-Umgebungen verwendet werden, um Konfiguration zentral und unabhängig vom Code zu verwalten. In containerisierten Umgebungen wie Docker werden solche Variablen häufig über .env-Dateien oder direkt über die Container-Konfiguration (z. B. docker compose) bereitgestellt.

Sind entsprechende Umgebungsvariablen bereits im Betriebssystem definiert, werden diese standardmäßig bevorzugt und überschreiben die Werte aus der .env-Datei.

Mehr zu Django Environ: https://django-environ.readthedocs.io/en/latest/

Warnung

Sensible Daten

Sensitive Daten dürfen auf gar keinen Fall in die Versionskontrolle. Dazu gehören insbesondere Zugangsdaten, API-Keys und geheime Schlüssel.

Auch .env-Dateien enthalten in der Regel solche Informationen und müssen daher zwingend in .gitignore eingetragen werden.

Beispiel für die .gitignore:

.env
*.env

Bereits versionierte Dateien müssen zusätzlich aus dem Repository entfernt werden, da ein nachträglicher Eintrag in .gitignore diese nicht automatisch löscht.

Dabei ist zu beachten, dass das Entfernen einer Datei aus dem aktuellen Stand die Commit-Historie nicht verändert – sensible Daten bleiben weiterhin in früheren Commits erhalten.

Wurden Zugangsdaten oder Schlüssel bereits veröffentlicht, müssen diese als kompromittiert betrachtet und zwingend rotiert (z. B. Passwörter ändern, API-Keys neu erzeugen) werden.

Umgebungsvariablen unter Windows oder Linux

Mit folgenden Kommandos können die aktuellen Umgebungsvariablen unter Windows und Linux ausgelesen werden.

Linux:

env

Windows Powershell:

Get-ChildItem Env:

django-environ installieren

Wir installieren das Paket django-environ mit uv:

uv add django-environ

.env Datei

Wir legen die Datei event_manager/.env an (also im Projektroot auf der gleichen Ebene wie die manage.py) und füllen sie mit folgendem Inhalt:

DEBUG=True
SECRET_KEY=29309239stable09jalsdf02309238stable0239840
ALLOWED_HOSTS=127.0.0.1,localhost

Hier sehen wir die Datei .env auf der selben Ebene wie die manage.py:

event_project
   ├── event_manager
       ├── db.sqlite3
       ├── event_manager
       ├── .env
       ├── env.example
       ├── events
       ├── manage.py
       ....

Zeitgleich zur .env-Datei haben wir auch noch eine env.example angelegt. Diese sollte im Gegensatz zur .env versioniert werden und anderen Entwicklern zeigen, welche Inhalte die .env-Datei benötigt. Ein Beispiel könnte so aussehen:

# SECURITY WARNING: don't run with the debug turned on in production!
DEBUG=True

# comma separated list with valid hosts
ALLOWED_HOSTS=localhost,127.0.0.1

# Should robots.txt allow everything to be crawled?
ALLOW_ROBOTS=False

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY=secret

# A list of all the people who get code error notifications.
ADMINS="John Doe <john@example.com>, Mary <mary@example.com>"

# By default, Django will send system email from root@localhost.
# However, some mail providers reject all email from this address.
SERVER_EMAIL=webmaster@example.com

Diese Beispiel-Datei bietet nicht nur Default-Werte, sondern zeigt durch Kommentare auch gleich an, was die einzelnen Konfigurationswerte im Einzelnen sind.

Hinweis: Dateien mit einem Punkt davor gelten unter unixoiden Betriebssystemen als unsichtbar. Um sie beim Auflisten zu sehen, muss man, je nach verwendetem Betriebssystem und Software die entsprechende Einstellung vornehmen. So lassen sich unter Linux mit dem ls -Kommando Dateien mit einem Punkt nur anziegen, wenn man noch das entstprechende Argument angibt:

ls -al

In diese .env-Datei kommen alle sensitiven Daten, die das Projekt benötigt. Passwörter für die Datenbank, Secret-Key, Port-Angaben und so weiter. Diese Datei wird nicht versioniert, dh. jeder, der das Repository klont, benötigt ebenfalls wieder seine eigene .env-Datei.

Damit man weiß, was in dieser Datei drinzustehen hat, bietet es sich an, eine env.example mit dummy-Inhalt anzulegen. Es sollte darauf geachtet werden, diese Beispieldatei ebenfalls up to date zu halten und mit der eigentlichen .env-Datei zu synchronisieren, damit sie als Vorlage für die anderen Entwickler dienen kann.

environ importieren

Wir importieren das Modul in der settings.py und erstellen ein env-Objekt.

import environ

env = environ.Env()
environ.Env.read_env(BASE_DIR / ".env")

DEBUG = env.bool("DEBUG", default=False)
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")

Das env-Objekt stellt verschiedene Methoden bereit, um Umgebungsvariablen direkt in den passenden Datentyp zu konvertieren. Mit env.bool(...) wird ein Wahrheitswert (True/False) eingelesen, env(...) liefert den Wert als String, und env.list(...) wandelt eine kommaseparierte Zeichenkette in eine Python-Liste um. Dadurch entfällt manuelle Umwandlungslogik, und Konfigurationswerte können typsicher und kompakt verarbeitet werden.

Wir weisen hier der DEBUG-Konstante einen Default-Wert zu, nämlich False. Sollte sich diese KONSTANTE nicht in der .env-Datei befinden, wird der Defaultwert genommen. Sollte kein Defaultwert angegeben worden sein, gibt es einen Key-Error, sollte sich die KONSTANTE nicht in .env befinden.

.env ignorieren

Wir fügen .env in die .gitignore-Datei ein, damit diese Datei nicht versioniert wird und Passwörter am Ende in einem frei zugänglichen Repository landen.

Wenn wir jetzt den runserver starten, sollte das Projekt fehlerfrei laufen.

Gehen wir also jetzt zum nächsten Schritt.

Settings-Datei für jede Umgebung

Jede Umgebung, in der das Projekt betrieben wird, hat andere Einstellung: eine andere Datenbank, ein anderer Logger usw. Im Produktivbetrieb sollte zum Beispiel die django-Debugtoolbar nicht in den MIDDLEWARE stehen.

Die TEMPLATES-Liste hingegen wird u.U. auch im Produktivbetrieb die gleiche sein, wie im lokalen Betrieb.

Wir sehen also: es gibt Konfigurationen in den settings.py, die für alle Umgebungen gelten, und manche nur für spezielle.

Für einfache Projekte reicht oft eine einzelne settings.py in Kombination mit Umgebungsvariablen aus.

Sobald jedoch mehrere Umgebungen (z. B. Entwicklung, Staging, Produktion) ins Spiel kommen, kann es sinnvoll sein, die Einstellungen aufzuteilen. Dabei werden gemeinsame Einstellungen in eine Basisdatei ausgelagert und umgebungsspezifische Anpassungen in separaten Dateien definiert.

Diese Struktur ist optional und wird typischerweise erst bei größeren Projekten relevant.

Unsere Gliederung der Settings wird also so sein, dass es eine Base-Settings gibt, von der alle anderen Settings erben und die überschreiben oder anreichern kann.

Settings organisieren

ein Verzeichnis für die Settings

Wir legen ein neues Verzeichnis an: event_manager/event_manager/settings, in welches die Settings für die verschiedenen Umgebungen gespeichert werden. Die alte settings.py nennen wir temporär um in settings_old.py, da wir diese nicht mehr benötigen und später gleich löschen werden.

Settings-Dateien

In das event_manager/event_manager/settings-Verzeichnis legen wir drei neue Dateien: base.py, dev.py und prod.py. Wenn später noch eine weitere Umgebung dazukommt, kann diese hier angelegt werden.

event_manager
    ├───event_manager
         ├───settings
               ├─base.py
               ├─dev.py
               ├─prod.py

Inhalt der base.py

Diese Inhalte sind die Einstellungen, die in allen Umgebungen benötigt werden. Alle sensiblen Inhalte kommen über die Umgebungsvariablen. Wir kopieren jetzt alle Einstellungen aus der alten settings_old.py in die base.py und entfernen alle Einstellungen, die wir später in den dev- oder prod-Settings überschreiben wollen. Das sind im aktuellen Fall alle Einsetellungen, die mit der Django-Debugtoolbar zu tun haben, also die Middleware, die App und die Konfiguration der Debugtoolbar. Zusätzlich muss das BASE_DIR angepasst werden, da sie sich in jetzt in einem Unterordner befindet.

So sollte sie dann aussehen:

from pathlib import Path
import environ


BASE_DIR = Path(__file__).resolve().parent.parent.parent

env = environ.Env()
environ.Env.read_env(BASE_DIR / ".env")

DEBUG = env.bool("DEBUG", default=False)
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")

INSTALLED_APPS = [
    "user",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "crispy_bootstrap5",
    "crispy_forms",
    "events",
    "pages",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "event_manager.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "event_manager" / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "event_manager.wsgi.application"

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
    },
]

TIME_ZONE = "Europe/Berlin"
USE_I18N = True
USE_L10N = True
USE_TZ = True

CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bootstrap5",)

# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"


# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "user.User"

Man beachte das BASE_DIR: Es wird jetzt eine Ebene höher gesucht, da die Settings sich jetzt in einem Unterordner befinden. Deshalb brauchen wir dreimal parent statt zweimal, wie es vorher der Fall war.

from pathlib import Path
import environ


BASE_DIR = Path(__file__).resolve().parent.parent.parent

env = environ.Env()
environ.Env.read_env(BASE_DIR / ".env")

DEBUG = env.bool("DEBUG", default=False)
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")

INSTALLED_APPS = [
    "user",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "crispy_bootstrap5",
    "crispy_forms",
    "events",
    "pages",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "event_manager.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "event_manager" / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "event_manager.wsgi.application"

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
    },
]

TIME_ZONE = "Europe/Berlin"
USE_I18N = True
USE_L10N = True
USE_TZ = True

CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bootstrap5",)

# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"


# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "user.User"

Inhalt der dev.py

Diese Datei importiert alle Einstellungen der base.py, überschreibt aber bei Bedarf diejenigen Einstellungen, die im Entwicklungsbetrieb anderes sein müssen.

Wir haben die Django-Debugtoolbar in die dev.py übertragen, da sie nur im Entwicklungsbetrieb benötigt wird. Alle Einstellungen, die mit der Debugtoolbar zu tun haben, werden also in die dev.py verschoben.

# pyright: reportWildcardImportFromLibrary=false, reportUndefinedVariable=false
# ruff: noqa: F403, F405
from event_manager.settings.base import *


# für Debug-Toolbar
INTERNAL_IPS = ("127.0.0.1",)

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

MIDDLEWARE.extend(["debug_toolbar.middleware.DebugToolbarMiddleware"])

INSTALLED_APPS.extend(
    [
        "debug_toolbar",
    ]
)


DEBUG_TOOLBAR_CONFIG = {
    "INTERCEPT_REDIRECTS": False,
}

Beim Einsatz von from .base import * kann es vorkommen, dass Typechecker und Linter Warnungen ausgeben, da die importierten Namen für statische Analysen nicht eindeutig nachvollziehbar sind. Für einfache Settings-Strukturen ist dieser Ansatz jedoch üblich und in der Praxis unproblematisch.

Wer Tools wie Pyright oder Ruff verwendet, kann diese Warnungen gezielt für die Datei deaktivieren:

# pyright: reportWildcardImportFromLibrary=false, reportUndefinedVariable=false
# ruff: noqa: F403, F405

Damit bleiben die Settings übersichtlich, ohne dass unnötige Warnungen die Entwicklung stören.

Die settings_old.py können wir nun löschen, wenn wir alles übertragen haben.

Best Practice: Settings-Dateien versionieren

Das aktuelle Setup ermögtlicht uns, dass auch jeder User eine eigene Settings Datei haben könnte, also zb. tom.py für den User tom. Trotzdem ist es ratsam, diese privaten, lokalen dev-Settings nicht von der Versionierung auszuschließen. Miskonfigurationen oder bad-pratices können so schneller auffallen.

Wenn wir jetzt den Runserver starten, bekommen wir einen Fehler:

(eventenv) python manage.py runserver
CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False

Bekanntmachen des settings-Folders

Django weiß von unserer neuen Struktur natürlich nichts. Per default wird nach einer Datei settings.py im Projektverzeichnis gesucht. Das müssen wir ändern, da es sonst zu einem Fehler kommt.

Django benötigt die Umgebungsvariable DJANGO_SETTINGS_MODULE, um zu wissen, welche Einstellungsdatei geladen werden soll (z. B. event_manager.settings.dev oder event_manager.settings.prod). Diese Variable kann je nach Umgebung unterschiedlich gesetzt werden, etwa in der Shell, im Deployment oder beim Start des Servers.

Ist die Variable nicht gesetzt, kann im Code ein Standardwert definiert werden, meist in manage.py oder wsgi.py:

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "event_manager.settings.dev")

Das bedeutet: Nur wenn die Umgebungsvariable noch nicht existiert, wird auf die angegebenen Default-Settings zurückgegriffen. Dadurch ist sichergestellt, dass das Projekt lokal ohne zusätzliche Konfiguration startet, während in produktiven Umgebungen gezielt andere Settings verwendet werden können.

In produktiven Setups wird die Variable in der Regel explizit gesetzt, z. B. beim Start von Gunicorn oder innerhalb von Docker. In einer docker-compose.yml kann dies beispielsweise über den Abschnitt environment erfolgen. Dadurch wird sichergestellt, dass immer die korrekten Produktions-Settings geladen werden und keine versehentliche Nutzung von Entwicklungs-Konfigurationen erfolgt.

manage.py

Wir öffnen die Datei event_manager/manage.py und ersetzen in der main()-Funktion eine Zeile:

def main():
  ...

  # alt
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "event_manager.settings")

  # neu mit Defaultwert auf dev-Settings
  os.environ.setdefault("DJANGO_SETTINGS_MODULE", "event_manager.settings.dev")

  ...

Damit stellen wir sicher, dass ohne gesetzte Umgebungsvariable automatisch die Entwicklungs-Settings geladen werden, während in anderen Umgebungen weiterhin gezielt alternative Konfigurationen verwendet werden können.

wsgi.py

Auch beim Start über einen WSGI-Server wird das Einstellungsmodul über die Umgebungsvariable DJANGO_SETTINGS_MODULE bestimmt.

Die wsgi.py enthält – analog zu manage.py – lediglich einen Default-Wert über os.environ.setdefault(...). In produktiven Umgebungen wird dieser jedoch üblicherweise von außen überschrieben, sodass keine weitere Anpassung notwendig ist.

Deshalb müssen wir die event_manager/event_manager/wsgi.py anpassen:

# alt
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "event_manager.settings")

# neu
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "event_manager.settings.dev")

Wenn jetzt der Runserver gestartet wird, sollte alles funktionieren. Wenn wir in der Django-Debugtoolbar auf Einstellungen gehen, sehen wir, dass die aktuelle Settings-Datei wie gewünscht unsere event_manager.settings.dev ist.

Auf der shell kann man sich die Settings so angucken:

>>> from django.conf import settings
>>> settings.__dict__

In einem späteren Kapitel werden wir noch die Django-Extensions kennenlernen. Dort ist ein Subkommando definiert, welches genau für diesen Zweck implementiert wurde:

python manage.py print_settings

Fazit

Wir haben nun unsere Settings so angepasst, dass für jede Umgebung, produktiv oder lokal, eine eigene Settings-Datei angelegt werden kann. Man muss im Betrieb nur darauf achten, den Pfad zu den Settings in der .env-Datei zu definieren. Fehlt dieser Eintrag, gibt es bewusst keinen Fallback via einem Defaultwert, sondern Django bricht den Bootvorgang ab.