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.
Quick answer
- Identify which process fails: web server, a management command over SSH, a Docker build, CI, or a scheduled job.
- Check how
settings.pyreads the key (os.environ["..."],os.getenv, django-environ, a.envloader) and which variable name it expects. - Supply the variable to that specific process:
EnvironmentFile=for systemd,env_file/environmentfor Docker Compose, CI secrets, or loading the env file in the cron wrapper. - For build steps that only need settings to import (such as
collectstatic), pass a throwaway build-only value. - Restart the service after changing its environment.
- 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
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
[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")
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 --deploydoes not warn about a short or insecure-lookingSECRET_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
~/.bashrcand 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
- Structure configuration with Configure Django Environment Variables for Production.
- Store and rotate the key safely with Manage Django Secrets in Production.
- If the service still fails, see Fix a Gunicorn systemd Service That Fails to Start.
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.