Secrets
Where secrets live
Section titled “Where secrets live”| Environment | Storage | How they reach the running process |
|---|---|---|
| Local dev | .env.local in each app | Bun + Vite read at startup. |
| Dev (deployed) | Dokploy per-app Environment tabs (runtime) + the data-services Compose env; svelte-web build-time values go in Build Arguments (non-secret: PUBLIC_API_URL, Sentry DSN/org/project — SENTRY_AUTH_TOKEN is omitted in dev) | Dokploy injects on container boot; build args bake at image build. |
| Staging + Production | GCP Secret Manager, automatic (multi-region) replication; GCS access is Workload Identity, so no static storage keys exist in either tier | Injected into Cloud Run at deploy; rotation is a secret-version bump. Decided, not provisioned yet — see Production landscape. |
| Native mobile builds | Per-app build config (buildConfigField / build settings) + gitignored signing/keystore files | Compiled into the binary at native build time; client-safe values only. |
Categories of secrets
Section titled “Categories of secrets”| Category | Server-only | Client-safe |
|---|---|---|
| Database | DATABASE_URL, DIRECT_URL | — |
| Auth | BETTER_AUTH_SECRET, GOOGLE_CLIENT_SECRET | GOOGLE_CLIENT_ID (public on OAuth consent) |
| Cache / queues | VALKEY_URL (incl. password) | — |
| Search | MEILISEARCH_ADMIN_API_KEY | MEILISEARCH_SEARCH_API_KEY (read-only, scoped to public indexes) |
| Storage | AWS_SECRET_ACCESS_KEY | — |
| Image proxy | IMGPROXY_KEY, IMGPROXY_SALT | imgproxy URLs (signed) |
RESEND_API_KEY | — | |
| Chat | GETSTREAM_API_SECRET | PUBLIC_GETSTREAM_API_KEY (public-by-design, gated by server-issued user tokens) |
| Notifications | KNOCK_SECRET_API_KEY, KNOCK_FCM_CHANNEL_ID, KNOCK_APNS_CHANNEL_ID | PUBLIC_KNOCK_API_KEY, PUBLIC_KNOCK_FEED_CHANNEL_ID |
| Maps | GOOGLE_MAPS_API_KEY (server) | Android Maps key in the Android project (iOS uses MapKit — no key); restricted by package name + signing-cert SHA-1 |
| Analytics | SENTRY_AUTH_TOKEN | SENTRY_DSN, PUBLIC_POSTHOG_KEY |
| AI | GEMINI_API_KEY, REPLICATE_API_TOKEN, REPLICATE_WEBHOOK_SIGNING_SECRET, AI_STAGING_USER_HASH_SECRET | — |
| Payments | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SIGNING_SECRET | — (hosted Checkout, so no publishable key ships — there is no Stripe.js on the client) |
| Fiscal / CFDI | FACTURAPI_SECRET_KEY (the FACTURAPI_PRODUCT_KEY / _UNIT_KEY / _PAYMENT_FORM overrides are SAT catalog values, not secrets) | — |
| Fiscal data | PII_ENCRYPTION_KEY — non-re-issuable, see below | — |
Non-re-issuable secrets
Section titled “Non-re-issuable secrets”Every secret above this line can be replaced. Lose RESEND_API_KEY and you issue a new one; lose AI_STAGING_USER_HASH_SECRET and the old pseudonyms stop correlating, which costs nothing anyone can name. The rotation procedure below assumes exactly that, and its first step — “issue a new credential in the provider dashboard” — is meaningless for a key with no dashboard behind it.
PII_ENCRYPTION_KEY is the first secret in this repo that does not work that way.
It is a data-encrypting key. It is self-generated (openssl rand -base64 32), no provider holds a copy, and it is the only thing that can read tax_profiles.{taxId,legalName,zip,email} and cfdi_documents.{receiverTaxId,receiverLegalName} — the RFC, razón social and fiscal domicile of every paying customer (FSR-014, packages/api/src/services/pii-crypto.ts).
Consequences that follow from that, and are not optional:
- An escrowed copy must exist outside the deployment platform, before the first encrypted row does. Dokploy’s env UI is the live store today, and a platform account problem must not be the same event as permanent data loss.
- Record the vault and item name here — never the value. Escrowed copy lives in:
⚠️ NOT YET RECORDED. Left visibly blank rather than hidden in a comment: this is the one line that makes the escrow real rather than intended, and an unfinished sentence on the page is harder to overlook than a TODO nobody greps for. - Never derive it from another secret, and never reuse it. Coupling it to something with a routine rotation cadence is exactly the mistake documented under
BETTER_AUTH_SECRETbelow. - Rows written before FSR-014 shipped are plaintext at rest and stay readable with or without the key, which is what makes a rollback safe — it does not make the key optional for anything written since.
Rotation procedure
Section titled “Rotation procedure”- Issue a new credential in the provider dashboard (Better Auth, Resend, Knock, etc.).
- Update the Dokploy env var on the dev environment first.
- Redeploy and verify nothing breaks.
- Update STG — a new Secret Manager version on the
rec-stgsecret, then redeploy and verify. Skipping STG defeats the reason it exists: rotation is exactly the class of change that should break on a rehearsal tier rather than in production. - Update production the same way (once it exists).
- Revoke the old credential in the provider dashboard.
For BETTER_AUTH_SECRET rotation: rotating the secret invalidates all sessions. Coordinate with the team so users get a single sign-out event instead of a silent log-out mid-flow.
For IMGPROXY_KEY / IMGPROXY_SALT rotation: all currently rendered HTML pages and SSR caches still hold URLs signed with the old key. Either (a) keep the old key alongside the new for an overlap window (imgproxy supports multiple keys), or (b) accept a short window of broken images while pages re-render.
For REPLICATE_WEBHOOK_SIGNING_SECRET rotation: fetch the current default secret from Replicate, update apps/api, and redeploy the API before creating new predictions. Webhooks are verified against the exact raw body with a five-minute replay window. REPLICATE_API_TOKEN belongs only on the worker; apps/api needs the webhook signing secret but not the API token.
Mobile-specific guardrails
Section titled “Mobile-specific guardrails”Any client-side key compiled into a native binary ships to user devices. Anyone with the IPA / APK can extract these keys (via strings, decompilation, or sniffing the first network request).
Provider-side restrictions are mandatory before public ship. See Architecture → Deployment for the per-key restriction recipes.
Server-only secrets like GETSTREAM_API_SECRET and KNOCK_SECRET_API_KEY must never be shipped to a mobile bundle. They belong in apps/api’s env (repo-root .env.local locally, the Dokploy api app’s Environment tab on the deployed dev env) — never in any client app.
Audit checklist
Section titled “Audit checklist”- No
.env*(except.env.example) committed. - No
*_vault.yml(decrypted) committed. - No
*.tfvars(real values) committed. - No
google-services.jsoncommitted. - No
*.pem/*.jkscommitted. - CI logs scrubbed of secret values (Dokploy build logs stay on the VPS; GitHub Actions secrets are masked).
- Sentry
beforeSendscrubber is installed in every Sentry init. - Mobile builds have provider-side API key restrictions applied.