Skip to content

Configuration

The backend uses a two-layer configuration system:

  1. YAML files hold non-sensitive defaults (ports, hosts, timeouts, feature flags).
  2. 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.