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 missing
  • os.environ.get("DEBUG") returns the string "False", which is truthy
  • ALLOWED_HOSTS read as one string instead of a list
  • a missing SECRET_KEY silently falls back to a default committed in Git
  • the web server and manage.py in 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.

Where production settings come fromSecret storeor env fileProcess managersystemd, Docker,platformProcess environmentos.environsettings.pytyped, fail-fastEach process type needs the same variables; Django reads them once at startup.

Quick answer

  1. Required secrets: use os.environ["NAME"] so Django fails at startup when they are missing.
  2. Booleans: compare against an explicit set of true values; default to the safe choice (DEBUG defaults to False).
  3. Lists: split on commas and strip whitespace.
  4. Integers: convert with int() and a sensible default.
  5. Supply values with systemd EnvironmentFile, Docker env_file or environment, or your platform's config UI. Do not rely on .env files being loaded implicitly.
  6. Keep one settings module that reads the environment, instead of separate hardcoded settings files per server.
  7. Verify with python manage.py diffsettings or a targeted shell -c check 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.

every environment variable is a stringos.environ.get("DEBUG", True)✕missing variable → True (debug on)✕"False" is a non-empty string → truthy✕production shows tracebacks to visitorsenv_bool("DJANGO_DEBUG", False)✓missing variable → False (safe)✓only "1", "true", "yes", "on" mean true✓required secrets use os.environ["…"]Default to the safe value, and parse strings explicitlydjango-deployment.com
The unsafe version passes every local test, because developers usually have DEBUG set.

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=value per 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_file values are passed into the container.
  • environment entries override values from env_file.
  • Compose also reads a .env file in the project directory, but that file is for interpolating ${VARIABLES} inside compose.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".
values are read once, at startRead whenApply a change withsystemd EnvironmentFileservice start!restart (not reload)Docker Compose env_filecontainer creation!docker compose up -dPaaS config varsrelease or dyno startautomatic redeploySSH sessionnever, unless loaded!set -a; . env; set +aEditing the file changes nothing until the process is started againdjango-deployment.com
Most "my new setting is ignored" reports are a process that never restarted.

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:

  • DEBUG is False in the running process
  • ALLOWED_HOSTS is a list of your real domains
  • starting the app with DJANGO_SECRET_KEY unset fails immediately with ImproperlyConfigured
  • 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

the symptom is rarely where the bug isSymptomCause400 for a host that is listedtrailing space or \r in the valueDefault used althoughthe variable is setset but empty; get() returns ""manage.py disagrees with the siteSSH shell never loaded the env fileSecret truncated at a $ or #special characters in an envfile; restrict the alphabetPrint lengths and repr() of values, never the secrets themselvesdjango-deployment.com
Invisible characters and empty-but-set values explain most configuration surprises.

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

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.

Learning path · step 2 of 11

Your first production deployment on a VPS

Keep going