Troubleshooting
Settings & secrets

Fix ImproperlyConfigured: SECRET_KEY Must Not Be Empty

Fix the SECRET_KEY must not be empty error in production: find where settings load, pass the key to systemd, Docker, CI, and cron, and avoid unsafe defaults.

Problem statement

Django refuses to start, or a management command fails, with:

django.core.exceptions.ImproperlyConfigured: The SECRET_KEY setting must not be empty.

Variants of the same problem:

KeyError: 'DJANGO_SECRET_KEY'
django.core.exceptions.ImproperlyConfigured: Set the DJANGO_SECRET_KEY environment variable

In every case, the process that imported your settings did not receive the secret key. The key may exist in your shell, in a .env file, or in the platform dashboard, but not in the environment of that particular process: the Gunicorn service, the Docker build step, the CI job, or the cron job.

The error is a safety feature. Django signs sessions, password reset tokens, and other data with SECRET_KEY, and an empty or predictable key would make those forgeable.

Every process needs the keySecret storeor env filesystemd, Docker,CI, cronProcess envDJANGO_SECRET_KEYDjango startupfails if missingA shell export does not reach services; each entry point loads the same source.

Quick answer

  1. Identify which process fails: web server, a management command over SSH, a Docker build, CI, or a scheduled job.
  2. Check how settings.py reads the key (os.environ["..."], os.getenv, django-environ, a .env loader) and which variable name it expects.
  3. Supply the variable to that specific process: EnvironmentFile= for systemd, env_file/environment for Docker Compose, CI secrets, or loading the env file in the cron wrapper.
  4. For build steps that only need settings to import (such as collectstatic), pass a throwaway build-only value.
  5. Restart the service after changing its environment.
  6. Never fix it by hardcoding a key or adding an insecure default in settings.py.

Step-by-step solution

1. Find which settings file is loaded

echo "$DJANGO_SETTINGS_MODULE"
grep -rn "SECRET_KEY" config/settings*.py config/settings/ 2>/dev/null
which settings file is this process using?DJANGO_SETTINGS_MODULE in the environmentwins when set--settings flagfor management commandssetdefault in manage.py / wsgi.pyused only if nothing else is setDifferent entry points can default to different settings filesdjango-deployment.com
manage.py often defaults to development settings while wsgi.py defaults to production.

Look for the exact variable name. Common patterns:

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]           # KeyError if missing
SECRET_KEY = os.environ.get("SECRET_KEY")              # None -> ImproperlyConfigured
SECRET_KEY = env("DJANGO_SECRET_KEY")                  # django-environ

A mismatch between names (SECRET_KEY in the environment, DJANGO_SECRET_KEY in code) is a frequent cause.

Also check whether settings rely on a .env loader such as python-dotenv or environ.Env.read_env(). Those loaders usually read a file relative to the current working directory or BASE_DIR. If the service starts in a different directory, the file is not found and nothing is loaded.

2. Reproduce with the failing process's environment

systemd service:

systemctl cat gunicorn | grep -E "EnvironmentFile|Environment="
sudo systemctl show gunicorn -p Environment
sudo journalctl -u gunicorn -n 30 --no-pager

systemctl show -p Environment lists variables set with Environment= lines but not those from EnvironmentFile=. Check the file path, ownership, and contents:

sudo ls -l /etc/myapp/env
sudo grep -c "^DJANGO_SECRET_KEY=" /etc/myapp/env

The grep -c prints 1 if the line exists, without printing the secret.

If the path in EnvironmentFile= is wrong, systemd refuses to start the service unless the path is prefixed with -, which silently ignores a missing file. A leading - can hide exactly this problem.

Running process:

sudo tr '\0' '\n' < /proc/$(systemctl show -p MainPID --value gunicorn)/environ | cut -d= -f1 | sort

This lists variable names only.

3. Supply the key to systemd

every entry point needs the keyGets the key fromGunicorn (systemd)EnvironmentFile= in the unitmanage.py over SSH!set -a; . /etc/myapp.env; set +aDockerenv_file or a secretCIa masked CI secretcron!a wrapper that loads the env fileThe web app working proves nothing about the other fourdjango-deployment.com
One wrapper script for all entry points removes most of these failures.
[Service]
EnvironmentFile=/etc/myapp/env

/etc/myapp/env:

DJANGO_SECRET_KEY=paste-a-generated-value

Generate a key:

python -c "import secrets; print(secrets.token_urlsafe(50))"

Apply:

sudo systemctl daemon-reload
sudo systemctl restart gunicorn

daemon-reload is needed after editing the unit file; restart is needed after editing the environment file.

4. Supply the key to management commands over SSH

Your SSH session does not inherit the service's environment. Load the same file:

sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; \
  cd /srv/myapp/app && /srv/myapp/venv/bin/python manage.py migrate'

Create a small wrapper script if you run commands often, so everyone uses the same environment.

5. Supply the key to Docker

Runtime (Compose):

services:
  web:
    image: myorg/myapp:1.4.2
    env_file:
      - /etc/myapp/production.env

Recreate the container after changes: docker compose up -d web.

Build time: a RUN python manage.py collectstatic step imports settings but has no access to runtime secrets. Use a build-only placeholder in that step:

RUN DJANGO_SECRET_KEY=build-only-not-secret python manage.py collectstatic --noinput

The placeholder exists only for that command and is not the key used at runtime. Do not pass the real key as a build argument, because build arguments can be preserved in image metadata.

6. Supply the key to CI

Tests need a key too. Set a CI-only value in the workflow, separate from production:

env:
  DJANGO_SECRET_KEY: ci-only-not-secret

Using a fixed non-secret value in CI is fine because CI signs nothing that matters.

7. Supply the key to cron and scheduled jobs

Cron starts with an almost empty environment. Load the environment file in the job's wrapper script, or use a systemd timer with the same EnvironmentFile= as the web service.

8. Avoid unsafe fixes

Tempting but dangerous:

# Do not do this
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-dev-key")
fixes that create new problemsUnsafe✕hard-code the key in settings✕os.environ.get with a default key✕generate a new key on every startSafe✓os.environ["DJANGO_SECRET_KEY"]✓one env file, mode 600✓same key for every processA random key per start logs everyone out and breaks signed datadjango-deployment.com
A default key silently ships the same secret to every environment.

If the variable goes missing in production, the site silently runs with a key that is in your Git history, so anyone who has seen the repository can forge signed data. Fail loudly instead, and give development its own explicit value through a local .env file or shell configuration.

If you want a friendlier error message:

import os

from django.core.exceptions import ImproperlyConfigured

try:
    SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
except KeyError as exc:
    raise ImproperlyConfigured("Set the DJANGO_SECRET_KEY environment variable.") from exc

9. Verify

sudo systemctl restart gunicorn
systemctl is-active gunicorn
curl -sI https://www.example.com/ | head -1
sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; cd /srv/myapp/app && /srv/myapp/venv/bin/python manage.py check --deploy'

Verification check:

  • the service stays active
  • the site responds
  • management commands run with the same environment
  • check --deploy does not warn about a short or insecure-looking SECRET_KEY (security.W009)

Explanation

Each process on a server gets its own environment from whoever starts it: systemd, Docker, the CI runner, cron, or your shell. Settings read that environment once at import time. The SECRET_KEY error appears whenever a new kind of process imports settings without having been given the same configuration as the web server. Fixing it per process, with the same source file, keeps every entry point consistent.

Edge cases / notes

The key contains special characters

Values with $, quotes, or spaces can be mangled by shell loading (set -a; . file) even though systemd reads them correctly. Generate keys from a URL-safe alphabet, as secrets.token_urlsafe does.

Changing the key

Changing SECRET_KEY invalidates sessions and outstanding password reset links. To rotate without logging everyone out, use SECRET_KEY_FALLBACKS with the old key for a transition period.

Several settings modules

If manage.py defaults to config.settings.dev while the web server uses config.settings.prod, commands may fail differently than the service. Set DJANGO_SETTINGS_MODULE explicitly in the environment file.

One wrapper for every entry point

Most SECRET_KEY errors happen because different entry points load configuration differently. A single wrapper script used by systemd timers, deploy scripts, and humans removes that variation:

#!/bin/bash
# /srv/myapp/bin/manage
set -euo pipefail

ENV_FILE=/etc/myapp/env
APP_DIR=/srv/myapp/app
VENV=/srv/myapp/venv

if [ "$(id -un)" != "myapp" ]; then
  exec sudo -u myapp "$0" "$@"
fi

set -a
. "$ENV_FILE"
set +a

cd "$APP_DIR"
exec "$VENV/bin/python" manage.py "$@"

Usage:

/srv/myapp/bin/manage migrate --noinput
/srv/myapp/bin/manage createsuperuser
/srv/myapp/bin/manage shell

The script re-executes itself as the service user, loads the same environment file as the web service, and runs the project's Python. Anyone running a command on the server gets production configuration without remembering the incantation.

Checking configuration in CI and before deploys

A deploy that introduces a new required variable should fail before it reaches production. Keep a list of required variables in the repository and check the target environment during the deploy:

required="DJANGO_SECRET_KEY DJANGO_ALLOWED_HOSTS DATABASE_URL"
missing=0
for name in $required; do
  if ! sudo grep -q "^${name}=" /etc/myapp/env; then
    echo "missing required variable: $name"
    missing=1
  fi
done
[ "$missing" -eq 0 ] || exit 1

The check prints variable names only, never values, so it is safe in CI logs.

Why Django checks the key at all

Django uses SECRET_KEY through django.core.signing and salted_hmac for password reset tokens, the messages framework's cookie storage, signed cookie sessions, and session authentication hashes. An empty key would make those signatures predictable. Django therefore refuses to start when the setting is empty, and check --deploy additionally warns when the key is short or looks like the insecure development default.

Common mistakes

  • Exporting the variable in ~/.bashrc and expecting the systemd service to see it.
  • Using EnvironmentFile=-/wrong/path, which silently ignores the missing file.
  • Adding a hardcoded fallback key to "make deploys work".
  • Passing the real key as a Docker ARG.

Next steps

FAQ

Why does Django say SECRET_KEY must not be empty when I set it in my shell?

The failing process does not inherit your shell's environment. systemd services, Docker containers, cron jobs, and CI runners each need the variable supplied explicitly.

Can I use a default SECRET_KEY value in settings.py?

Not for production. A committed default silently becomes the production key if the variable is missing. Require the variable and give development its own local value.

How do I run collectstatic in a Docker build without the real secret key?

Set a throwaway value only for that command, such as DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput. The real key is provided at runtime.

How do I generate a secure Django SECRET_KEY?

Use python -c "import secrets; print(secrets.token_urlsafe(50))" or Django's get_random_secret_key(). Store the result in your secret store, not in Git.

Keep going