Skip to content

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