Configuration¶
The backend uses a two-layer configuration system:
- YAML files hold non-sensitive defaults (ports, hosts, timeouts, feature flags).
- Environment variables override secrets and infrastructure-injected values (DB password, JWT secret, OAuth client secrets, API keys).
The loader lives in backend/config/config.go. Everything goes through config.Load() which is invoked once at startup from cmd/server/main.go.
YAML layering¶
config.Load() reads ENV from the environment (defaults to local) and loads:
config.{ENV}.yaml (preferred)
config.yaml (fallback if env-specific file is missing)
The string production is normalised to prod so both work as ENV values.
Files in this repo:
| File | Purpose |
|---|---|
backend/config.yaml |
Base / shared defaults |
backend/config.local.yaml |
Local Docker stack (localhost, MinIO endpoint) |
backend/config.dev.yaml |
Dev cloud environment |
backend/config.prod.yaml |
Production |
What goes in YAML vs env
YAML: ports, hostnames, JWT expiry, worker concurrency, S3 bucket/region, WebAuthn RP config, email from address, Photon URL. Env vars: all secrets — DB password, JWT secret, OAuth client secrets, Stripe keys, AWS keys, encryption key.
Env file precedence¶
After parsing YAML, config.Load() loads .env.{ENV} via joho/godotenv. If that file is missing, it falls back to a bare .env. Variables already present in the OS environment are not overridden.
Then every secret-bearing field is read from os.Getenv(...) and overwrites the YAML value when set. This means env vars always win over YAML.
# Override the DB host from config.local.yaml without editing the file
DB_HOST=postgres.internal go run ./cmd/server
The Config struct¶
The full top-level shape (backend/config/config.go):
type Config struct {
Server ServerConfig // port, env (typed), allowed_origins, trusted proxies
Database DatabaseConfig // host, port, user, password, dbname, sslmode, log_level
Redis RedisConfig // host, port, password
JWT JWTConfig // secret, expiry (time.Duration)
Email EmailConfig // api_key, base_url, from, logo_base_url
Worker WorkerConfig // concurrency
Stripe StripeConfig // secret_key, webhook_secret, price_ids, urls
Security SecurityConfig // encryption_key (for API keys at rest)
Google GoogleConfig // client_id, client_secret, places_api_key
GooglePlaces GooglePlacesConfig // api_key (alias)
Line LineConfig // client_id, client_secret
Apple AppleConfig // client_id, service_id, team_id, key_id
WebAuthn WebAuthnConfig // rp_id, rp_display_name, rp_origin
S3 S3Config // bucket, region, access_key_id, secret_access_key,
// base_url (CDN), endpoint (MinIO), use_path_style
Logger LoggerConfig // level, format
Klipy KlipyConfig // api_key
Photon PhotonConfig // url, timeout, enabled
Semantic SemanticConfig // enabled, base_url, model, api_key, timeout, json_mode
Location LocationConfig // background_paid_enrichment (off locally to keep seeding Serper-free)
RankWeights RankWeightsConfig // search-ranking weight vector; env-swappable, defaults to the shipped values
}
Database.DSN() / Redis.Address() are helpers that downstream code uses to assemble connection strings.
The environment is a typed value¶
Server.Env is a config.Environment, not a string. config.ParseEnvironment resolves it once at load time from the config file's server.env (falling back to the ENV var) and rejects any value it does not know, so a typo fails the boot instead of silently landing in a non-production branch.
| Constant | Value | Notes |
|---|---|---|
config.EnvLocal |
local |
Developer machine. An empty value parses to this. |
config.EnvTest |
test |
Test harnesses. |
config.EnvDev |
dev |
Dev cluster. development parses to this. |
config.EnvProd |
prod |
Production. production parses to this. |
Compare with the helpers, never a string literal: env.IsProduction() gates HSTS (internal/middleware/security.go), the Swagger blackout (internal/wiring/router.go), and login throttling (internal/middleware/rate_limiter.go). env.IsLocal() covers local plus test, and is what relaxes the credential checks below.
Required vs optional¶
Config.Validate() runs at the end of Load() and hard-fails the boot on settings that would silently degrade security:
| Check | Rule |
|---|---|
JWT_SECRET |
At least 32 bytes. golang-jwt happily signs with a zero-length HMAC key, so nothing downstream catches an empty one. |
ENCRYPTION_KEY |
Exactly 32 bytes (AES-256). |
| Placeholder credentials | The local development placeholders are rejected outside local and test. |
server.allowed_origins |
Non-empty, and no * outside local and test. |
In local and test only, applyLocalDevDefaults substitutes obviously-insecure placeholder credentials and a wildcard CORS origin, so a fresh checkout boots with no secrets file. It logs a warning each time, and Validate rejects those same placeholders in dev and prod.
Everything else is still validated at the point of use (e.g. EmailService.SendEmail errors when APIKey or BaseURL is missing), so boot succeeds without them and the affected feature fails at runtime.
CORS and proxy topology¶
Server.AllowedOrigins feeds the CORS middleware in internal/wiring/router.go. Native clients send no Origin header and are unaffected; this list only constrains browsers. config.dev.yaml and config.prod.yaml name their real origins, and CORS_ALLOWED_ORIGINS (comma-separated) overrides the file so a cluster can add one without a rebuild.
Server.TrustedProxyCount and Server.TrustedProxies describe how many reverse-proxy hops sit in front of the pod and which CIDRs may set X-Forwarded-For. Both default to the ingress topology in the per-environment YAML and override via TRUSTED_PROXY_COUNT / TRUSTED_PROXIES.
Secrets and GCP Secret Manager¶
Production runs on GKE and pulls secrets from GCP Secret Manager via the External Secrets Operator. No .env files exist in production containers.
For local development, the script scripts/pull-secrets.sh fetches the same secrets out of GCP and exports them into your shell:
# Pull and export into the current shell
eval $(./scripts/pull-secrets.sh --export)
# Or pull and exec the backend in one step
./scripts/pull-secrets.sh --run
task dev wraps this: if gcloud auth print-access-token succeeds, it runs pull-secrets.sh --export automatically. Otherwise it falls back to a hard-coded set of local-only defaults (DB_USER=tomoda, DB_PASSWORD=tomoda123, MinIO creds, empty Redis password).
To enable secret pulling locally:
gcloud auth login
gcloud config set project development-485000
Never commit .env.* files
.env.local, .env.dev, .env.prod are all gitignored. Treat them as ephemeral — regenerate from GCP whenever you need them.
Common env vars¶
| Category | Variables |
|---|---|
| Database | DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE |
| Auth | JWT_SECRET, ENCRYPTION_KEY |
| OAuth | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_PLACES_API_KEY, LINE_CLIENT_ID, LINE_CLIENT_SECRET, APPLE_CLIENT_ID, APPLE_SERVICE_ID, APPLE_TEAM_ID, APPLE_KEY_ID |
| Payments | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
| Email & APIs | EMAIL_APIKEY, KLIPY_API_KEY, PHOTON_URL |
| Storage | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_ENDPOINT |
| Redis | REDIS_HOST, REDIS_PORT, REDIS_PASSWORD |
| App | ENV, FRONTEND_URL, GIN_MODE, SERVER_MODE, CORS_ALLOWED_ORIGINS, TRUSTED_PROXY_COUNT, TRUSTED_PROXIES |
| Ranking | RANK_WEIGHT_NAME, RANK_WEIGHT_PROXIMITY, RANK_WEIGHT_POPULARITY, RANK_WEIGHT_PROMINENCE, RANK_WEIGHT_SOURCE, RANK_WEIGHT_SEMANTIC, RANK_WEIGHTS_REFRESH_INTERVAL |
RANK_WEIGHT_* supply the composite search-ranking weight vector as a fallback. Each defaults to its shipped value, so an unset environment ranks byte-identically to the compile-time constants. Effective weights resolve active ranking_weights DB row > env config > shipped default: an approved retune is applied to the DB and hot-swapped in without a redeploy (see Locations → Ranking). RANK_WEIGHTS_REFRESH_INTERVAL (default 2m, 0 disables) sets how often the API ranker re-reads the active row.
For the canonical, exhaustive list, see Reference → Environment Variables. For how secrets are provisioned and rotated, see Infrastructure → Secrets.
Where S3.BaseURL matters¶
S3.BaseURL is only read from YAML, never from env. This is intentional: it sets the public CDN URL for uploaded files (e.g. https://assets.tomoda.life), and we want it pinned per environment rather than leaking from a development shell.
Debug¶
When the loader runs, it prints a redacted snippet of the Stripe webhook secret to stdout so you can verify env merging succeeded:
Loaded config from .env.local
Stripe Webhook Secret loaded: whsec_xxxx...
If you see Stripe Webhook Secret NOT loaded or too short, your env file isn't being picked up.