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/eas.json, 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, dev, staging, prod (production is normalized to prod). |
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, require in prod). |
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. Generate with openssl rand -base64 32. |
ENCRYPTION_KEY |
yes | — | Symmetric key for encrypting provider config (OAuth tokens, etc.) at rest. 32 chars recommended. |
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 | — | OAuth client ID for Google Sign-In. |
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).
| 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.