Env Files and 12-Factor Config for Python on One Box
Keep secrets out of git with .env files and 12-factor environment variables so a single Ubuntu VPS can host one Python app cleanly and safely.
Netbay Engineering
Netbay Engineering
On this page
Twelve-factor config says configuration lives in the environment, not in committed settings.py files. On a single Ubuntu VPS that still holds: secrets stay out of git, the same codebase boots in staging and production, and systemd injects values the process reads at start. The mistake is treating python-dotenv as a second source of truth that silently overrides production, or committing .env because it is convenient.
This is the one-box version. You do not need a secret manager cluster. You need a file with mode 640, an EnvironmentFile= line, and application code that refuses to start when DATABASE_URL is missing.
What Goes in the Environment
If it changes between machines, it is config. If it is a secret, it is config. If it is a Python import path, it is code.
- DATABASE_URL, REDIS_URL, SECRET_KEY, ALLOWED_HOSTS
- EMAIL_HOST, SENTRY_DSN, DEBUG (and DEBUG must be false on the VPS)
- Feature flags you would otherwise hardcode as True
Do not put Django's INSTALLED_APPS, URL patterns, or Celery task names in env vars. Those are the program. When you need a different program, that is a different deploy, not a different .env.
A practical .env on disk:
# /srv/app/.env mode 640, owner app:app
DEBUG=false
SECRET_KEY=replace-with-a-long-random-string
DATABASE_URL=postgres://app:secret@127.0.0.1:5432/app
ALLOWED_HOSTS=app.example.com
SENTRY_DSN=
PYTHONUNBUFFERED=1systemd EnvironmentFile does not expand quotes the way bash does. Keep values unquoted unless they contain spaces. Do not write export KEY=value; systemd is not a shell. Comments starting with # are fine. Blank lines are fine. Variable interpolation is not: you cannot write REDIS_URL=$HOST:6379 and expect it to work.
Create it as root, then:
sudo install -o app -g app -m 640 /dev/null /srv/app/.env
sudo nano /srv/app/.env
sudo chown app:app /srv/app/.env
sudo chmod 640 /srv/app/.env
grep -n SECRET_KEY /srv/app/.envThe last grep is a reminder to look with your eyes. A SECRET_KEY that is django-insecure- or changeme belongs on a laptop, not on a Lucknow VPS with a public IP.
How the Process Should Read Config
Application code should read os.environ, fail loud, and never guess. python-dotenv is for local laptops so developers do not export twenty variables in bashrc. On production, systemd already put the keys in the environment. Loading .env from disk inside the worker duplicates that and hides a missing EnvironmentFile.
import os
def require(name):
value = os.environ.get(name)
if not value:
raise RuntimeError("missing env var: " + name)
return value
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
SECRET_KEY = require("SECRET_KEY")
DATABASE_URL = require("DATABASE_URL")
ALLOWED_HOSTS = require("ALLOWED_HOSTS").split(",")raise at import time. A worker that starts with SECRET_KEY=dev because a default existed is worse than a worker that refuses to start. Defaults are for DEBUG and log level, not for credentials.
Django-environ, pydantic-settings, and os.environ all work. Pick one. pydantic-settings can parse DATABASE_URL into a PostgresDsn and reject junk. That is worth the dependency if you already use Pydantic. Do not parse URLs with string split.
If you insist on dotenv in production, load it only when a file exists AND an explicit APP_ENV=local is set. Never let dotenv override real environment variables: load_dotenv(override=False) is the only acceptable call, and even then it is a footgun.
Files, Git, and the Twelve-Factor Split
.gitignore must include .env. The repo can contain .env.example with fake values and every key documented. The VPS holds the real file. Back up that file with the same care you back up the database; losing SECRET_KEY invalidates sessions and signed cookies.
Do not copy .env into Docker build layers if you later containerize. Do not paste secrets into a git-tracked deploy script. Do not dump os.environ into an error page. FastAPI and Django debug toolbars will happily print the whole environment to a stranger if DEBUG is true.
Twelve-factor also wants one process type per role: web, worker, beat. They share the same env file on one box. That is fine. They must not share a process. Celery in a thread inside Gunicorn is how you lose jobs when a worker restarts.
L3/L4 DDoS filtering does not protect a leaked SECRET_KEY. File mode 640 and a non-root user do more for config than a bigger CPU. Intel Xeon Platinum and High-Speed SSD make the app fast; they do not store secrets for you.
Changing Config Without Shipping Code
Because config is env, a config change is a file edit plus systemctl restart app. No rebuild. No pip install. That is the payoff. If you had to commit settings_production.py to change ALLOWED_HOSTS, you have not actually externalized config.
Keep a short checklist: edit .env, run systemd-show to confirm EnvironmentFile parsed, restart, curl a health endpoint, then journalctl -u app --since "1 min ago" to see the boot. A missing key should crash the unit in seconds, not serve 500s for an hour.
Takeaway
On one VPS, 12-factor config is a 640 file, a systemd EnvironmentFile, and application code that crashes when a key is missing. python-dotenv is a laptop tool. Secrets do not belong in git, debug pages, or default arguments.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and keep config in the environment from the first boot — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance