Skip to Content
InfraSecrets & config

Secrets & configuration

This page is the single inventory of where every secret and environment variable lives and how each app receives it. It’s cross-cutting by nature — secrets span GitHub Actions, Infisical, and DigitalOcean, and no single component owns them — which is why it sits in infra/.

Names only. This page never contains secret values — neither GitHub, DigitalOcean, nor Infisical will reveal a stored secret after creation, and we don’t reproduce them here. If you need a value, rotate it (see Rotation).

The three stores

StoreHoldsRead by
InfisicalSource of truth for application secrets, per project, per environment (dev / staging / prod).The apps (at runtime or via sync) + the migration runner.
DigitalOcean app envWhatever an app needs at runtime — either just Infisical bootstrap creds, or the full synced set.The running container (process.env).
GitHub Actions secretsCredentials the CI/CD workflows need — DO API token, S3, Infisical bootstrap, Slack..github/workflows/* only.

Two secret-loading models

Knowing which model an app uses explains its DO env footprint and its failure modes.

ModelHow it loadsDO env footprintFailure mode
A — runtime pullApp logs into Infisical on boot with a universal-auth identity (INFISICAL_CLIENT_ID/SECRET) and fetches all secrets into process.env (api/src/shared/utils/set-env).Only the 5 INFISICAL_* bootstrap vars.Bad/expired creds → boot crash (Missing required environment variables / 401 Invalid credentials).
B — Infisical→DO syncInfisical pushes secrets directly into the DO env panel via the DO App Platform connector; the app just reads process.env.Full secret set as SECRET-typed vars.No runtime Infisical dependency — nothing to expire at boot.

When both apply, Model B wins: setEnvFromInfisical skips any key already present in process.env, so a synced var overrides the runtime pull.

Per-app model

All deployed apps are now Model B (synced) — the runtime Infisical pull is off everywhere. Migrated June 2026; see Migration.

AppDO app idSynced from new project b0466e3e…App-level keys
iba-api (prod)5c790555…/api, prod69 (/api 57 + /shared 12)
iba-www (prod)117b6530…/www, prod18 (/www 6 + /shared 12)
iba-admin (prod)ad522377…/admin, prod3 (NEXT_PUBLIC_*)
iba-dev-api (playground)1620ef3e…/api, staging69
tunnelflight-assistanted1d52aa…its own Infisical project27
Counts verified against the live DO specs on 2026-08-03. iba-api grew 65 → 69 since the June cutover: GA4_MEASUREMENT_ID, GA4_MP_API_SECRET, SLACK_CRON_WEBHOOK_URL, SLACK_CRONS_WEBHOOK_URL. The last two are near-duplicates — SLACK_CRONS_WEBHOOK_URL is the one the crons use (kept distinct from the deploys webhook); the singular is a leftover.

Component-level on each app: SKIP_INFISICAL=1 + DO build flags (USE_NPM_INSTALL, NODE_MODULES_CACHE; www also NPM_CONFIG_PRODUCTION=false so sass survives the build). The old INFISICAL_* bootstrap creds were removed after cutover. The prod API’s db-migrate Pre-Deploy job runs on the app-level synced DB_MIGRATE_* (no bootstrap, no pull).

IS_PRODUCTION is overloaded — worth knowing. if (!IS_PRODUCTION) gates the runtime pull and dev-only guards; if (IS_PRODUCTION === 'Yes') gates real production side-effects across ~20 crons/scripts. Syncing /shared (which contains IS_PRODUCTION) both disabled the pull and set the prod flag on apps that previously ran without it — a correction, but be aware it arms the === 'Yes' paths (which live in crons/scripts run via the GH Actions scheduler, not the web apps). We rely on the explicit SKIP_INFISICAL=1 to disable the pull, independent of IS_PRODUCTION.

⚠️ The truthiness form is a trap. IS_PRODUCTION is Yes in prod and No in staging / dev — and "No" is truthy in JS. So !process.env.IS_PRODUCTION in api/index.js is false in staging and dev, meaning the runtime pull is disabled there by the string "No", not by SKIP_INFISICAL. Today that’s masked, because every app is Model B and carries SKIP_INFISICAL=1 anyway. The trap is the recovery path: drop SKIP_INFISICAL expecting the pull to resume — debugging a bad sync, or standing up a new app — and it silently won’t. The app boots on whatever the sync happened to deliver, and the loud Missing required environment variables throw that set-env exists to produce never fires.

Three conventions are live for the same variable name: !IS_PRODUCTION (truthiness, 19 sites in api/src), === 'Yes' (13 sites in api/src), and === "true" (assistant/src/lib/infisical.ts, where the value is true/false rather than Yes/No). The assistant’s strict comparison is the correct pattern; the API’s truthiness check is the one to fix.

GitHub Actions secrets

There is exactly one: GITHUB_TOKEN — the token GitHub injects itself. Every other CI credential moved into Infisical /github-ci and is fetched at run time over OIDC, so no long-lived secret is stored in GitHub at all.

Five workflows authenticate this way — crons.yml, db-backup.yml, do-deployment.yml, pr-preview-create.yml, pr-preview-cleanup.yml — each declaring permissions: id-token: write and calling infisical/secrets-action:

identity-id: a16d53a8-23a6-4994-b61e-7bc66da545ff project-slug: tunnelflight-2g-un env-slug: prod secret-path: /github-ci

The 13 keys in /github-ci:

KeyPurpose
CRONS_API_KEYauthenticates the scheduled cron caller
DO_API_TOKENdoctl auth for deploy + preview workflows
DO_ASSISTANT_APP_IDassistant deploy target
DO_DB_CLUSTER_IDpreview-DB provisioning
INFISICAL_CLIENT_IDhanded to preview apps, which are still Model A
INFISICAL_CLIENT_SECRETditto
INFISICAL_PROJECT_IDditto
S3_BUCKET_ACCESS_KEY_IDpreview-DB restore + prod DB backup
S3_BUCKET_SECRET_ACCESS_KEYditto
S3_BUCKET_NAMEditto
S3_REGIONditto
SLACK_WEBHOOK_URLdeploy-failure alerts
SLACK_CRONS_WEBHOOK_URLcron-failure alerts (distinct from deploys)

The Infisical bootstrap credentials living inside Infisical isn’t circular — OIDC breaks the chicken-and-egg. The workflow proves its identity with GitHub’s own OIDC token, then reads the credentials it needs to hand to preview apps, which still use the Model A runtime pull (INFISICAL_ENVIRONMENT: staging, INFISICAL_SECRET_PATH: /api). Long-lived apps never see them.

Infisical: the API / WWW / Admin keys

These app secrets live in the foldered project b0466e3e-98a7-4cb8-9db1-97dfa5d8da74 (folders /shared /api /www /admin — see Migration for the structure). 88 keys, same key set across dev / staging / prod (values differ). The matrix below is the usage analysis that drove the folder allocation ( = the key is referenced in that app’s source — API 64, WWW 18, Admin 5 of 88).

Legacy: the old flat project c02f5163-16d8-4c5b-a1b2-879e12013cad (no folders, all 88 at root) is superseded and pending decommission — nothing deployed reads from it anymore. Keep it until the cutover’s bedded in, then delete.

= a literal process.env.<KEY> reference in app source. A blank is not proof a key is unused — it may be consumed by the Docker build, CI workflows, scripts, or accessed via a dynamically-built name. It’s also why /shared = keys used by ≥2 apps, /api = api-only, and so on. Signal, not verdict.

Database & cache

KeyAPIWWWAdmin
CACHE_ENABLED
DB_HOST
DB_MIGRATE_PASSWORD
DB_MIGRATE_USER
DB_NAME
DB_PASSWORD
DB_PORT
DB_USER
REDIS_HOST
REDIS_PASSWORD
REDIS_PORT
REDIS_USERNAME

App config & URLs

KeyAPIWWWAdmin
ADMIN_URL
API_PORT
API_URL
APP_PORT
APP_URL
COOKIE_DOMAIN_API
COOKIE_DOMAIN_WWW
DOCKER_API_URL
DOCS_FULL_URL
DOCS_FUSEMETRIX_URL
DOMAIN
IS_PRODUCTION
NODE_ENV
NODE_MODULES_CACHE
RATE_LIMIT_MAX_REQUESTS
RATE_LIMIT_WINDOW_MINUTES
SITE_DOWN
USE_NPM_INSTALL
USE_SPLIT_MIDDLEWARE

Auth & crypto

KeyAPIWWWAdmin
CRONS_API_KEY
CRYPTO_KEY
JWT_ALGO
JWT_SECRET
NEXT_JWT_SECRET
NEXT_PUBLIC_ENCRYPT_SECRET
RECAPTCHA_SECRETE_KEY
RECAPTCHA_SITE_KEY
TOTP_ENCRYPTION_KEY

OAuth / social

KeyAPIWWWAdmin
FACEBOOK_CLIENT_ID
FACEBOOK_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET

Firebase (push notifications — API only)

KeyAPIWWWAdmin
FIREBASE_AUTH_PROVIDER_X509_CERT_URL
FIREBASE_AUTH_URI
FIREBASE_CLIENT_EMAIL
FIREBASE_CLIENT_ID
FIREBASE_CLIENT_X509_CERT_URL
FIREBASE_PRIVATE_KEY
FIREBASE_PRIVATE_KEY_ID
FIREBASE_PROJECT_ID
FIREBASE_TOKEN_URI
FIREBASE_TYPE

Google services

KeyAPIWWWAdmin
GA_PROPERTY_ID
GOOGLE_APPLICATION_CLIENT_EMAIL
GOOGLE_APPLICATION_PRIVATEKEY
GOOGLE_APPLICATION_WALLET_CLIENT_EMAIL
GOOGLE_APPLICATION_WALLET_PRIVATEKEY
GOOGLE_TRASLATE_CLIENT_EMAIL
GOOGLE_TRASLATE_PRIVATEKEY
GOOGLE_TRASLATE_TEXT_KEY

Payments

KeyAPIWWWAdmin
STRIPE_PK
STRIPE_SK

Email & SMS

KeyAPIWWWAdmin
EMAIL_ALERT
FROM_EMAIL
MANDRILL_API_KEY
TWILIO_ACCOUNDSID
TWILIO_AUTHTOKEN
TWILIO_PHONE_UK
TWILIO_PHONE_US

Storage (S3)

KeyAPIWWWAdmin
S3_BASE_URL
S3_BUCKET_ACCESS_KEY_ID
S3_BUCKET_NAME
S3_BUCKET_SECRET_ACCESS_KEY
S3_REGION

AI

KeyAPIWWWAdmin
OPENAI_KEY

Admin app

KeyAPIWWWAdmin
NEXT_PUBLIC_API_URL
NEXT_PUBLIC_APP_URL

Jira automation

KeyAPIWWWAdmin
JIRA_ACCOUNT_EMAIL
JIRA_ACCOUNT_TOKEN
JIRA_ISSUE_TYPE_ID
JIRA_MEMBER_ALEX
JIRA_MEMBER_VIVEK
JIRA_PROJECT_ID

Postman docs

KeyAPIWWWAdmin
POSTMAN_FUSE_MATRIX_DOC
POSTMAN_MOBILE_DOC
POSTMAN_TOKEN

Referenced by none of the three apps (14): REDIS_USERNAME, COOKIE_DOMAIN_API, COOKIE_DOMAIN_WWW, USE_SPLIT_MIDDLEWARE, JWT_ALGO, NEXT_JWT_SECRET, GOOGLE_TRASLATE_TEXT_KEY, JIRA_ISSUE_TYPE_ID, JIRA_PROJECT_ID, POSTMAN_TOKEN, POSTMAN_MOBILE_DOC, POSTMAN_FUSE_MATRIX_DOC, plus the DO buildpack flags NODE_MODULES_CACHE / USE_NPM_INSTALL. The buildpack flags are expected (consumed by DO, not app code); the rest are drift candidates — likely used in scripts/CI, accessed dynamically, or stale. Verify before removing. Notably NEXT_JWT_SECRET is set on the iba-admin DO app but unreferenced in admin/ source.

Some keys carry historical typos baked into the code (RECAPTCHA_SECRETE_KEY, TWILIO_ACCOUNDSID, GOOGLE_TRASLATE_*). They’re listed as-is — renaming them is a coordinated code+Infisical change, not a doc fix.

Infisical: assistant project

Separate Infisical project, Model B — synced 1:1 into tunnelflight-assistant’s DO env, so the DO app’s keys are the project’s secret set.

GroupKeys
Anthropic / modelANTHROPIC_API_KEY, ASSISTANT_MODEL
Assistant configASSISTANT_USERS, ASSISTANT_STAFF_ROLE_IDS, ASSISTANT_ARTIFACTS_BUCKET, ASSISTANT_CRONS_API_KEY, ASSISTANT_JWT_SECRET
DatabaseASSISTANT_DB_NAME, ASSISTANT_DB_USER, ASSISTANT_DB_PASSWORD, DB_HOST, DB_NAME, DB_PORT, DB_MIGRATE_USER, DB_MIGRATE_PASSWORD
AWS / storageAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
Ops / observabilityAPI_BASE_URL, SENTRY_DSN, OPS_ALERT_EMAILS, LOG_LEVEL, RELEASE_VERSION
Runtime flagsNODE_ENV, IS_PRODUCTION, SKIP_INFISICAL

⚠️ Component-level drift (found 2026-08-03). The live app has 24 of these keys set a second time at component level with type: GENERAL — i.e. plaintext, and returned in full by doctl apps spec get to any token with app read access. Component-level wins over app-level in DO, so the plaintext copies are the ones actually in effect and the encrypted app-level values are inert.

They can’t just be deleted: 20 of 24 match Infisical, but 4 divergeANTHROPIC_API_KEY, ASSISTANT_JWT_SECRET, ASSISTANT_USERS (live is narrower than Infisical’s), and IS_PRODUCTION (live false vs Infisical true). Dropping the block would swap the Anthropic key, invalidate every session via a new JWT secret, widen the user list, and flip the prod flag.

The safe remediation is to convert them in place to type: SECRET — same values, encrypted — then reconcile the 4 divergences deliberately. The IS_PRODUCTION=false is inert today only because assistant/src/lib/infisical.ts checks SKIP_INFISICAL === "1" first.

Infisical: mobile signing & store credentials

/mobile holds the 15 credentials used to sign and publish the React Native apps. It exists in prod only — there is no staging/dev equivalent, because signing identities aren’t environment-scoped.

These are the highest-blast-radius secrets in the project: they can publish builds to the App Store and Play Store under IBA’s identity. Nothing deployed reads them; they’re consumed by local Fastlane runs and mobile CI.

GroupKeys
Android signingANDROID_APP_ALIAS, ANDROID_APP_PRIVATE_KEY, ANDROID_ALIAS_STORE_PW, ANDROID_KEY_STORE_PW, ANDROID_KEYSTORE_JKS_B64
Google PlayGOOGLE_PLAY_JSON_KEY, PLAY_SERVICE_ACCOUNT_JSON_B64
iOS signingIOS_DIST_CERT_P12_B64, IOS_DIST_CERT_P12_PW, IOS_ASC_KEY_P8_B64, TEAM_ID
App Store ConnectAPP_STORE_CONNECT_API_KEY_KEY_ID, APP_STORE_CONNECT_API_KEY_ISSUER_ID, APP_STORE_CONNECT_API_KEY_KEY_FILEPATH
ObservabilitySENTRY_AUTH_TOKEN (source-map upload)

The _B64 suffixes are base64-wrapped binaries (keystore, P12, P8) — Infisical stores them as single-line strings. Decode to a temp file at use time; don’t commit the decoded artefact.

Infisical→DO sync

The connector lets Infisical be the single source of truth and push secrets into DigitalOcean — so secrets aren’t re-entered in DO by hand and the app doesn’t depend on a runtime Infisical login. All deployed apps now use this (api, www, admin, the dev playground, and the assistant).

StepActionWhere
1Create/reuse a DO API token — scopes: App read + update, plus actions / regions / sizes read. DO tokens are account-wide and shown once.DO console → API → Tokens
2Add an org-level DigitalOcean App Connection (method: API Token) so every project’s syncs share one credential (one rotation point).Infisical → Organization Settings → App Connections
3Create a Secret Sync: source environment + path → destination DO app.Infisical → project → Secret Syncs

⚠️ DigitalOcean sync is overwrite-only — no import/merge mode. The first sync makes the DO app’s env a mirror of the Infisical source, deleting any DO-only var not in Infisical. For an app still on Model A (runtime pull, e.g. the preview apps), that prunes the INFISICAL_* bootstrap vars it needs → boot crash. Mitigate: enable “Disable Secret Deletion” so the sync upserts without pruning. This is what protected the bootstrap during each cutover (validated on iba-dev-api first).

Migration: flat project → foldered project (complete)

We moved from the single flat project (c02f5163…) to a foldered project (b0466e3e-98a7-4cb8-9db1-97dfa5d8da74) where each app has its own folder, shared values are single-sourced, and every deployed app runs on Model B (sync). Completed June 2026. The gotchas and runbook below are kept as the record of how.

Target structure (b0466e3e…, per env)

FolderContentsShared values via
/shared12 keys used by ≥2 apps (canonical)
/api57 API-only keys (canonical DB_HOST/DB_PORT/DB_NAME)imports /shared
/www6 WWW-only keysimports /shared
/admin3 NEXT_PUBLIC_*— (admin doesn’t use /shared)
/assistant26 keys — assistant’s own setreferences /api for DB_HOST/DB_PORT/DB_NAME (${env.api.KEY})
/github-ci13 CI credentials, fetched over OIDC
/mobile15 app-signing / store-publishing credentials — prod only

Use “Add Secret Import” (root-level link) for /api//www/sharednot “Replicate”, which nests a /api/shared subfolder a sync won’t reach.

Hard-won gotchas (read before touching this)

  • Deployed apps never sync from dev. The dev environment holds local-docker values (DB_HOST=mysql, REDIS_HOST=redis) for developers’ machines — unreachable from a deployed app. Deployed apps sync from prod (the real apps) or staging (the dev/preview playground — staging has the real prod-cluster hosts but a separate DB_NAME, so it reaches real infra without touching prod data).
  • Create the Secret Sync inside the new project — syncs are project-scoped; one created under the old flat project can’t find /api.
  • Imports flow through DO syncs (verified on dev): syncing /api pushes its own keys + the 12 imported from /shared (57 + 12 = 69 as of 2026-08-03; it was 53 + 12 = 65 at cutover). No references-fallback needed for api/www .
  • DO sync is overwrite-only → set Disable Secret Deletion = ON, or it prunes the component-level bootstrap vars and the app crash-loops.
  • App-level vs component-level: the sync writes app-level env; the bootstrap INFISICAL_* and DO build flags (USE_NPM_INSTALL, NODE_MODULES_CACHE) live component-level. DO merges them (component wins on conflict) — keep them disjoint.
  • SKIP_INFISICAL=1 (component-level, not synced) turns off the runtime pull so the app runs purely on synced env — the Model-A→B switch. Don’t reuse IS_PRODUCTION for this (it also gates prod cron side-effects).
  • Multi-line values (PEM keys): the CLI’s bulk secrets set --file rejects them — set each via KEY=@file. (The DO sync handles multi-line fine; this is only a CLI-import concern.)

Per-app cutover runbook (dev-first, one app at a time)

  1. Create a Secret Sync in project b0466e3e…: source env (prod for real apps; staging for the playground) + path (/api /www /admin) → destination DO app, connection digitalocean-iba, Overwrite + Disable Secret Deletion ON. (/admin’s NEXT_PUBLIC_* need BUILD_TIME scope.)
  2. Trigger it — editing source config doesn’t auto-push; run the sync.
  3. doctl-verify the app’s env: expected count landed (api 69, www 18, admin 3+), bootstrap INFISICAL_* survived, hosts reachable.
  4. Set SKIP_INFISICAL=1 on the app (component-level) → redeploy.
  5. Verify boot via doctl apps logs --type run: no Infisical pull, server starts, DB/Redis connect.
  6. Clean up: remove the 5 dead INFISICAL_* bootstrap vars (keep SKIP_INFISICAL + the build flags).

Status (updated 2026-08-03)

  • ✅ New project populated + verified; /shared imports + /assistant references wired.
  • SKIP_INFISICAL shipped in api/www (PR #672).
  • iba-dev-api (playground) — synced from staging, SKIP_INFISICAL=1, bootstrap removed.
  • iba-www — synced from prod (NPM_CONFIG_PRODUCTION=false to keep sass at build), SKIP_INFISICAL=1, bootstrap removed.
  • iba-admin — synced 3 NEXT_PUBLIC_* from prod, component shadows removed (NEXT_JWT_SECRET confirmed unused — login verified).
  • iba-api — synced from prod, SKIP_INFISICAL=1, bootstrap removed from both the web service and the db-migrate job (Pre-Deploy migrations now run on app-level synced DB_MIGRATE_*).
  • 🔸 tunnelflight-assistant — already Model B on its own project; the new /assistant folder is prepared (DB coords reference /api), but cutting the assistant DO app to the new project is optional and not done.
  • GitHub Actions → Infisical (pull + OIDC)shipped. Five workflows authenticate via OIDC against /github-ci; GITHUB_TOKEN is the only secret left in GitHub. See GitHub Actions secrets. The path landed as /github-ci, not the originally-planned /ci.
  • Rotate INFISICAL_CLIENT_SECRET — still outstanding, and the oldest open item here. See SECURITY-credential-rotation-pending.md in the repo root for the full runbook. The blast radius has shrunk since that runbook was written: it lists the DO app env var and the GitHub Actions secret as consumers, and both are now gone. What remains is the Infisical dashboard itself, the copy in /github-ci, and developers’ local infisical.env files — no production redeploy required.
  • Decommission the old flat project c02f5163… once the cutover’s bedded in.
  • 🔸 tunnelflight-assistant component-level env drift — 24 keys are set a second time at component level as GENERAL (plaintext, readable by any app-read token), shadowing correct SECRET app-level entries. 20 of the 24 match the Infisical values; 4 diverge (ANTHROPIC_API_KEY, ASSISTANT_JWT_SECRET, ASSISTANT_USERS, IS_PRODUCTION), so they cannot simply be deleted without changing running behaviour. Found 2026-08-03.

Rotation & expiry

ItemRule / triggerAction
DO API tokenExpires 2027-05-28; every sync using it then fails silently.Rotate before expiry; update the org App Connection.
Infisical client secret (INFISICAL_CLIENT_SECRET)Shown once; the in-use value can’t be revealed. Exposed in git history (9 commits, via the formerly-tracked infisical.env) and still live. Consumers are now: Infisical itself, the copy in /github-ci, preview apps (injected at creation), and local infisical.env files.Add a new client secret to the identity (don’t revoke the in-use one), update /github-ci + local files, smoke-test one preview app, then revoke the old. Revoking neutralises the git-history exposure; the git filter-repo scrub is hygiene after that.
Any valueNot readable from any store.Treat “I can’t find the value” as “create a new one,” not “look harder.”

See also

Last updated on