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
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
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
}
Server.Env and Database.DSN() / Redis.Address() are helpers that downstream code uses to assemble connection strings.
Required vs optional¶
The loader does not validate which fields must be non-empty — services validate at the point of use (e.g. EmailService.SendEmail returns an error if APIKey or BaseURL is missing). Boot will succeed with placeholder secrets, but the affected feature will fail at runtime.
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 |
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.