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
| Material | Belongs in |
|---|---|
| Terraform variables (non-secret) | *.tfvars.example committed |
| Terraform secrets | Remote backend / CI vars / Vault — not committed |
| App runtime | Injected 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.tsper app that readsprocess.envonce — stop scattering rawprocess.env.FOOacross 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.