ZetCode

Django settings and environment

last modified November 13, 2026 Python 3.15

The file settings.py that startproject generates is fine for the first day. It has a hard-coded secret key, DEBUG = True, and an empty list of allowed hosts, and it is the same file on a laptop and on a server. Real projects need different values in different places and must keep secrets out of the source code. In this article we read settings from environment variables with small typed helpers, split the settings into a common module and one module per environment, keep local values in a .env file, check a configuration with check --deploy, see what DEBUG and ALLOWED_HOSTS change on a running server, rotate the secret key, and test the helpers. The examples use Django 6.1 and Python 3.15.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, start the app core, and create the folder demos for demo scripts.

$ mkdir settings
$ cd settings
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp core
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'core',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The app core has one view, which prints the setting SITE_NAME, the mode, and the host name of the request. The view and the URL configuration are all the application code we need; the topic of the article is the configuration.

core/views.py
from django.conf import settings
from django.http import HttpResponse


def home(request):
    mode = 'debug' if settings.DEBUG else 'production'
    return HttpResponse(f'{settings.SITE_NAME} ({mode}), host {request.get_host()}')

The URL configuration maps the root path to the view.

config/urls.py
from django.urls import path

from core import views

urlpatterns = [
    path('', views.home),
]

The settings are in the single file config/settings.py for now. We split it in the next sections.

Reading environment variables

An environment variable is a string that the operating system gives to a process. It is the conventional way to pass configuration that changes between deployments, and especially secrets, to an application: the code stays the same, and the server, the container, or the service manager provides the values. In Python they are available in the mapping os.environ. Because they are always strings, a setting such as DEBUG needs a conversion, and a missing required variable should stop the start of the application with a clear message instead of failing later. The small module config/env.py provides four helpers that do this. A value that cannot be converted, or a required variable that is missing, raises ImproperlyConfigured, the exception that Django uses for configuration errors. (The packages django-environ and python-decouple offer similar functions, but a few lines of our own are enough.)

config/env.py
import os

from django.core.exceptions import ImproperlyConfigured

MISSING = object()
TRUE_VALUES = {'1', 'true', 'yes', 'on'}
FALSE_VALUES = {'0', 'false', 'no', 'off', ''}


def env_str(name, default=MISSING):
    try:
        return os.environ[name]
    except KeyError:
        if default is MISSING:
            raise ImproperlyConfigured(f'Set the environment variable {name}') from None
        return default


def env_bool(name, default=MISSING):
    value = env_str(name, default)
    if isinstance(value, bool):
        return value
    lowered = value.strip().lower()
    if lowered in TRUE_VALUES:
        return True
    if lowered in FALSE_VALUES:
        return False
    raise ImproperlyConfigured(f'{name} must be a boolean, not {value!r}')


def env_int(name, default=MISSING):
    value = env_str(name, default)
    try:
        return int(value)
    except ValueError:
        raise ImproperlyConfigured(f'{name} must be an integer, not {value!r}') from None


def env_list(name, default=MISSING):
    value = env_str(name, default)
    if isinstance(value, (list, tuple)):
        return list(value)
    return [item.strip() for item in value.split(',') if item.strip()]

The script sets some variables and calls the helpers. The helper for booleans accepts the usual spellings, the helper for lists splits at commas and ignores empty items and spaces, and every helper accepts a default for variables that are optional.

demos/envvars.py
import os

from django.core.exceptions import ImproperlyConfigured

from config.env import env_bool, env_int, env_list, env_str

os.environ.update({
    'TITLE': 'Demo',
    'FLAG_A': 'true', 'FLAG_B': 'Off', 'FLAG_C': 'maybe',
    'PORT': '8080', 'WORKERS': 'many',
    'HOSTS': 'example.com, www.example.com,',
})

print('env_str   :', env_str('TITLE'), '|', env_str('MISSING', default='fallback'))
print('env_bool  :', env_bool('FLAG_A'), env_bool('FLAG_B'), env_bool('MISSING', default=True))
print('env_int   :', env_int('PORT') + 1)
print('env_list  :', env_list('HOSTS'), env_list('MISSING', default=['localhost']))
print()
for call in (lambda: env_str('SECRET'), lambda: env_bool('FLAG_C'), lambda: env_int('WORKERS')):
    try:
        call()
    except ImproperlyConfigured as error:
        print('ImproperlyConfigured:', error)

The three errors at the end are the messages an operator sees when the configuration is wrong.

$ uv run python -m demos.envvars
env_str   : Demo | fallback
env_bool  : True False True
env_int   : 8081
env_list  : ['example.com', 'www.example.com'] ['localhost']

ImproperlyConfigured: Set the environment variable SECRET
ImproperlyConfigured: FLAG_C must be a boolean, not 'maybe'
ImproperlyConfigured: WORKERS must be an integer, not 'many'

Splitting the settings module

A common structure is a package config/settings with a module base.py for everything that is the same in all environments, and one module per environment that imports from it and overrides what differs. The commands below create the package, move the generated file to base.py, and change the module that manage.py uses by default to the development settings. The files wsgi.py and asgi.py, which a production server loads, point to the production settings.

$ mkdir config/settings
$ touch config/settings/__init__.py
$ mv config/settings.py config/settings/base.py
$ sed -i 's/config.settings/config.settings.dev/' manage.py
$ sed -i 's/config.settings/config.settings.prod/' config/wsgi.py config/asgi.py

The module base.py is the generated file without the comments and without the three settings that must differ between environments: SECRET_KEY, DEBUG, and ALLOWED_HOSTS. The path BASE_DIR needs one more .parent because the file is one level deeper. The only setting here that comes from the environment is SITE_NAME, with a default, so the project runs without any variables.

config/settings/base.py
from pathlib import Path

from config.env import env_str

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

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'core',
]

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 = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'

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

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'},
]

LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True

STATIC_URL = 'static/'

SITE_NAME = env_str('SITE_NAME', default='Demo shop')

The development module is short: the debug mode, a fixed key that is clearly not a secret, and the local host names. The star import brings in all uppercase names of the base; Django uses only the uppercase names of a settings module.

config/settings/dev.py
from .base import *

DEBUG = True
SECRET_KEY = 'django-insecure-development-key'
ALLOWED_HOSTS = ['localhost', '127.0.0.1']

The production module has no defaults for the values that matter. SECRET_KEY and ALLOWED_HOSTS are read without a default, so the application does not start if they are missing. The database file, the trusted origins for CSRF checks, and the previous secret keys are optional. The SECURE_ settings turn on the redirect to HTTPS, the HSTS header, and the secure flags of the cookies; the setting SECURE_PROXY_SSL_HEADER tells Django to trust the header X-Forwarded-Proto from a reverse proxy that terminates TLS. Set it only if the proxy always overwrites that header, otherwise a client could fake it.

config/settings/prod.py
from config.env import env_bool, env_int, env_list, env_str

from .base import *

DEBUG = False
SECRET_KEY = env_str('SECRET_KEY')
SECRET_KEY_FALLBACKS = env_list('SECRET_KEY_FALLBACKS', default=[])
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS')
CSRF_TRUSTED_ORIGINS = env_list('CSRF_TRUSTED_ORIGINS', default=[])

DATABASES['default']['NAME'] = env_str('DATABASE_PATH', default=BASE_DIR / 'db.sqlite3')

SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = env_bool('SECURE_SSL_REDIRECT', default=True)
SECURE_HSTS_SECONDS = env_int('SECURE_HSTS_SECONDS', default=31536000)
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Django finds the settings module through the environment variable DJANGO_SETTINGS_MODULE. The line in manage.py uses os.environ.setdefault, so a variable that is already set wins, and the option --settings=... of a management command wins over both. This makes it possible to switch the configuration without touching a file.

How Django reads settings

The object django.conf.settings is a lazy wrapper. On the first access it imports the module named by DJANGO_SETTINGS_MODULE, takes its uppercase attributes, and uses the defaults from django.conf.global_settings for the rest. Application code should always read from django.conf import settings, never import the settings module directly, so that the overrides of the tests and the lazy loading work. The script compares some defaults with our values, reads a custom setting, and shows what happens with a name that does not exist.

demos/tour.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
django.setup()

from django.conf import global_settings, settings

print('settings module:', settings.SETTINGS_MODULE)
print()
print(f'{"setting":<20} {"Django default":<22} project')
for name in ('DEBUG', 'ALLOWED_HOSTS', 'SECRET_KEY', 'TIME_ZONE', 'LANGUAGE_CODE', 'SESSION_COOKIE_AGE'):
    default = getattr(global_settings, name)
    print(f'{name:<20} {default!r:<22} {getattr(settings, name)!r}')
print()

print('BASE_DIR is a Path:', type(settings.BASE_DIR).__name__)
print('SITE_NAME:', settings.SITE_NAME)
print('hasattr(settings, "NO_SUCH_SETTING"):', hasattr(settings, 'NO_SUCH_SETTING'))
print('getattr with a default:', getattr(settings, 'NO_SUCH_SETTING', 'fallback'))
try:
    settings.NO_SUCH_SETTING
except AttributeError as error:
    print('AttributeError:', error)

The setting SECRET_KEY is empty by default, so the project cannot start without one. Reading a missing name raises AttributeError; getattr with a default is the way to read an optional setting of a reusable app.

$ uv run python -m demos.tour
settings module: config.settings.dev

setting              Django default         project
DEBUG                False                  True
ALLOWED_HOSTS        []                     ['localhost', '127.0.0.1']
SECRET_KEY           ''                     'django-insecure-development-key'
TIME_ZONE            'America/Chicago'      'UTC'
LANGUAGE_CODE        'en-us'                'en-us'
SESSION_COOKIE_AGE   1209600                1209600

BASE_DIR is a Path: PosixPath
SITE_NAME: Demo shop
hasattr(settings, "NO_SUCH_SETTING"): False
getattr with a default: fallback
AttributeError: 'Settings' object has no attribute 'NO_SUCH_SETTING'

Secrets and .env files

The production module needs a secret key and the host names. In production the service manager or the container platform sets the variables. On a developer machine, or on a small server, it is convenient to keep the values in a file named .env next to manage.py. A .env file is plain text with one NAME=value per line. Values with special characters such as # or $, which the key generator produces, must be in single quotes. The file contains secrets, so it must never be committed to version control; add it to .gitignore and commit an example file with fake values instead. Django cannot read the file itself. With uv, the option --env-file loads it into the environment of the command, so nothing has to be changed in the code. Outside of uv, the package python-dotenv has the function load_dotenv(), which does the same inside manage.py. A fresh key comes from get_random_secret_key().

$ uv run python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'

The command prints a random string of 50 characters; we put such a string into the file. The file below is an example for this article; the key was generated for it and should never be reused.

.env
SECRET_KEY='n9+o6m_b%e_j=bv+68a%=%s$rt06(84e^7$(9kuj9ef=b)whl('
ALLOWED_HOSTS=example.com,www.example.com
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
SITE_NAME="Demo shop"

The list values are comma-separated, as our helper expects. The variable SITE_NAME is not required, but the file may set it.

Checking the configuration

The command diffsettings prints the settings that differ from the defaults of Django, a quick way to see what a configuration really contains; a name followed by ### is a setting that Django has no default for. The output of the whole command is long, so we filter a few lines. The second command shows that the environment variable wins over the line in manage.py.

$ uv run python manage.py diffsettings | grep -E '^(DEBUG|ALLOWED_HOSTS|SECRET_KEY|SETTINGS_MODULE|SITE_NAME)'
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
DEBUG = True
SECRET_KEY = 'django-insecure-development-key'
SETTINGS_MODULE = 'config.settings.dev'  ###
SITE_NAME = 'Demo shop'  ###
$ DJANGO_SETTINGS_MODULE=config.settings.prod uv run --env-file .env python manage.py diffsettings | grep -E '^(DEBUG|ALLOWED_HOSTS|SETTINGS_MODULE|SITE_NAME)'
ALLOWED_HOSTS = ['example.com', 'www.example.com']
SETTINGS_MODULE = 'config.settings.prod'  ###
SITE_NAME = 'Demo shop'  ###

The command check --deploy runs additional checks for settings that matter in production. With the development settings it reports six warnings: no HSTS, no redirect to HTTPS, an insecure secret key, cookies without the secure flag, and DEBUG on. The production settings need the environment: without the variables they refuse to load, and with the .env file the check finds nothing to complain about.

$ uv run python manage.py check --deploy
System check identified some issues:

WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. If your entire site is served only over SSL, you may want to consider setting a value and enabling HTTP Strict Transport Security. Be sure to read the documentation first; enabling HSTS carelessly can cause serious, irreversible problems.
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.
?: (security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-' indicating that it was generated automatically by Django. Please generate a long and random value, otherwise many of Django's security-critical features will be vulnerable to attack.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True. Using a secure-only session cookie makes it more difficult for network traffic sniffers to hijack user sessions.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True. Using a secure-only CSRF cookie makes it more difficult for network traffic sniffers to steal the CSRF token.
?: (security.W018) You should not have DEBUG set to True in deployment.

System check identified 6 issues (0 silenced).
$ uv run python manage.py check --deploy --settings=config.settings.prod
  File "./config/env.py", line 15, in env_str
    raise ImproperlyConfigured(f'Set the environment variable {name}') from None
django.core.exceptions.ImproperlyConfigured: Set the environment variable SECRET_KEY
$ uv run --env-file .env python manage.py check --deploy --settings=config.settings.prod
System check identified no issues (0 silenced).

DEBUG, ALLOWED_HOSTS and the proxy

We start the development server with the production settings and the .env file and talk to it with curl. (The development server is not meant for production; here it only shows the effect of the settings.) The first request comes through a proxy that terminates TLS: it carries the host example.com and the header X-Forwarded-Proto: https. Without the header, Django believes that the request used plain HTTP and redirects to HTTPS. A host that is not in ALLOWED_HOSTS is rejected with the status 400, and with DEBUG off, an unknown URL gives a minimal 404 page that does not reveal the URL patterns, the settings, or the code, unlike the yellow debug page.

The first response has the status 200 and the HSTS header added by SecurityMiddleware, and the body shows that the view saw the production mode and the host. The second request is redirected, the third is refused, and the 404 page of the production mode is small. For comparison, the last command asks the server with the development settings for the same URL: the debug page is much larger, because it lists the URL patterns that were tried, which is useful for the developer and a gift for an attacker. This is why DEBUG must never be true in production.

$ curl -si -H 'Host: example.com' -H 'X-Forwarded-Proto: https' http://127.0.0.1:8000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
X-Frame-Options: DENY
Content-Length: 40
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

Demo shop (production), host example.com
$ curl -si -H 'Host: example.com' http://127.0.0.1:8000/ | head -n 5 | grep -v '^Date\|^Server'
HTTP/1.1 301 Moved Permanently
Content-Type: text/html; charset=utf-8
Location: https://example.com/
$ curl -si -H 'Host: evil.example' -H 'X-Forwarded-Proto: https' http://127.0.0.1:8000/ | head -n 1
HTTP/1.1 400 Bad Request
$ curl -s -o /dev/null -w '%{http_code} %{size_download} bytes\n' -H 'Host: example.com' -H 'X-Forwarded-Proto: https' http://127.0.0.1:8000/missing/
404 179 bytes
$ curl -s -o /dev/null -w '%{http_code} %{size_download} bytes\n' http://127.0.0.1:8001/missing/
404 2246 bytes

Rotating the secret key

SECRET_KEY signs sessions, password reset links, signed cookies, and messages; changing it logs everybody out and invalidates the pending links. Since Django 4.1 the setting SECRET_KEY_FALLBACKS makes a rotation smooth: the new key is used for signing, and the old ones are still accepted for verification. After the old tokens have expired, the old key is removed from the list. In our production module the list is read from the variable SECRET_KEY_FALLBACKS. The script signs a value with an old key and loads it with different configurations.

demos/signing.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
django.setup()

from django.core import signing
from django.test import override_settings

with override_settings(SECRET_KEY='old-key'):
    token = signing.dumps({'user': 42})
print('signed with the old key:', token[:40] + '...')

with override_settings(SECRET_KEY='new-key'):
    try:
        signing.loads(token)
    except signing.BadSignature as error:
        print('new key only    :', type(error).__name__)

with override_settings(SECRET_KEY='new-key', SECRET_KEY_FALLBACKS=['old-key']):
    print('with the fallback:', signing.loads(token))
    print('new tokens use the new key:', signing.loads(signing.dumps('x')))

Without the fallback the old token is rejected with BadSignature; with the fallback it is accepted, and new tokens are signed with the new key.

$ uv run python -m demos.signing
signed with the old key: eyJ1c2VyIjo0Mn0:1xRyLM:9F_MSEMHBbQsKC-EG...
new key only    : BadSignature
with the fallback: {'user': 42}
new tokens use the new key: x

Testing settings

The tests of the helpers use mock.patch.dict on os.environ, so they do not depend on the real environment, and the tests of the application use the decorator override_settings, which changes a setting for the duration of a test and restores it afterwards. Note that Django forces DEBUG to False while the tests run, whatever the settings say, so that the tests see the behaviour of production; this is why the test of the view expects the word production although we use the development settings.

core/tests.py
import os
from unittest import mock

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase, TestCase, override_settings

from config.env import env_bool, env_int, env_list, env_str


class EnvTests(SimpleTestCase):

    def test_required_variable(self):
        with mock.patch.dict(os.environ, clear=True):
            with self.assertRaisesMessage(ImproperlyConfigured, 'Set the environment variable SECRET'):
                env_str('SECRET')

    def test_types(self):
        with mock.patch.dict(os.environ, {'A': 'yes', 'B': '0', 'N': '42', 'L': 'a, b,,c'}):
            self.assertIs(env_bool('A'), True)
            self.assertIs(env_bool('B'), False)
            self.assertEqual(env_int('N'), 42)
            self.assertEqual(env_list('L'), ['a', 'b', 'c'])

    def test_defaults(self):
        with mock.patch.dict(os.environ, clear=True):
            self.assertEqual(env_list('L', default=['x']), ['x'])
            self.assertIs(env_bool('A', default=True), True)

    def test_bad_values(self):
        with mock.patch.dict(os.environ, {'A': 'maybe', 'N': 'many'}):
            with self.assertRaises(ImproperlyConfigured):
                env_bool('A')
            with self.assertRaises(ImproperlyConfigured):
                env_int('N')


class SettingsTests(TestCase):

    def test_settings_module(self):
        self.assertEqual(settings.SETTINGS_MODULE, 'config.settings.dev')

    def test_view_uses_the_settings(self):
        response = self.client.get('/')
        self.assertContains(response, 'Demo shop (production)')

    @override_settings(SITE_NAME='Test shop')
    def test_override_settings(self):
        self.assertContains(self.client.get('/'), 'Test shop')

All seven tests pass.

$ uv run python manage.py test
Found 7 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 0.004s

OK
Destroying test database for alias 'default'...

Source

Django documentation: Settings

Django documentation: Settings reference

Django documentation: Deployment checklist

In this article we have read settings from environment variables, split the settings into a base module and modules for development and production, used a .env file with uv, checked the configuration with diffsettings and check --deploy, seen the effect of DEBUG and ALLOWED_HOSTS, rotated the secret key, and tested the code. Related articles are Django introduction, Django static files, and Django database configuration.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.