Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • Why the tutorial settings file is a security risk
  • python-decouple and a .env file
  • Committing .env.example instead of .env
  • DEBUG and ALLOWED_HOSTS per environment

Lesson notes

1. The problem nobody warned you about

Open the settings.py you deployed at the end of the tutorial. It contains, in plain text, in a file that is committed to git:

SECRET_KEY = "django-insecure-8s%3v!k2$0m@..."
DEBUG = True
ALLOWED_HOSTS = ["*"]

Each of those three lines is a separate incident waiting to happen.

  • SECRET_KEY signs session cookies, password-reset tokens and CSRF tokens. Anyone who knows it can forge a session and log in as you.
  • DEBUG = True in production renders a full traceback - including your settings, your environment variables and fragments of your database - to whoever triggers an error.
  • ALLOWED_HOSTS = ["*"] disables host-header validation and opens the door to cache poisoning and poisoned password-reset links.

2. The rule: configuration is not code

The principle comes from the Twelve-Factor App: anything that differs between your laptop, the staging server and production is configuration, and configuration lives in the environment.

os.environ 1st priority (production) .env file 2nd priority (local) default=... in code 3rd priority (fallback) decouple.config() cast + validate core/settings.py no secrets inside

3. python-decouple in five minutes

pip install python-decouple
pip freeze > requirements.txt

Create a .env file next to manage.py:

# .env  -  NEVER commit this file
DJANGO_SECRET_KEY=replace-me-with-50-random-characters
DEBUG=True
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
DATABASE_URL=sqlite:///db.sqlite3
EMAIL_HOST_PASSWORD=

And rewrite the top of your settings:

# core/settings.py
from pathlib import Path

from decouple import Csv, config

BASE_DIR = Path(__file__).resolve().parent.parent

# No default -> the project refuses to start without a key. That is intentional.
SECRET_KEY = config("DJANGO_SECRET_KEY")

# Safe default: if nobody says otherwise, we are in production mode.
DEBUG = config("DEBUG", default=False, cast=bool)

ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", default="127.0.0.1", cast=Csv())

EMAIL_PORT = config("EMAIL_PORT", default=587, cast=int)

Three details are worth memorising:

  1. No default for real secrets. A missing SECRET_KEY should crash the process immediately, not silently fall back to a value an attacker knows.
  2. Defaults must be the safe option. DEBUG defaults to False, never to True.
  3. Always cast. Environment variables are strings. Without cast=bool, DEBUG would be the string "False" - which Python considers true.

4. .env.example: the file you do commit

.env is private, but the list of variables is documentation. Commit a template with empty or fake values so a new contributor knows what to fill in:

# .env.example  -  committed to git
DJANGO_SECRET_KEY=
DEBUG=True
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
DATABASE_URL=sqlite:///db.sqlite3
EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=587

And make absolutely sure the real one can never be staged:

# .gitignore
.env
.env.*
!.env.example
*.sqlite3
__pycache__/
/staticfiles/
/media/

5. Generating a new SECRET_KEY

python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Put the result into .env on your machine and into the environment of your server - and use a different value in each place. If the old key was ever pushed to GitHub, rotate it now; every session and every pending password-reset link will be invalidated, which is exactly what you want.

6. The same settings on the server

On a PaaS you set the variables in the dashboard. On your own server you have options:

# /etc/systemd/system/myblog.service
[Service]
EnvironmentFile=/srv/myblog/.env
ExecStart=/srv/myblog/.venv/bin/gunicorn core.wsgi:application
# docker-compose.yml
services:
  web:
    build: .
    env_file: .env
    ports:
      - "8000:8000"

The application code is identical in all three cases. That is the whole point: one artifact, many environments.

7. Verification checklist

git ls-files | grep -c "^\.env$"     # must print 0
python manage.py check --deploy      # read every warning
python -c "from decouple import config; print(config('DEBUG', cast=bool))"

8. Exercise

  1. Move SECRET_KEY, DEBUG and ALLOWED_HOSTS out of settings.py into .env.
  2. Generate a fresh secret key and rotate the one from the tutorial.
  3. Add .env to .gitignore and commit .env.example.
  4. Delete .env temporarily and confirm the project refuses to start with a clear error message.
  5. Bonus: run gitleaks over your repository history and see whether anything else is in there.

Further reading

After this lesson you will

  • Keep SECRET_KEY and passwords out of git
  • Read configuration from environment variables
  • Document required settings for other developers