Environment Management¶
Environment variables are the difference between "it works on my machine" and "it exploded in production" — they let your code behave differently in dev , staging , and prod without changing a single line The golden rule: your code should be environment-agnostic. The environment provides config via variables. If you're hardcoding database URLs in your source code , you're doing it wrong
.env Files — The Foundation¶
# .env — NEVER commit this
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-not-for-prod
API_KEY=sk-test-your-real-key-here
# .env.example — Commit this
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:pass@localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-in-production
API_KEY=get-your-own-api-key
The setup:
# Install dotenv
npm install dotenv
# Load in your app
require('dotenv').config()
# Or with path for different environments
require('dotenv').config({ path: `.env.${process.env.NODE_ENV}` })
NEVER commit .env files to git. Always add to .gitignore immediately Use .env.example as the template that documents every variable needed
Environment Per Environment — Dev , Staging , Prod¶
The naive approach: one .env file. The real approach: environment-specific configs
.env # Base config (loaded everywhere)
.env.development # Dev overrides
.env.staging # Staging overrides
.env.production # Production overrides
// config.js — Load environment-specific config
const dotenv = require('dotenv')
const path = require('path')
// Load base .env first
dotenv.config()
// Load environment-specific .env
const env = process.env.NODE_ENV || 'development'
dotenv.config({
path: path.resolve(process.cwd(), `.env.${env}`),
override: true // Environment-specific values override base
})
module.exports = {
port: parseInt(process.env.PORT, 10) || 3000,
database: {
url: process.env.DATABASE_URL,
pool: {
min: parseInt(process.env.DB_POOL_MIN, 10) || 2,
max: parseInt(process.env.DB_POOL_MAX, 10) || 10
}
},
redis: {
url: process.env.REDIS_URL
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d'
}
}
Secrets in CI — Don't Put Them in the YAML¶
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Deploy to production
run: ./deploy.sh
env:
# These are set in GitHub repo Settings > Secrets > Actions
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
Set secrets in GitHub UI: Repo → Settings → Secrets and variables → Actions → New repository secret
Best practices: * Never log secrets — GitHub Actions auto-masks secrets in logs * Use environment-specific secrets (production vs staging) * Rotate secrets regularly — set expiry dates * Use GitHub Environments for production secrets with approval gates
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Link to GitHub Environment
# Environment has its own secrets + required reviewers
Doppler — Environment Management on Steroids¶
When you outgrow .env files (multiple services , multiple environments , multiple team members), you need a secrets manager
# Install Doppler CLI
brew install dopplerhq/cli/doppler
# Or: curl -sL https://cli.doppler.com/install.sh | sh
# Setup
doppler login
doppler setup
# Fetch config
doppler secrets download --format env > .env
# Run app with Doppler
doppler run -- node server.js
# Switch between environments
doppler setup --config prd # Production
doppler setup --config stg # Staging
doppler setup --config dev # Development
# Share config with team
doppler secrets --config dev
Doppler in CI/CD:
- name: Inject secrets
uses: dopplerhq/cli-action@v3
with:
setup-configure: true
install-cli: true
- run: doppler run -- node server.js
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }}
Environment Validation — Fail Fast , Fail Loud¶
Invalid config should crash the app immediately , not fail silently three hours later
// validate-env.js
const envalid = require('envalid')
const { str, num, url } = envalid
const env = envalid.cleanEnv(process.env, {
NODE_ENV: str({ choices: ['development', 'staging', 'production'] }),
PORT: num({ default: 3000 }),
DATABASE_URL: url(),
JWT_SECRET: str(),
REDIS_URL: url(),
LOG_LEVEL: str({ default: 'info', choices: ['debug', 'info', 'warn', 'error'] })
})
module.exports = env
// server.js
const env = require('./validate-env')
// If DATABASE_URL is missing or invalid , the app won't start
// It'll throw immediately with a clear error message
console.log(`Starting server in ${env.NODE_ENV} mode on port ${env.PORT}`)
Benefits: * App fails at startup with clear error messages * Type coercion (PORT becomes a number , not a string) * Default values documented in one place * Invalid values caught immediately
Config as Code — The 12-Factor App Way¶
Store config in environment variables , not in the codebase The 12-factor app methodology — each deploy can change config without changing code
Do:
const dbConfig = {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
}
Don't:
const dbConfig = {
host: 'localhost',
port: 5432,
user: 'admin',
password: 'password123' // In git forever
}
The litmus test: Can you deploy the same Docker image to dev , staging , and prod without rebuilding? If you need to rebuild for different environments , your config management is broken
next → devops_08_secrets.md