Configure Django Environment Variables for Production
Read Django settings from environment variables safely: typed parsing, fail-fast secrets, systemd and Docker env files, and verification.
Problem statement
Django's generated settings.py hardcodes a SECRET_KEY, sets DEBUG = True, and uses SQLite. Before deploying, those values must change per environment without editing code on the server. Environment variables are the standard way to do it, and nearly every hosting platform supports them.
The pattern looks simple, but small mistakes cause real incidents:
DEBUG = os.environ.get("DEBUG", True)turns debug mode on whenever the variable is missingos.environ.get("DEBUG")returns the string"False", which is truthyALLOWED_HOSTSread as one string instead of a list- a missing
SECRET_KEYsilently falls back to a default committed in Git - the web server and
manage.pyin an SSH session read different values - quoting rules differ between shells, systemd, and Docker env files
This guide shows a small, dependency-free way to read typed settings, how to supply them on common deployment targets, and how to verify what Django actually loaded.
Quick answer
- Required secrets: use
os.environ["NAME"]so Django fails at startup when they are missing. - Booleans: compare against an explicit set of true values; default to the safe choice (
DEBUGdefaults toFalse). - Lists: split on commas and strip whitespace.
- Integers: convert with
int()and a sensible default. - Supply values with systemd
EnvironmentFile, Dockerenv_fileorenvironment, or your platform's config UI. Do not rely on.envfiles being loaded implicitly. - Keep one settings module that reads the environment, instead of separate hardcoded settings files per server.
- Verify with
python manage.py diffsettingsor a targetedshell -ccheck using the same environment as the web process.
Step-by-step solution
1. Small helpers for typed values
Put these at the top of settings.py:
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
TRUE_VALUES = {"1", "true", "yes", "on"}
def env_str(name, default=None, required=False):
value = os.environ.get(name, default)
if required and not value:
raise ImproperlyConfigured(f"Environment variable {name} is required")
return value
def env_bool(name, default=False):
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in TRUE_VALUES
def env_list(name, default=""):
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
def env_int(name, default):
value = os.environ.get(name)
return int(value) if value not in (None, "") else default
These helpers cover almost every Django setting and avoid the classic string-truthiness bug.
2. Use them for production settings
SECRET_KEY = env_str("DJANGO_SECRET_KEY", required=True)
DEBUG = env_bool("DJANGO_DEBUG", default=False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env_str("DB_NAME", required=True),
"USER": env_str("DB_USER", required=True),
"PASSWORD": env_str("DB_PASSWORD", required=True),
"HOST": env_str("DB_HOST", "127.0.0.1"),
"PORT": env_int("DB_PORT", 5432),
"CONN_MAX_AGE": env_int("DB_CONN_MAX_AGE", 60),
}
}
EMAIL_HOST = env_str("EMAIL_HOST", "localhost")
EMAIL_PORT = env_int("EMAIL_PORT", 587)
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", default=True)
The defaults are chosen so that a missing variable produces a safe or loudly broken result, never an insecure working one. DEBUG defaults to False; SECRET_KEY and database credentials have no default at all.
Local development then sets DJANGO_DEBUG=1 and development values explicitly, for example through a .env file your shell or IDE loads.
3. Name variables consistently
Use a prefix for application settings (DJANGO_, or your project name) and standard names where libraries expect them (DATABASE_URL, REDIS_URL, SENTRY_DSN). Consistent names make it easy to grep configuration across services and avoid collisions with variables the platform sets, such as PORT or HOST.
4. Supplying variables with systemd
/etc/myapp/env:
DJANGO_SECRET_KEY=replace-with-generated-value
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=www.example.com,example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://www.example.com,https://example.com
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=replace-with-generated-value
Reference it from the service:
[Service]
EnvironmentFile=/etc/myapp/env
systemd environment files are not shell scripts:
- do not use
export - one
KEY=valueper line; lines starting with#are comments - quotes are allowed around values, but shell features such as
$(command)do not run
Protect the file:
sudo chown root:myapp /etc/myapp/env
sudo chmod 640 /etc/myapp/env
After editing it, restart the service; a reload does not re-read environment files.
5. Supplying variables with Docker Compose
services:
web:
image: myorg/myapp:1.4.2
env_file:
- ./production.env
environment:
DJANGO_SETTINGS_MODULE: config.settings
env_filevalues are passed into the container.environmententries override values fromenv_file.- Compose also reads a
.envfile in the project directory, but that file is for interpolating${VARIABLES}insidecompose.yaml, not for passing variables into containers. Mixing up the two is a common source of "the variable is set but Django does not see it".
Changing env_file requires recreating the container (docker compose up -d), not just restarting it.
6. Running management commands with the same environment
The web process and your SSH session are separate. A command that works in the web app may fail from the shell, or worse, silently use development defaults.
With systemd, load the same file in a subshell:
sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; \
cd /srv/myapp/app && /srv/myapp/venv/bin/python manage.py migrate'
set -a exports every variable the file defines. This works for simple KEY=value files; values containing spaces or special characters must be quoted in a way both systemd and the shell accept.
With Docker, run commands inside the service container so they inherit its configuration:
docker compose exec web python manage.py migrate
7. Using django-environ instead
If you prefer a library, django-environ provides typed casting and URL parsing:
import environ
env = environ.Env(DJANGO_DEBUG=(bool, False))
SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[])
DATABASES = {"default": env.db("DATABASE_URL")}
env("NAME") without a default raises ImproperlyConfigured when the variable is missing. environ.Env.read_env() can load a .env file; call it only for local development, so production never depends on a file being present in the working directory.
8. Verify what Django loaded
diffsettings shows settings that differ from Django's defaults:
python manage.py diffsettings --output unified | grep -E "^[+-] (DEBUG|ALLOWED_HOSTS|CSRF_TRUSTED_ORIGINS)"
It prints every changed setting, including secrets, so filter the output and never paste it into tickets or chat. For a single value:
python manage.py shell -c "from django.conf import settings; print(settings.DEBUG, settings.ALLOWED_HOSTS)"
To check the running process rather than a new shell, inspect its environment (requires root):
sudo cat /proc/$(systemctl show -p MainPID --value myapp)/environ | tr '\0' '\n' | grep '^DJANGO_DEBUG='
Verification check:
DEBUGisFalsein the running processALLOWED_HOSTSis a list of your real domains- starting the app with
DJANGO_SECRET_KEYunset fails immediately withImproperlyConfigured manage.py check --deploy, run with the production environment loaded, shows no unexpected warnings
Explanation
Environment variables separate configuration from code: the same commit runs in development, staging, and production, and only the environment differs. The risks come from the fact that environment variables are always strings and may be absent. Parsing them with explicit types and choosing fail-closed defaults turns those risks into startup errors, which are far easier to notice than a production site quietly running in debug mode.
Edge cases / notes
Secrets in environment variables
Environment variables can leak through debug pages, crash reporters, child processes, and /proc for users with sufficient access. They are acceptable for most apps when combined with restrictive file permissions; for stricter needs, load secrets from a secret manager or mounted files.
Values containing special characters
Generated secret keys may contain $, #, or quotes. Generate keys with a restricted alphabet (for example secrets.token_urlsafe(50)) to avoid quoting problems across shells, systemd, and Compose.
Multiple settings modules
Separate settings/production.py and settings/development.py files are fine when they differ in structure (installed apps, logging). Keep secrets and hostnames in the environment even then.
Common configuration bugs and how to spot them
Trailing whitespace and invisible characters
Values copied from web consoles sometimes carry a trailing space or a Windows line ending (\r). A host of www.example.com\r never matches the request host, and a password with a trailing space fails authentication. Strip values in your helpers, and check files for carriage returns:
grep -c $'\r' /etc/myapp/env
A non-zero count means the file has Windows line endings; convert it with sed -i 's/\r$//' /etc/myapp/env.
Empty strings versus missing variables
DJANGO_ALLOWED_HOSTS= (set but empty) and an absent variable are different. os.environ.get(name, default) returns the empty string in the first case, not the default. The env_str helper above treats empty values as missing when required=True; apply the same thinking to optional values where an empty string is not meaningful.
Values read at import time versus at runtime
Settings are evaluated once when Django starts. Code that reads os.environ directly inside views sees the process environment at startup too, but it scatters configuration across the codebase. Read variables in settings.py and access them through django.conf.settings everywhere else, so there is a single place to look.
Integer and duration parsing
int("60s") raises ValueError at startup, which is good: a clear failure. Avoid wrapping conversions in try/except that silently fall back to defaults, because a typo then changes behavior without any signal.
A startup summary without secrets
Logging a short, non-secret configuration summary at startup makes incidents easier to debug:
# core/apps.py
import logging
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
class CoreConfig(AppConfig):
name = "core"
def ready(self):
logger.info(
"config debug=%s hosts=%s db_host=%s",
settings.DEBUG,
",".join(settings.ALLOWED_HOSTS),
settings.DATABASES["default"].get("HOST"),
)
Only include values that are safe to appear in logs. Never log keys, passwords, tokens, or full connection URLs, which usually contain credentials.
Documenting required variables
Keep a committed .env.example listing every variable with a placeholder and a comment:
# Required
DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=www.example.com
DB_NAME=myapp
DB_USER=myapp
DB_PASSWORD=change-me
# Optional (defaults shown)
DJANGO_DEBUG=0
DB_PORT=5432
EMAIL_PORT=587
When a pull request adds a new required variable, the same pull request updates .env.example, which reminds whoever deploys it to set the value first.
Next steps
- Go further with Manage Django Secrets in Production.
- Check every security-related value with the Django Production Settings Checklist.
- If startup fails on the secret key, see Fix ImproperlyConfigured: SECRET_KEY Must Not Be Empty.
FAQ
Why is DEBUG still True when I set DEBUG=False?
os.environ.get("DEBUG") returns the string "False", and any non-empty string is truthy in Python. Parse booleans explicitly, for example by checking membership in a set of true values.
Should I use a .env file in production?
It is acceptable as the source file for systemd EnvironmentFile or Docker env_file, with strict permissions. Avoid having Django itself load a .env from the working directory in production, because behavior then depends on where the process starts.
How do I pass ALLOWED_HOSTS as an environment variable?
Store a comma-separated string such as www.example.com,example.com and split it into a list in settings, stripping whitespace and dropping empty items.
Do I need to restart Django after changing environment variables?
Yes. Settings are read once when the process starts. Restart the service or recreate the container so new values are loaded.