How to structure environment variables in large projects

At scale, environment variables become an undocumented API between teams. New hires grep for process.env across forty packages and guess. Structure is documentation + naming + boundaries — not a fancier .env parser.


1. Principles (12-factor aligned)

The Twelve-Factor config factor separates code (same across deploys) from config (varies per environment). In large codebases:

  • Names are stable and namespaced.
  • Defaults for non-secret config can live in code or small committed files.
  • Secrets are never committed — only templates.

2. Naming conventions

Prefix by subsystem

WEB_PUBLIC_SITE_URL          # safe for client if truly public
API_DATABASE_URL             # server only
WORKER_REDIS_URL
SHARED_OTEL_EXPORTER_OTLP_ENDPOINT

Explicit environment scope (optional suffix)

PAYMENTS_STRIPE_WEBHOOK_SECRET_DEV
PAYMENTS_STRIPE_WEBHOOK_SECRET_STAGING

Better yet: separate deploys with separate secret stores so names do not need _PROD in every key.


3. Monorepo layout

Per-app .env.example

apps/
  web/.env.example
  api/.env.example
  worker/.env.example

Root README links to each. Avoid a root .env that becomes a god object.

Shared packages

Never put secrets in packages/ui or packages/types. If a package needs config, accept parameters from the app that owns secrets.


4. Microservices (polyrepo)

Standardize cross-cutting names across repos:

SERVICE_NAME
OTEL_SERVICE_NAME
LOG_LEVEL

Document service-specific keys in each repo’s docs/env.md.


5. Infrastructure-as-code vs runtime

MaterialBelongs in
Terraform variables (non-secret)*.tfvars.example committed
Terraform secretsRemote backend / CI vars / Vault — not committed
App runtimeInjected by platform at container start

Do not duplicate the same secret in both Terraform state and also every developer’s .env unless you have a migration story.


6. Discovery tooling (optional but valuable)

  • Zod / envalid / language equivalents: validate required env at startup with a single error message listing missing keys.
  • Single config.ts per app that reads process.env once — stop scattering raw process.env.FOO across 200 files.

Example (TypeScript sketch):

// apps/api/src/config.ts
function required(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required env: ${name}`);
  return v;
}

export const config = {
  databaseUrl: required("API_DATABASE_URL"),
  port: Number(process.env.PORT ?? "3000"),
};

7. Human documentation

Maintain docs/environment.md per service:

  • Table: Name | Required? | Example (fake) | Owner team | How to obtain.

8. Where PassStore fits

Developers still need one place on macOS for their dev copies — PassStore workspaces mirror per-service groups without duplicating prod material on disk. Pair with organize secrets across multiple projects.


Related