07 · Environment Variables & Config Management¶
Hardcoding database passwords, API keys, and per-environment settings directly into source code is one of the most common ways secrets end up leaked in a git history. This module covers the standard pattern: keep config out of code, injected at runtime via environment variables and config files.
Why environment variables¶
The twelve-factor app methodology's config principle, in short: anything that varies between environments (dev, staging, prod) — credentials, hostnames, feature flags — belongs in the environment, not in the codebase. The same built artifact should run correctly in any environment purely by changing what's injected around it.
Benefits in practice:
- The same code (and the same deploy script) works in every environment.
- Secrets never need to be committed to version control.
- Different engineers/environments can have different values without touching code.
Setting environment variables for a systemd service¶
Two supported patterns, and they compose:
1. Inline in the unit file — fine for non-secret, rarely-changing values:
2. An external env file — better for secrets and per-host values, kept out of the unit file (which often lives in version control alongside your infra config):
# /etc/myapp/myapp.env — KEY=value, one per line, no quotes needed
APP_ENV=production
DATABASE_URL=postgresql://myapp:s3cr3t@localhost:5432/myapp
API_KEY=sk_live_abc123
LOG_LEVEL=info
Lock this file down — it holds secrets:
640 = owner (root) can read/write, group (deploy) can read, others get
nothing. The service's User=deploy (or whichever group you set) can then
read it via EnvironmentFile=, but random other users on the box can't.
After changing the env file, reload and restart:
Reading environment variables in your app¶
# Shell script
echo "Running in ${APP_ENV:-development}" # default if unset
# Python
import os
db_url = os.environ["DATABASE_URL"] # raises if missing (fail fast)
log_level = os.environ.get("LOG_LEVEL", "info") # default if missing
# Node.js
const dbUrl = process.env.DATABASE_URL;
const logLevel = process.env.LOG_LEVEL || "info";
Prefer failing loudly (os.environ[...], no default) for values the app
genuinely cannot run without — a missing DATABASE_URL should crash on
startup with a clear error, not silently connect to undefined and fail
confusingly three requests later.
.env files for local development¶
Outside of systemd, .env files loaded by a library (python-dotenv,
dotenv for Node) are the common local-dev equivalent:
# .env — NEVER commit this file
DATABASE_URL=postgresql://myapp:localpass@localhost:5432/myapp_dev
API_KEY=sk_test_dummy
LOG_LEVEL=debug
Always add it to .gitignore immediately, and commit a .env.example with
the same keys and placeholder/dummy values so teammates know what's needed:
# .env.example — safe to commit
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
API_KEY=
LOG_LEVEL=debug
Per-environment config layering¶
A common pattern for anything beyond a handful of variables: a small, non-secret YAML/JSON per environment, with secrets still injected via env vars layered on top.
# config/production.yaml
log_level: info
request_timeout_seconds: 30
feature_flags:
new_dashboard: true
# config/staging.yaml
log_level: debug
request_timeout_seconds: 10
feature_flags:
new_dashboard: true
The app picks the file based on APP_ENV, then still pulls secrets
(DATABASE_URL, API_KEY) from the environment rather than from these
files — because these files are safe to commit, and secrets never should
be.
Worked example: wiring config end-to-end¶
# 1. Create the secret env file
sudo mkdir -p /etc/myapp
cat <<'EOF' | sudo tee /etc/myapp/myapp.env > /dev/null
APP_ENV=production
DATABASE_URL=postgresql://myapp:s3cr3t@localhost:5432/myapp
LOG_LEVEL=info
EOF
sudo chown root:deploy /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.env
# 2. Point the unit file at it (add this line under [Service])
# EnvironmentFile=/etc/myapp/myapp.env
sudo systemctl daemon-reload
sudo systemctl restart myapp
# 3. Confirm the process actually received the variables
sudo systemctl show myapp -p Environment
# or, given the PID:
sudo cat /proc/$(pgrep -f myapp)/environ | tr '\0' '\n' | grep APP_ENV
How It Actually Works¶
Environment variables as a per-process kernel data structure. Every
process has an environment block — a flat array of KEY=value strings living
alongside its argument list, set up by the execve() syscall that started
it. A child process inherits a copy of its parent's environment at fork
time; after that they're independent, which is why export FOO=bar in one
shell has no effect on an already-running sibling shell, and why a systemd
service's environment comes from Environment=/EnvironmentFile= in its
unit file (systemd is its direct parent, not your interactive shell) rather
than from whatever you exported at a terminal.
Why .env files aren't loaded automatically by the OS. There is no
kernel or shell mechanism that treats a file named .env specially — a
process reads environment variables purely from the block it was execve'd
with. Tools like dotenv libraries or docker run --env-file work by
explicitly parsing the file's KEY=value lines and calling setenv() (or
passing them into the child's environment block) before or during process
start; without that explicit step, .env is just an inert text file.
The precedence chain and why it matters for debugging. Most
applications resolve configuration through several layers: compiled
defaults → config file → environment variables → CLI flags, applied in that
order so each later layer overrides the former. A value that seems to be
"ignored" is almost always being overridden by a later layer — checking
printenv | grep VARNAME on the running process (cat /proc/<pid>/environ)
tells you definitively what the process actually received, independent of
what you think you set.
Exercise¶
- Create
/etc/myapp/myapp.envwith at least three variables, lock it down to640owned byroot:deploy. - Add
EnvironmentFile=/etc/myapp/myapp.envto thehello.serviceunit from module 4 and modifyhello.shto print one of those variables each loop iteration. daemon-reload+restart, then confirm viajournalctl -u hellothat the variable's value is showing up in the output.- Change the value in the env file, restart, and confirm the new value
takes effect — without touching
hello.shor the unit file at all.