Skip to content

Secrets Management

Secrets in Tomoda are never committed. They flow into the running process from one of three places depending on where the code is running:

Environment Source of secrets
Local (with GCP auth) GCP Secret Manager via scripts/pull-secrets.sh
Local (without GCP auth) Hard-coded defaults for infra creds; everything else is empty
GKE (dev / prod) Kubernetes Secret resources, sourced from GCP Secret Manager

Local development

Two ways to populate secrets for task dev:

Option 1: .env.local (manual, simpler)

Copy the template and fill in values:

cp backend/env.example.local backend/.env.local
# edit backend/.env.local with your real values

The backend's config loader will read this file when ENV=local. The template at backend/env.example.local covers DB credentials, JWT secret, encryption key, SMTP creds, Twilio creds, Stripe test keys, Google OAuth, WebAuthn, and the email API key.

If you have gcloud authenticated and the right IAM role on development-485000, the task dev target automatically pulls shared secrets at startup:

gcloud auth login
gcloud config set project development-485000
task dev

What happens:

  1. The task dev script runs gcloud auth print-access-token to detect auth.
  2. If authenticated, it runs eval $(./scripts/pull-secrets.sh --export).
  3. pull-secrets.sh shells out to gcloud secrets versions access latest --secret=<name> for each known secret and prints export KEY=value lines.
  4. Those exports are evaluated into the current shell before air / go run ./cmd/server launches.

The list of secrets pulled lives in scripts/pull-secrets.sh. As of writing it includes JWT secret, encryption key, email API key, Stripe (secret + webhook), Google OAuth (web/iOS/Android), Google Places, Apple Sign In (client/service/team/key IDs + the P8 auth key), LINE OAuth, and Klipy.

No secrets touch disk

pull-secrets.sh is careful: it either exports values into the current shell (--export, default) or execs the backend directly with env vars set (--run). No .env file is ever written by the script.

If gcloud is not authenticated, task dev falls back to hard-coded local infra defaults:

DB_USER=tomoda
DB_PASSWORD=tomoda123
REDIS_PASSWORD=
AWS_ACCESS_KEY_ID=tomoda
AWS_SECRET_ACCESS_KEY=tomoda123
S3_ENDPOINT=http://localhost:9000

Application secrets (JWT, Stripe, Google OAuth, etc.) will be empty — most flows that depend on them will fail. This is fine for working purely on local data or UI.

--full mode

By default, pull-secrets.sh keeps the infra creds (DB user/password, Redis, S3) pointed at the local Docker Compose stack. Pass --full to additionally fetch the prod-style DB and Redis credentials from GCP Secret Manager — useful if you're trying to repro a prod-only issue against a tunnel.

Production secrets

In GKE, the backend Pod reads secrets from Kubernetes via envFrom:

envFrom:
  - secretRef:
      name: backend-secrets
  - secretRef:
      name: s3-uploader-secret

(from devops/k8s/apps/tomoda/base/backend-deployment.yaml)

The backend-secrets and s3-uploader-secret Secret resources themselves are not committed to either repository. They're materialized into the cluster by External Secrets Operator (ESO) which bridges GCP Secret Manager (via Workload Identity) and AWS Secrets Manager (via an IAM user) into Kubernetes secrets. For the full mechanism see DevOps → External Secrets and DevOps → Secrets Management.

Adding a new secret

Adding a new secret is a two-repo change:

  1. In tomoda: add the field to the config struct and config.{ENV}.yaml, add a line in scripts/pull-secrets.sh so local dev can pick it up, and document the env var.
  2. In devops: create the secret in GCP Secret Manager and add a key to the backend-secrets Kubernetes Secret (or whatever mechanism is in use).

Forgetting step 2 means the Pod will start without the value and the affected code path will silently fail.

Frontend "secrets" are not secrets

Anything prefixed EXPO_PUBLIC_* is shipped to clients — both in the static web bundle (Cloud Build bakes them in at build time) and in the mobile app binary. Treat the EXPO_PUBLIC_* env vars as public configuration, not secrets.

The values that are public-safe and live as EXPO_PUBLIC_*:

  • EXPO_PUBLIC_API_URL, EXPO_PUBLIC_WS_URL
  • EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID (OAuth client IDs are public by design)
  • EXPO_PUBLIC_COMMIT_SHA, EXPO_PUBLIC_APP_VERSION

A Google OAuth client secret, a Stripe secret key, a JWT signing key — these must never have an EXPO_PUBLIC_ prefix and must never appear in frontend/. They belong to the backend.

Rotating a secret

  1. Generate a new value (openssl rand -base64 32 for symmetric secrets).
  2. gcloud secrets versions add <name> --data-file=- (in the development-485000 project).
  3. Restart the backend Pod (kubectl rollout restart deployment/backend -n <ns>) so it picks up the new version. External Secrets refresh interval is typically a few minutes — see DevOps → External Secrets.
  4. For asymmetric secrets (JWT, encryption key), follow the rotation runbook in DevOps → Secrets Management so existing tokens stay valid through the cutover.