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!