App Deployment·7 min read·

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.

NB

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:

bash
# /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=1

systemd 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:

bash
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/.env

The 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.

python
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.

Config flow on one box git repo .env.example only /srv/app/.env mode 640, app:app systemd EnvironmentFile= os.environ in the app require() or refuse to boot laptop dotenv not a production source of truth

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