Environment files are deceptively simple. A few KEY=value pairs, what could go wrong? Quite a bit, actually. Here’s how to manage them without shooting yourself in the foot.

The Basic Rules

1
2
3
4
# .env
DATABASE_URL=postgres://localhost:5432/myapp
API_KEY=sk_test_abc123
DEBUG=true

Rule 1: Never commit secrets to git.

1
2
3
4
5
6
# .gitignore
.env
.env.local
.env.*.local
*.pem
*.key

Rule 2: Always commit an example file.

1
2
3
4
# .env.example (committed to repo)
DATABASE_URL=postgres://localhost:5432/myapp
API_KEY=your_api_key_here
DEBUG=true

New developers copy .env.example to .env and fill in their values. The example documents what’s needed without exposing real credentials.

Multiple Environments

Don’t use one .env for everything. Layer them:

....eeeennnnvvvv...ldpoercvoaedlluocptmieonnt####SLDPhoeracvora-dels-dpsepdvceeeicfrfiarifuicildcte(ssc(on((mocnmtooimttcmtoicemtodmtm)iemtdit)tetdedo)rencrypted)

Most frameworks load in order, with later files overriding earlier ones:

1
2
3
4
5
# Loading order (varies by framework)
1. .env
2. .env.local
3. .env.{environment}
4. .env.{environment}.local

Framework Examples

Node.js with dotenv:

1
2
3
4
// Load environment-specific file
require('dotenv').config({ 
  path: `.env.${process.env.NODE_ENV || 'development'}` 
});

Docker Compose:

1
2
3
4
5
services:
  app:
    env_file:
      - .env
      - .env.${ENVIRONMENT:-development}

Python with python-dotenv:

1
2
3
4
5
6
from dotenv import load_dotenv
import os

env = os.getenv('ENVIRONMENT', 'development')
load_dotenv(f'.env.{env}')
load_dotenv('.env')  # Fallback defaults

Variable Expansion

Some tools support referencing other variables:

1
2
3
4
5
# .env
BASE_URL=https://api.example.com
API_ENDPOINT=${BASE_URL}/v1
DATABASE_HOST=localhost
DATABASE_URL=postgres://${DATABASE_HOST}:5432/myapp

Not all parsers support this. Test before relying on it.

Quoting Rules

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# No quotes needed for simple values
SIMPLE=hello

# Single quotes: literal (no expansion)
SINGLE='$PATH stays literal'

# Double quotes: allows spaces, some expansion
DOUBLE="hello world"

# Multiline values
MULTILINE="line one
line two
line three"

# Or use \n (parser-dependent)
MULTILINE_ALT="line one\nline two\nline three"

When in doubt, use double quotes for values with spaces or special characters.

Secrets Management Patterns

Pattern 1: Encrypted .env Files

Use sops, age, or git-crypt to encrypt production secrets:

1
2
3
4
5
# Encrypt with sops
sops -e .env.production > .env.production.enc

# Decrypt at deploy time
sops -d .env.production.enc > .env.production

The encrypted file can be committed; the decryption key stays in your CI/CD secrets.

Pattern 2: External Secret Managers

Pull secrets at runtime instead of storing in files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# AWS Secrets Manager
DATABASE_URL=$(aws secretsmanager get-secret-value \
  --secret-id prod/database \
  --query SecretString --output text)

# HashiCorp Vault
DATABASE_URL=$(vault kv get -field=url secret/database)

# 1Password CLI
DATABASE_URL=$(op read "op://Vault/Database/url")

Pattern 3: CI/CD Injection

Let your deployment pipeline inject secrets:

1
2
3
4
5
6
7
8
# GitHub Actions
env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  API_KEY: ${{ secrets.API_KEY }}

# GitLab CI
variables:
  DATABASE_URL: $DATABASE_URL  # From CI/CD settings

The secrets never touch disk—they exist only in the process environment.

Validation

Don’t discover missing variables at runtime. Validate at startup:

1
2
3
4
5
6
7
8
// Node.js
const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
const missing = required.filter(key => !process.env[key]);

if (missing.length > 0) {
  console.error(`Missing required environment variables: ${missing.join(', ')}`);
  process.exit(1);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Python
import os
import sys

required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET']
missing = [key for key in required if not os.getenv(key)]

if missing:
    print(f"Missing required environment variables: {', '.join(missing)}")
    sys.exit(1)

Libraries like envalid (Node) or pydantic (Python) provide typed validation with defaults and error messages.

Docker Considerations

1
2
3
4
5
# Don't bake secrets into images
ENV API_KEY=secret  # ❌ Bad - visible in image layers

# Pass at runtime instead
# docker run -e API_KEY=secret myapp  # ✅ Good

For Docker Compose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
services:
  app:
    environment:
      # Direct value (avoid for secrets)
      DEBUG: "true"
      
      # From host environment
      API_KEY: ${API_KEY}
      
      # From .env file (preferred)
    env_file:
      - .env

Security Checklist

  • .env files in .gitignore
  • .env.example committed with dummy values
  • No secrets in Dockerfiles or docker-compose.yml
  • Production secrets encrypted or in external manager
  • CI/CD secrets in platform’s secret storage
  • Validation at application startup
  • Secrets rotated periodically
  • Access to secrets audited

Common Mistakes

Mistake: Committing .env to test “just this once”

1
2
# Check if you've ever committed secrets
git log --all --full-history -- "*.env" ".env*"

If you have, rotate those credentials immediately. Git history is forever.

Mistake: Sharing .env files via Slack/email

Use a password manager or secret sharing tool. Messages persist in logs and backups.

Mistake: Same secrets across environments

Your dev API key should not work in production. Isolate environments completely.

Mistake: Overly broad secrets

1
2
3
4
5
6
7
# Bad - one key for everything
MASTER_KEY=abc123

# Good - scoped secrets
DATABASE_PASSWORD=xxx
STRIPE_API_KEY=yyy
JWT_SECRET=zzz

If one leaks, you only rotate one.

Local Development Setup Script

Make onboarding easy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# setup.sh

if [ ! -f .env ]; then
  cp .env.example .env
  echo "Created .env from .env.example"
  echo "Please edit .env with your local values"
  exit 1
fi

# Validate required vars
source .env
REQUIRED="DATABASE_URL API_KEY"
for var in $REQUIRED; do
  if [ -z "${!var}" ]; then
    echo "Missing required variable: $var"
    exit 1
  fi
done

echo "Environment validated ✓"

Environment files are a solved problem—if you follow the patterns. Keep secrets out of git, validate early, layer your configurations, and use proper secret management for production. Your future self (and your security team) will thank you.