Environment Variables¶
Every env var read by the backend or frontend. Sources: backend/env.example.local, scripts/pull-secrets.sh (authoritative list of prod secrets pulled from GCP Secret Manager), backend/config/config.go, frontend/.env + the Cloud Build trigger substitutions in the devops repo's infrastructure/gcp/cloudbuild.tf (web EXPO_PUBLIC_* build-args), and direct grep of frontend/utils/.
Backend¶
The backend uses a two-layer config: config.{env}.yaml for non-sensitive defaults, .env.{env} for secrets and overrides. Env vars listed here override the matching YAML value. See Configuration for layer precedence.
| Variable | Required? | Default | Purpose |
|---|---|---|---|
ENV |
no | local |
Selects which config.{env}.yaml and .env.{env} to load. Values: local, test, dev, prod (development normalizes to dev, production to prod). An unrecognized value fails startup. |
CORS_ALLOWED_ORIGINS |
no | from YAML | Comma-separated browser origins allowed to call the API. Overrides server.allowed_origins. * is rejected outside local and test. |
TRUSTED_PROXY_COUNT |
no | from YAML | Reverse-proxy hops in front of the server, for X-Forwarded-For parsing. |
TRUSTED_PROXIES |
no | from YAML | Comma-separated CIDR blocks whose X-Forwarded-For header is believed. |
DB_HOST |
yes | from YAML | Postgres host. |
DB_PORT |
yes | from YAML | Postgres port. |
DB_USER |
yes | from YAML | Postgres user. |
DB_PASSWORD |
yes | — | Postgres password. |
DB_NAME |
yes | from YAML | Postgres database name. |
DB_SSLMODE |
yes | from YAML | Postgres SSL mode (disable locally, verify-full in the cluster). |
DB_SSLROOTCERT |
no | — | Path to the CA cert that signs the server certificate. Required for sslmode=verify-ca/verify-full; omitted from the DSN when empty. |
REDIS_HOST |
yes | from YAML | Redis host. |
REDIS_PORT |
yes | from YAML | Redis port. |
REDIS_PASSWORD |
no (local) / yes (prod) | — | Redis password. Blank for local docker-compose. |
JWT_SECRET |
yes | — | HMAC secret for signing JWTs. At least 32 bytes, enforced at startup. Generate with openssl rand -base64 32. |
ENCRYPTION_KEY |
yes | — | Symmetric key for encrypting provider config (OAuth tokens, etc.) at rest. Exactly 32 bytes for AES-256, enforced at startup. |
AWS_ACCESS_KEY_ID |
yes | — | S3/MinIO access key for object storage. |
AWS_SECRET_ACCESS_KEY |
yes | — | S3/MinIO secret key. |
S3_ENDPOINT |
no | — | Override endpoint. Set to http://localhost:9000 for local MinIO; leave unset for AWS S3. |
STRIPE_SECRET_KEY |
for payments | — | Stripe API key. Use sk_test_… locally. |
STRIPE_WEBHOOK_SECRET |
for payments | — | Verifies the signature on Stripe webhook callbacks (whsec_…). |
GOOGLE_CLIENT_ID |
for Google OAuth | — | Web OAuth client ID for Google Sign-In (accepted aud). May be comma-separated. |
GOOGLE_IOS_CLIENT_ID |
for Google OAuth (iOS) | — | Native iOS client ID; accepted as a valid Google token aud. |
GOOGLE_ANDROID_CLIENT_ID |
for Google OAuth (Android) | — | Native Android client ID; accepted as a valid Google token aud. |
GOOGLE_CLIENT_SECRET |
for Google OAuth | — | OAuth client secret. |
GOOGLE_PLACES_API_KEY |
for Places | — | Google Places API key. Used only for resolve escalations (caller-supplied place id, rich-detail enrichment); search and autocomplete are Photon-only. |
LINE_CLIENT_ID |
for LINE login | — | LINE Login channel ID. |
LINE_CLIENT_SECRET |
for LINE login | — | LINE Login channel secret. |
APPLE_CLIENT_ID |
for Apple SIWA (iOS) | — | iOS bundle ID, e.g. com.tomoda.app. |
APPLE_SERVICE_ID |
for Apple SIWA (web/Android) | — | Apple Services ID, e.g. com.tomoda.app.service. |
APPLE_TEAM_ID |
for Apple SIWA | — | Apple Developer team ID. |
APPLE_KEY_ID |
for Apple SIWA | — | Apple Sign-in key ID. |
EMAIL_APIKEY |
yes | — | API key for the transactional email provider (Resend). |
SMTP_HOST |
optional | — | SMTP fallback host (e.g. Mailtrap for local testing). |
SMTP_USERNAME |
optional | — | SMTP username. |
SMTP_PASSWORD |
optional | — | SMTP password. |
WEBAUTHN_RP_ID |
yes (passkeys) | from YAML | WebAuthn Relying Party ID — domain only, e.g. localhost or tomoda.life. |
WEBAUTHN_RP_ORIGIN |
yes (passkeys) | from YAML | Full origin including scheme, e.g. http://localhost:3000. |
KLIPY_API_KEY |
for GIF picker | — | Klipy GIF search API key. |
SYNTHETIC_PROBE_EMAIL |
no | synthetic+probe@tomoda.life |
Email of the synthetic-monitoring test user seeded at boot by migrate.SeedSyntheticUser (backend/internal/migrate/synthetic_user.go). The Cloudflare synthetic Worker logs in as this user to probe POST /api/v1/auth/login. |
SYNTHETIC_PROBE_PASSWORD |
for login probe | — | Password for the synthetic test user. Empty = seed is skipped (suitable for local dev). In prod, sourced from GCP SM tomoda-synthetic-probe-password via ESO. |
SYNTHETIC_PROBE_NAME |
no | Synthetic Probe |
Display name for the synthetic user. |
PHOTON_URL |
for self-host geocoder | from YAML | Base URL of the self-hosted Photon (OSM) geocoder. Falls back to disabled if blank. |
PHOTON_ALLOW_PRIVATE |
for dev docker-network | false |
When true, the outbound httpx.SafeClient may reach docker-network hosts (e.g. http://photon:2322). Off in prod (public Photon endpoint). |
PHOTON_BIAS_SCALE |
no | 0.2 |
Photon location_bias_scale for biased forward search: a light nudge toward the viewer, not a radius filter. Matches Photon's public default. A larger value buries distant/prominent results, so autocomplete feels nearby-only. |
PHOTON_ZOOM |
no | 14 |
Photon zoom hint for biased forward search, tuning the scale at which proximity matters. |
SERPER_API_KEY |
for link enrichment | — | Serper.dev key for the link-capture /search + /maps harvest. Empty disables enrichment (capture degrades to the deterministic parse). |
ENABLE_SEARCH_ENRICHMENT |
for link enrichment | false |
Kill-switch for Serper enrichment. Must be exactly true to enable; anything else disables. |
CAPTURE_DAILY_PARSE_LIMIT |
no | 50 |
Per-user/day budget for billable enrichment passes (Redis rate limit). Dev/admin accounts bypass. |
ENRICH_DEFAULT_CITY |
no | Ottawa |
Locale fallback for enrichment when a capture has no resolved place. |
ENRICH_DEFAULT_GL |
no | ca |
Serper country bias (gl) fallback, ISO 3166-1 alpha-2 lowercase. |
SEMANTIC_ENABLED |
no | from YAML | Master switch for the semantic resolver. Off → enrichment uses the deterministic Serper heuristic only. |
SEMANTIC_PROVIDER |
no | openai_compat |
LLM transport; openai_compat (Ollama / llama.cpp / vLLM / OpenAI-style hosted) is the only value today. |
SEMANTIC_BASE_URL |
for semantic layer | from YAML | Model server root (OpenAI-compatible chat API). Prod: https://api.deepseek.com. Local Ollama: http://semantic:11434. |
SEMANTIC_MODEL |
for semantic layer | from YAML | Model id. Prod: deepseek-v4-flash. Local: qwen2.5:3b-instruct. |
SEMANTIC_API_KEY |
for hosted provider | — | Bearer key. In prod, from GCP SM tomoda-deepseek-api-key via ESO. Empty for keyless self-host (Ollama). |
SEMANTIC_TIMEOUT |
no | 8s |
Per-call timeout (runs in the async enrich worker). |
SEMANTIC_JSON_MODE |
no | from YAML | Request response_format: json_object from the server. |
SEMANTIC_ALLOW_PRIVATE |
for dev docker-network | false |
When true, the outbound httpx.SafeClient may reach docker-network hosts (e.g. http://semantic:11434). Off in prod. |
EMBEDDINGS_ENABLED |
no | false |
Master switch for semantic location search (bge-m3 vectors). Off → search stays lexical-only. |
EMBEDDINGS_BASE_URL |
for semantic search | from YAML | Text-embeddings (TEI) server root. Prod: the in-cluster TEI service; local: http://localhost:8085. |
EMBEDDINGS_MODEL |
no | from YAML | Informational model id (bge-m3); the TEI server hosts a single model. |
EMBEDDINGS_DIMENSIONS |
no | 1024 |
Vector length; must match the locations.embedding halfvec column. |
EMBEDDINGS_API_KEY |
for hosted provider | — | Bearer key; empty for keyless self-host (TEI). |
EMBEDDINGS_TIMEOUT |
no | 10s |
Per-call timeout. |
EMBEDDINGS_ALLOW_PRIVATE |
for dev/in-cluster | false |
When true, the outbound httpx.SafeClient may reach private hosts (the TEI service). |
ENRICH_BACKGROUND_PAID |
no | true |
Gates the paid Serper/Places step in the background enrichment worker. task dev sets it false so seeding the catalog locally stays Serper-free (Photon gap-fill + embeddings still run). Interactive resolve/capture paths ignore it. Prod leaves it unset (true). |
RANK_WEIGHT_NAME |
no | 0.55 |
Name-match share of the composite search-ranking score. Unset keeps the shipped value; set to roll a retuned weight without a recompile. |
RANK_WEIGHT_PROXIMITY |
no | 0.12 |
Proximity (distance-decay) share of the search-ranking score. |
RANK_WEIGHT_POPULARITY |
no | 0.04 |
Popularity (saves + external rating) share of the search-ranking score. |
RANK_WEIGHT_PROMINENCE |
no | 0.08 |
Gazetteer-prominence share of the search-ranking score. |
RANK_WEIGHT_SOURCE |
no | 0.10 |
Source-trust (own catalog vs Photon) share of the search-ranking score. |
RANK_WEIGHT_SEMANTIC |
no | 0.11 |
Vector-similarity share of the search-ranking score. |
RANK_WEIGHTS_REFRESH_INTERVAL |
no | 2m |
How often the API ranker re-reads the active ranking_weights row and hot-swaps it in, so an approved retune applies without a redeploy. A Go duration (e.g. 90s, 5m). 0 disables the periodic refresh (boot-resolved weights stay). |
FRONTEND_URL |
yes | — | Used to build email verification + password-reset links. |
TWILIO_ACCOUNT_SID |
for SMS | — | Twilio account SID. |
TWILIO_AUTH_TOKEN |
for SMS | — | Twilio auth token. |
TWILIO_PHONE_NUMBER |
for SMS | — | Twilio sender phone number. |
GIN_MODE |
no | release |
Set to debug for verbose Gin logging. |
SERVER_MODE |
no | full |
Selects what the single backend binary starts. Values: full (everything, default for local), multi-hub (API + WS Hub, prod api deployment), async (Asynq worker + scheduler, prod async deployment), api-hub / ws-hub (reserved for the future api/ws split). Overridable per-pod via --mode flag. See Architecture → Decisions. |
Pulling secrets locally
For day-to-day local dev you don't need to copy values into .env.local by hand. Run eval $(./scripts/pull-secrets.sh) to pull from GCP Secret Manager into your shell, then task dev. See scripts.md for the full flow.
Frontend¶
Expo reads EXPO_PUBLIC_* variables at build time and bakes them into the JS bundle. In development they come from frontend/.env. For release builds, the wrapper script at frontend/scripts/build-with-secrets.sh pulls Sentry values from GCP Secret Manager and execs the build command (see scripts.md). The signing + upload secrets for a store build (match passphrase, App Store Connect API key, Android upload keystore, Play service account) are also pulled from GCP Secret Manager by the same script and exported only for the build process; see Signing for the inventory.
| Variable | Required? | Default | Purpose |
|---|---|---|---|
EXPO_PUBLIC_API_URL |
yes | — | Base URL for the REST API, e.g. http://localhost:8080/api/v1 locally, https://api.tomoda.life/api/v1 in production. |
EXPO_PUBLIC_WS_URL |
yes | — | Base URL for the WebSocket endpoint, e.g. wss://api.tomoda.life/ws. |
EXPO_PUBLIC_WEB_URL |
no | https://tomoda.life |
Marketing/web URL used for share links. |
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID |
yes (Google login) | hardcoded fallback | Google OAuth web client ID. |
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID |
yes (iOS) | hardcoded fallback | Google OAuth iOS client ID. |
EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID |
yes (Android) | hardcoded fallback | Google OAuth Android client ID. |
EXPO_PUBLIC_APP_VERSION |
no | dev |
App version displayed in the admin dashboard footer. Also sent to Sentry as the release tag. |
EXPO_PUBLIC_COMMIT_SHA |
no | local |
Build commit SHA displayed in the admin dashboard footer. |
EXPO_PUBLIC_BUILD_NUMBER |
no | — | Release build number. Sent to Sentry as the dist tag to disambiguate releases with the same version. |
EXPO_PUBLIC_ENV |
no | dev |
Environment label sent to Sentry (dev / prod). build-with-secrets.sh sets this to prod. |
EXPO_PUBLIC_SENTRY_DSN |
no | — | Sentry project DSN. When empty, Sentry is fully disabled (no init, no captures). Pulled from GCP Secret Manager (tomoda-sentry-dsn) by frontend/scripts/build-with-secrets.sh at release-build time. Required for crash reporting in staging/prod builds — see Frontend → Observability. |
EXPO_PUBLIC_SENTRY_ENV |
no | falls back to EXPO_PUBLIC_ENV |
Optional override if you want a different Sentry environment label from the broader EXPO_PUBLIC_ENV. |
SENTRY_AUTH_TOKEN |
for source-map upload | — | Build-time only, NEVER a public bundle. Auth token used by the Sentry CLI to upload source maps. Pulled from GCP Secret Manager (tomoda-sentry-auth-token) by build-with-secrets.sh. Not prefixed with EXPO_PUBLIC_ precisely because it must not ship to clients. |
SENTRY_ORG |
for source-map upload | tomoda |
Sentry organization slug used by the upload step. Defaults baked into app.config.js — override via env var if your org slug differs. |
SENTRY_PROJECT |
for source-map upload | tomoda-frontend |
Sentry project slug used by the upload step. Defaults baked into app.config.js — override via env var if your project slug differs. |
EXPO_PUBLIC_* is public
Anything prefixed with EXPO_PUBLIC_ is baked into the client bundle and shipped to every user. Treat them as public. Never put secrets, server-only API keys, signing keys, database credentials, or anything you wouldn't paste into a tweet behind an EXPO_PUBLIC_ prefix. If you need a value at runtime that must stay secret, fetch it from the backend over an authenticated channel.
Where values come from in prod¶
Local: .env.local, defaults from pull-secrets.sh.
CI / deploy: Cloud Build pulls from GCP Secret Manager via the tomoda-* secret names — see scripts/pull-secrets.sh for the canonical mapping.
Native release builds: frontend/scripts/build-with-secrets.sh pulls Sentry values from GCP Secret Manager and execs expo run:*. EXPO_PUBLIC_API_URL / EXPO_PUBLIC_WS_URL are hardcoded in frontend/app.config.js's production block.
If you add a new env var: register it in backend/config/config.go::Load, add it to env.example.local, document it here, and (if it's a secret used in dev/prod) add the GCP Secret Manager mapping to scripts/pull-secrets.sh plus the K8s backend-secrets Secret in the devops/ repo.