The twelve-factor app methodology made environment variables the standard for configuration. They’re simple, universal, and keep secrets out of code. But there are right and wrong ways to use them.
The Basics#
1
2
3
4
5
6
7
8
9
10
11
| # Set in current shell
export DATABASE_URL="postgres://localhost/mydb"
# Set for single command
DATABASE_URL="postgres://localhost/mydb" ./myapp
# Check if set
echo $DATABASE_URL
# Unset
unset DATABASE_URL
|
.env Files#
Don’t commit secrets. Use .env files for local development:
1
2
3
4
5
| # .env (gitignored!)
DATABASE_URL=postgres://localhost/mydb
REDIS_URL=redis://localhost:6379
SECRET_KEY=dev-secret-not-for-production
DEBUG=true
|
1
2
3
4
| # .gitignore
.env
.env.local
.env.*.local
|
Loading .env Files#
Shell:
1
2
3
4
5
| # Load into current shell
export $(grep -v '^#' .env | xargs)
# Or use a tool
source .env # Only works if exported
|
Node.js (dotenv):
1
2
| require('dotenv').config();
console.log(process.env.DATABASE_URL);
|
Python (python-dotenv):
1
2
3
4
5
| from dotenv import load_dotenv
import os
load_dotenv()
database_url = os.getenv("DATABASE_URL")
|
Docker Compose:
1
2
3
4
5
| services:
app:
env_file:
- .env
- .env.local # Overrides
|
Naming Conventions#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| # Good: SCREAMING_SNAKE_CASE
DATABASE_URL=...
API_SECRET_KEY=...
AWS_ACCESS_KEY_ID=...
# Prefix by app/service
MYAPP_DATABASE_URL=...
MYAPP_LOG_LEVEL=...
# Common patterns
*_URL # Connection strings
*_HOST # Hostnames
*_PORT # Port numbers
*_KEY # API keys, secrets
*_SECRET # Secrets
*_PASSWORD # Passwords
*_ENABLED # Boolean flags
*_COUNT # Numbers
*_PATH # File paths
|
Type Coercion#
Environment variables are always strings. Handle conversion:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import os
# Boolean
debug = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
# Integer
port = int(os.getenv("PORT", "8080"))
# List
allowed_hosts = os.getenv("ALLOWED_HOSTS", "localhost").split(",")
# With validation
def get_env_int(key, default):
value = os.getenv(key)
if value is None:
return default
try:
return int(value)
except ValueError:
raise ValueError(f"{key} must be an integer, got: {value}")
|
1
2
3
4
| // Node.js
const debug = process.env.DEBUG === 'true';
const port = parseInt(process.env.PORT, 10) || 8080;
const hosts = (process.env.ALLOWED_HOSTS || 'localhost').split(',');
|
Required vs Optional#
Fail fast on missing required variables:
1
2
3
4
5
6
7
8
9
10
11
12
13
| import os
import sys
REQUIRED_VARS = [
"DATABASE_URL",
"SECRET_KEY",
"REDIS_URL",
]
missing = [var for var in REQUIRED_VARS if not os.getenv(var)]
if missing:
print(f"Missing required environment variables: {', '.join(missing)}")
sys.exit(1)
|
1
2
3
4
5
6
7
| const required = ['DATABASE_URL', 'SECRET_KEY', 'REDIS_URL'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required environment variables: ${missing.join(', ')}`);
process.exit(1);
}
|
Secrets Management#
Don’t Do This#
1
2
3
4
5
6
7
8
9
| # Visible in process list
DATABASE_PASSWORD=secret ./myapp
# Visible in shell history
export API_KEY=supersecret
# Committed to git
echo "API_KEY=secret" >> .env
git add .env # NO!
|
Do This Instead#
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Read from file
export API_KEY=$(cat /run/secrets/api_key)
# Use a secrets manager
export API_KEY=$(aws secretsmanager get-secret-value --secret-id myapp/api_key --query SecretString --output text)
# Prevent history logging
read -s API_KEY
export API_KEY
# Or set HISTCONTROL
HISTCONTROL=ignorespace
export API_KEY=secret # Leading space = not saved
|
Docker Secrets#
1
2
3
4
5
6
7
8
9
10
11
| # docker-compose.yml
services:
app:
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
|
1
2
3
4
5
6
7
8
9
| # Read from file if *_FILE variant exists
def get_secret(name):
file_var = f"{name}_FILE"
if os.getenv(file_var):
with open(os.getenv(file_var)) as f:
return f.read().strip()
return os.getenv(name)
db_password = get_secret("DB_PASSWORD")
|
Environment-Specific Configuration#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # .env.example (committed - template)
DATABASE_URL=postgres://user:pass@localhost/myapp
SECRET_KEY=generate-a-real-key
DEBUG=false
# .env.development (local)
DATABASE_URL=postgres://localhost/myapp_dev
SECRET_KEY=dev-key-not-secret
DEBUG=true
# .env.test (CI)
DATABASE_URL=postgres://localhost/myapp_test
SECRET_KEY=test-key
DEBUG=false
# Production: Set via deployment platform, not files
|
Loading by Environment#
1
2
3
4
5
6
| from dotenv import load_dotenv
import os
env = os.getenv("APP_ENV", "development")
load_dotenv(f".env.{env}")
load_dotenv(".env") # Fallback/defaults
|
Validation Libraries#
Python (pydantic)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
secret_key: str
debug: bool = False
port: int = 8080
allowed_hosts: list[str] = ["localhost"]
class Config:
env_file = ".env"
settings = Settings()
print(settings.database_url)
|
Node.js (envalid)#
1
2
3
4
5
6
7
8
9
10
| const { cleanEnv, str, bool, num, url } = require('envalid');
const env = cleanEnv(process.env, {
DATABASE_URL: url(),
SECRET_KEY: str(),
DEBUG: bool({ default: false }),
PORT: num({ default: 8080 }),
});
console.log(env.DATABASE_URL);
|
CI/CD Integration#
GitHub Actions#
1
2
3
4
5
6
7
8
9
10
| jobs:
deploy:
env:
NODE_ENV: production
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: ./deploy.sh
|
Docker Build Args vs Runtime Env#
1
2
3
4
5
6
7
| # Build-time (baked into image - don't use for secrets!)
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
# Runtime (set when container starts)
# Don't set secrets here - pass at runtime
ENV PORT=8080
|
1
2
| # Pass secrets at runtime
docker run -e DATABASE_URL="$DATABASE_URL" myapp
|
Debugging#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # List all environment variables
env
printenv
# Check specific variable
echo $DATABASE_URL
printenv DATABASE_URL
# In Python
python -c "import os; print(os.environ)"
# In Node
node -e "console.log(process.env)"
# Check what a process sees
cat /proc/<pid>/environ | tr '\0' '\n'
|
Common Patterns#
Connection Strings#
1
2
3
4
5
6
7
8
9
10
11
| # Database
DATABASE_URL=postgres://user:pass@host:5432/dbname
DATABASE_URL=mysql://user:pass@host:3306/dbname
DATABASE_URL=mongodb://user:pass@host:27017/dbname
# Redis
REDIS_URL=redis://user:pass@host:6379/0
REDIS_URL=redis://host:6379 # No auth
# General format
PROTOCOL://USER:PASSWORD@HOST:PORT/PATH?QUERY
|
Feature Flags#
1
2
3
| FEATURE_NEW_UI=true
FEATURE_BETA_API=false
FEATURE_DARK_MODE=true
|
1
2
3
4
5
| def feature_enabled(name):
return os.getenv(f"FEATURE_{name.upper()}", "false").lower() == "true"
if feature_enabled("new_ui"):
render_new_ui()
|
Log Levels#
1
| LOG_LEVEL=debug # debug, info, warn, error
|
Quick Reference#
| Practice | Do | Don’t |
|---|
| Naming | DATABASE_URL | dbUrl, database-url |
| Secrets | Load from secrets manager | Commit to git |
| Defaults | Provide safe defaults | Assume values exist |
| Validation | Validate at startup | Trust input blindly |
| Types | Convert explicitly | Assume type |
| Files | Use .env for local dev | Use .env in production |
Environment variables are boring infrastructure that makes everything else work. Get them right once — validate at startup, use consistent naming, keep secrets out of code — and you’ll never think about them again. Which is exactly the point.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.