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
| 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.