Skip to content

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.