Easy Configuration Management in Django Projects

Setting up the settings module in Django can be cumbersome for beginners. This module holds many properties that are vital to your application, and a minor error may be fatal. Moreover, you need to keep track of separate environments, such as local, dev, test, staging, production, and so on. In this short article, I will show you my approach to handling different environments in Django projects.

Starting point – default config

When you create a new Django project, you get a default settings.py file that looks like this (I removed the comments to save space on the page):

# tutorial/settings.py

from pathlib import Path

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

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-756k6+ffj3f0q8i6-yaa+$+*lj&4(x@dfb6olfph49e%2f8p58"

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

ALLOWED_HOSTS = []

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

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 = "tutorial.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "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 = "tutorial.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/"

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

These defaults are suitable for a local development instance but are absolutely inappropriate for production. For example, it needs to have debug set to False, a proper secret key set, and so on. On the other hand, some of these settings, like INSTALLED_APPS and MIDDLEWARES should be the same across deployments. How should we handle this? I suggest moving all shared configuration properties into one file and creating separate files for all environment-specific properties.

Shared configuration options

Instead of using a single settings.py file, we will create a settings folder in your main application folder which will hold our settings files. In this folder, create a single __init__.py file. Your directory structure should resemble this:

  • app
    • settings
      • __init__.py
    • __init__.py
    • asgi.py
    • urls.py
    • wsgi.py
  • manage.py

In the __init__.py file we will put the settings values that are shared across all environments, which are most of them. For the sample config, this will look like that:

# shared settings

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

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 = "tutorial.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "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 = "tutorial.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",
    },
]

LANGUAGE_CODE = "en-us"

TIME_ZONE = "UTC"

USE_I18N = True

USE_TZ = True

STATIC_URL = "static/"

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

You can see the fields that are missing: DB config, secret key, ALLOWED_HOSTS, and DEBUG setting. We are going to put these into separate files. Let’s create them now.

Local configuration

We will start with local configuration, the easiest one. In the settings folder, create a file local.py with a local-specific configuration:

# local settings

from pathlib import Path

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

SECRET_KEY = "django-insecure-756k6+ffj3f0q8i6-yaa+$+*lj&4(x@dfb6olfph49e%2f8p58"

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

ALLOWED_HOSTS = []

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

This is nothing new: default settings values by Django, just moved to a local-specific file. It turns on debug, sets a secret key, and enables the SQLite database.

Right now, this file is not used by Django: it just sits in a folder and nothing imports it. We will fix that once we create another configuration, for the hosted development environment.

Hosted development configuration

For our hosted dev environment, we will create a config file that will connect to a Postgres database and set properly allowed hosts and the secret key. Of course, we will grab the sensitive values from the environment variables, like this:

# dev settings

import os
import dj_database_url
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured
from django.core.management.utils import get_random_secret_key

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

SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", get_random_secret_key())

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

ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost").split(",")

if os.getenv("DATABASE_URL", None) is None:
    raise ImproperlyConfigured("DATABASE_URL environment variable not defined")

DATABASES = {
    "default": dj_database_url.parse(os.environ.get("DATABASE_URL")),
}

Let us go over this. You can see we are now reading SECRET_KEY from the environment variables, as well as ALLOWED_HOSTS and DATABASES (using dj_database_url library). For the first two, we provide sensible (unsafe, but suitable for dev environment) defaults, and enforce the database configuration. Lastly, we move on to production configuration.

Production configuration

In my example, the production configuration will be similar to the development environment configuration, but with debug and unsafe defaults turned off:

# production settings

import os
import dj_database_url
from pathlib import Path

from django.core.exceptions import ImproperlyConfigured

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

if os.getenv("DJANGO_SECRET_KEY", None) is None:
    raise ImproperlyConfigured("DJANGO_SECRET_KEY environment variable not defined")

SECRET_KEY = os.getenv("DJANGO_SECRET_KEY")

DEBUG = False

if os.getenv("DJANGO_ALLOWED_HOSTS", None) is None:
    raise ImproperlyConfigured("DJANGO_ALLOWED_HOSTS environment variable not defined")

ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS").split(",")

if os.getenv("DATABASE_URL", None) is None:
    raise ImproperlyConfigured("DATABASE_URL environment variable not defined")

DATABASES = {
    "default": dj_database_url.parse(os.environ.get("DATABASE_URL")),
}

Now we will write logic to choose the appropriate configuration file

Mapping environments to the configuration

Now we have one shared file and three environment-specific files. By default, Django will import the app.settings module, which imports the __init__.py file. To load environment-specific values, you just need to import the relevant file in the __init__.py file. For example, if you add this line:

from .local import *

Django will load the local config. To choose which config to load, we will use another environment variable. Let us call it APP_ENV and it can take values local, dev, or prod. Then, write the logic to choose the relevant config based on it:

match os.environ.get("APP_ENV", None):
    case "local":
        from .local import *
    case "dev":
        from .dev import *
    case "prod":
        from .prod import *
    case _:
        raise ImproperlyConfigured(f"Bad value for environment!")

These lines go at the very bottom of the base settings file. It would read the APP_ENV variable and import the relevant config, and throw an exception if the value is incorrect or missing. Your robust and extensible settings module is now complete!

Answering concerns

I found that this setup works well for my projects, but I understand if it does not suit everyone. Here I will try to address some concerns:

  • Why not store everything in the environment variables and use a single settings module? I am a big fan of configuration-as-code, and I try to use environment variables for secrets only, such as API keys and DB connection strings. It is very easy to mess up some of your environment variables if you have 10+ of them across multiple deployments.
  • Why such a complex setup for a simple config? I tried to make the config as simple as possible for the tutorial, but it scales very neatly into large projects when you need to store more settings that are environment-specific, such as static file handling, token expiry time, etc.

I hope you liked this article and it helped you navigate the configuration options in Django. Please let me know in the comments if you used this approach or have your own!

Get new content delivered to your mailbox:

leave a comment