Settings, environments and secrets, done right
Settings are where a project meets the real world — the database it connects to, the secret key it signs with, whether debugging is on. The same code runs on your laptop, on staging, and in production, but its settings must differ between them, and its secrets must never be committed. Getting settings organisation right is a small amount of work that prevents a class of embarrassing (and dangerous) mistakes. This lesson is how to structure settings across environments and handle secrets safely.
The core rule: config from the environment
The foundational principle (from the widely-followed "twelve-factor app" guidelines): anything that
differs between environments, or is secret, comes from the environment — not hard-coded in settings. The
database URL, the secret key, the debug flag, the allowed hosts, API keys — these vary between your machine
and production, or must stay secret, so they are read from environment variables (or a .env file locally),
never written into the committed settings.py.
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # required — fail loudly if missing
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
Note os.environ["..."] (bracket access) for a required secret — it raises immediately if unset, which is
what you want: a missing secret key should stop the app, not silently use a default. Use os.environ.get(..., default) only for genuinely optional settings with a safe default.
Reading .env in development
Typing environment variables into your shell every time is tedious, so in development you keep them in a
.env file (which the setup lesson's .gitignore already excludes) and load it:
# .env — NEVER committed
DJANGO_SECRET_KEY=dev-only-key-abc123
DJANGO_DEBUG=True
DATABASE_URL=postgres://localhost/nidaan
A small library reads it. Two common, sound choices:
django-environ— reads.envand parses typed values, including aDATABASE_URLinto Django'sDATABASESdict:
import environ
env = environ.Env()
environ.Env.read_env() # load .env
SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env.bool("DJANGO_DEBUG", default=False)
DATABASES = {"default": env.db()} # parses DATABASE_URL
python-dotenv— a lighter option that just loads.envintoos.environ.
Either way, the pattern is: secrets and per-environment config live in .env locally (untracked) and in the
real environment variables in production (set by your host or deployment tool), and settings.py reads
them. The code is identical everywhere; only the values differ.
Organising settings across environments
There are two respectable ways to handle "dev versus production differ":
One settings file, driven by the environment (simpler). A single settings.py that branches on
environment variables (DEBUG, DATABASES from env, etc.). This is clean when the differences are just
values, and it is the modern lean approach — the file is the same, the environment supplies the
differences.
Split settings (for larger differences). A settings/ package with base.py (shared),
development.py and production.py (each from .base import * then overriding), selected by
DJANGO_SETTINGS_MODULE:
config/settings/
base.py # everything shared
development.py # DEBUG=True, dev tools, console email
production.py # DEBUG=False, security settings on, real email
Split settings suit projects where environments differ structurally (different installed apps, different
middleware, different email backends) rather than just in values. For many projects the single-file,
env-driven approach is enough; reach for split settings when the environments genuinely diverge. Either way,
secrets still come from the environment — splitting files is about structure, not about putting
secrets in a production.py (never do that).
What must never be committed — and what to do if it was
The non-negotiables:
SECRET_KEY, database passwords, API keys, tokens — never in the repository, ever. A secret in git is compromised the moment it is pushed, and it lives in history forever (deleting it in a later commit does not remove it from history)..env— always in.gitignore; commit a.env.example(with dummy values and the names of the variables) instead, so a new developer knows what to set without seeing real secrets.
If a real secret is accidentally committed, the only safe response is to rotate it — generate a new
secret key, change the database password, revoke and reissue the API key. Scrubbing it from git history is
secondary and often impractical (forks, clones, caches already have it); assume anything pushed is
compromised and replace it. This is why the .gitignore goes in before the first commit — prevention is
the only reliable cure.
Check your work
The core rule. Anything that differs between environments or is secret comes from the environment, not
hard-coded — database, SECRET_KEY, DEBUG, ALLOWED_HOSTS, API keys.
Required versus optional. os.environ["X"] for required secrets (fails loudly if missing);
os.environ.get("X", default) only for optional settings with a safe default.
.env in development. Keep secrets/config in an untracked .env, loaded by django-environ or
python-dotenv; production uses real environment variables. Same code, different values.
Organising settings. Single env-driven settings.py when environments differ only in values; a split
settings/ package (base/development/production) when they differ structurally — secrets still from
the environment either way.
Never commit secrets. SECRET_KEY, passwords, keys, and .env stay out of git (commit .env.example);
a committed secret lives in history forever and must be rotated, not just deleted.
Practice
- Move
SECRET_KEY,DEBUGandALLOWED_HOSTSto environment variables read insettings.py; run the app with them set via a.env. - Use
os.environ["DJANGO_SECRET_KEY"]and unset the variable; confirm the app fails loudly (and reason about why that is better than a default). - Install
django-environ, load aDATABASE_URLfrom.envwithenv.db(), and confirm the connection. - Create a
.env.examplewith variable names and dummy values; confirm.envis gitignored but.env.exampleis committed. - Sketch when you would split settings into
base/development/productionversus keep one env-driven file, for Nidaan. - Reason through the correct response to accidentally committing a real
SECRET_KEY— and why rotation, not deletion, is the fix.
Official documentation
- Django — Settings — How settings are loaded and
DJANGO_SETTINGS_MODULE. - The Twelve-Factor App — Config — Why config belongs in the environment.
- django-environ documentation — Reading
.envand parsingDATABASE_URL.
Next: query discipline — performance you can see.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship