Where secrets live, how they're loaded, and how to rotate them without downtime.
Status: Implemented (env hierarchy + boot-time validation) + Partial (rotation procedure — documented, not automated).
Env-var hierarchy
Govula reads ALL configuration from environment variables. There is no on-disk config file. The hierarchy is, in priority order:
- Process env at runtime (set by Railway / Vercel / your platform /
docker run -e). .envfile at repository root (development only; ignored by.gitignore).- Hard-coded defaults in
src/config/index.tsandsrc/config/communications.ts— only safe-by-design defaults (HOST=0.0.0.0,EMAIL_FROM=…@govula.com).
Boot validation in src/config/validateEnv.ts refuses to start in production when any required secret is missing or weak. The full template is .env.example.
Required vs optional, at a glance
| Category | Variables | Behaviour when unset |
|---|---|---|
| Required in prod | DATABASE_URL, JWT_SECRET (≥32 chars), JWT_REFRESH_SECRET (≥32 chars), CORS_ALLOWED_ORIGINS (no *), PUBLIC_URL, RESEND_API_KEY + ALERT_EMAIL_TO + EMAIL_FROM (all-or-none) | Boot fails with a structured error |
| Recommended | SENTRY_DSN, RAILWAY_API_TOKEN, VERCEL_API_TOKEN, NEON_API_KEY, every COMMS_* | Boot succeeds, structured warn emitted |
| Optional | Feature flags, cron toggles, federation keys, SSO config | Feature degrades or stays off |
Per-platform secret stores
Railway (canonical backend host)
- Project → service → Variables tab.
- Secrets are encrypted at rest, decrypted into the container env at boot.
- Changes trigger a redeploy.
Vercel (canonical frontend host)
- Project → Settings → Environment Variables.
- Mark each variable with the appropriate scope (Production / Preview / Development).
NEXT_PUBLIC_*vars are baked into the client bundle at build time — changing them requires a rebuild, not just a redeploy.
Self-hosted Docker
Three options, in increasing rigour:
| Method | When | Notes |
|---|---|---|
docker run -e KEY=value | Local dev | Visible in docker inspect |
--env-file .env | Single-host prod | File must be chmod 600, owned by root |
Docker secrets (/run/secrets/<name>) | Swarm or Compose v3.1+ | Requires app to read from file path; Govula does NOT support this out of the box |
For Docker secrets support, wrap the container entrypoint:
#!/bin/sh
export JWT_SECRET="$(cat /run/secrets/jwt_secret)"
exec node dist/index.js
Self-hosted with sops / Vault
- sops + age/PGP: encrypt
.envat rest in git; decrypt on the deploy host into ephemeral env. Works well with GitOps. - HashiCorp Vault: use the Vault Agent sidecar to template env vars before app start. Govula reads its env normally; Vault is invisible to the app.
The communication identity registry (T21)
src/config/communications.ts is the single source of truth for every external-facing email identity (support, security, governance, investor, founder-alerts, notifications). Each reads a COMMS_* env var with a clearly non-production @govula.com placeholder fallback. See docs/communications.md for the full table.
Implication for rotation: changing a contact address is a one-env-var change with no code deploy.
Federation keypair
GPS uses Ed25519 signatures. The keypair lives in FEDERATION_PRIVATE_KEY / FEDERATION_PUBLIC_KEY. Generate with the helper in src/services/federationCryptoService.ts.
Rotation:
- Generate new keypair.
- Set new
FEDERATION_PUBLIC_KEYon every peer first (peers verify with this). - Wait until peers have rotated.
- Set new
FEDERATION_PRIVATE_KEYon this instance and restart. - Old public key can be removed after one signal cycle.
JWT secret rotation
JWT_SECRET and JWT_REFRESH_SECRET are rotated independently:
JWT_SECRETsigns access tokens (default 1h TTL). Rotating logs everyone out at most 1h after the rotation. To rotate without downtime, accept BOTH old and new during a transition window — Govula does not currently ship dual-secret support, so the operational reality is "rotate during a low-traffic window, accept brief reauth".JWT_REFRESH_SECRETsigns refresh tokens. Rotation immediately invalidates every refresh token (forcing reauth on next refresh). This is exactly the documented hazard of leaving it unset (see../audits/phase1-readiness-audit.mdF-A3).
Rotation policy (recommended cadence)
| Secret | Cadence | Procedure |
|---|---|---|
JWT_SECRET | Annual or on suspected compromise | Set new value; restart; users reauth on next request |
JWT_REFRESH_SECRET | Annual or on suspected compromise | Same; users reauth on next refresh |
RESEND_API_KEY | Annual | Generate new key in Resend; swap env var; redeploy |
FEDERATION_PRIVATE_KEY | Quarterly | Two-phase rollout above |
DATABASE_URL (creds) | When DBA rotates | Update env, redeploy; pool drains and reconnects |
| Observability tokens | When platform rotates | Each is independently optional; degrades gracefully |
What NOT to do
- Do not put secrets in
replit.md,README.md, or any doc. - Do not put secrets in build args without
--secretmounts. - Do not commit
.env(already in.gitignore). - Do not print secrets in logs — Pino redaction (
src/utils/logger.ts:55-63) coverspassword,token,secret,authorization,cookie,apiKeyat top-level, nested, andmetadata.*paths, but new keys you add are not auto-redacted.
Where to read more
production-hardening.md— every assertion enforced at boot../communications.md— the identity registryci-cd.md— secret handling in CI- In-app:
/docs/deployment/secrets-management