Every application has configuration that varies by environment: database URLs, API keys, feature flags. The twelve-factor app says to store this in environment variables. That’s the easy part. The hard part is managing them securely across development, CI, staging, and production.

The Problem with .env Files

.env files work for local development but break down quickly:

  • They get committed. Someone forgets to update .gitignore. Now your secrets are in git history forever.
  • They drift. Each developer’s .env diverges. New variables are added to production but not to the .env.example.
  • They can’t be audited. Who changed what, when?

A Better Local Setup

Use .env.example as the source of truth for variable names:

# .env.example -- committed to git
DATABASE_URL=
STRIPE_API_KEY=
REDIS_URL=
FEATURE_NEW_CHECKOUT=

Then use a tool like direnv to load variables:

# .envrc (not committed)
export DATABASE_URL="postgres://localhost:5432/myapp_dev"
export STRIPE_API_KEY="sk_test_..."
export REDIS_URL="redis://localhost:6379"
export FEATURE_NEW_CHECKOUT="true"

direnv loads variables when you enter the directory and unloads them when you leave. No global pollution.

Secrets in Production

Production secrets should never be in files, environment variables on disk, or version control.

Secrets Managers

Use a secrets manager. The major options:

  • Cloudflare Workers Secrets — Built into the Workers platform.
  • AWS Secrets Manager / Parameter Store — Versioned, audited, IAM-controlled.
  • HashiCorp Vault — Self-hosted, most flexible, most complex.
  • 1Password / Doppler — Developer-friendly, good for teams.

Cloudflare Workers Example

# Set a secret (stored encrypted, not visible after setting)
npx wrangler secret put STRIPE_API_KEY
export default {
  async fetch(request, env) {
    // env.STRIPE_API_KEY is available at runtime
    const stripe = new Stripe(env.STRIPE_API_KEY);
  },
};

CI/CD Variables

  • Store secrets in CI platform (GitHub Actions secrets, GitLab CI variables). Never in pipeline files.
  • Use OIDC where possible. Instead of storing cloud credentials in CI, use identity federation.
  • Rotate regularly. Treat CI secrets like any other credential.
# GitHub Actions
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/deploy
          aws-region: us-east-1

No AWS keys stored in GitHub. The workflow gets temporary credentials via OIDC.

Validation

Validate all environment variables at startup, not when they’re first used:

import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_API_KEY: z.string().startsWith("sk_"),
  PORT: z.coerce.number().default(3000),
});

export const env = envSchema.parse(process.env);

Fail fast with a clear message. “Missing DATABASE_URL” at startup is infinitely better than a cryptic error deep in a request handler.

Configuration management isn’t glamorous, but a leaked secret or a misconfigured environment can bring your system down. Take it seriously.