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

PracticeDoDon’t
NamingDATABASE_URLdbUrl, database-url
SecretsLoad from secrets managerCommit to git
DefaultsProvide safe defaultsAssume values exist
ValidationValidate at startupTrust input blindly
TypesConvert explicitlyAssume type
FilesUse .env for local devUse .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.