Skip to content

Infrastructure Overview

How the tomoda app fits into production. This section is app-perspective — what runs where, how a request reaches the backend, how secrets get into a Pod. For the infrastructure itself (Terraform, K8s manifests, Argo CD, cluster operations), see the separate devops docs.

Environments

Three environments. Each is intentionally a strict superset of the previous one so surprises in production are minimized.

Local

Everything runs on the developer's machine. Infra dependencies (Postgres, Redis, MinIO, Photon) come up via docker-compose.dev.yml; the Go backend and the Expo frontend run outside Docker for fast iteration and hot reload.

Component How it runs locally
Postgres + PostGIS kartoza/postgis:15-3.3 container on :5432
Redis redis:7-alpine container on :6379
Object storage MinIO container on :9000 / :9001 (S3-compatible)
Geocoder Photon container on :2322
Backend (Go) go run / Air hot reload, served on :8080
Frontend (Expo) npm run dev, served on :8081

task dev orchestrates this — see Docker Compose and the Runbook.

Dev (GKE)

A non-prod Kubernetes namespace (tomoda) on the shared GKE cluster used for integration testing and pre-prod validation. Application manifests live in the separate devops/ repo at k8s/apps/tomoda/overlays/dev/. Argo CD watches that repo and reconciles cluster state. Secrets are sourced from GCP Secret Manager via External Secrets Operator.

Prod (GKE)

Same Kubernetes cluster as dev, isolated in the prod namespace. Argo CD reconciles k8s/apps/tomoda/overlays/prod/.

How prod actually runs

Prod is not managed databases. Postgres runs in-cluster as a CloudNative-PG (CNPG) cluster, Redis runs in-cluster as the Bitnami Helm chart. Static assets (avatars, chat images) go to AWS S3 + CloudFront, not GCS. GCS is only used for CNPG backups and Photon indexes. For the full story, see DevOps → Architecture.

Production topology (app-perspective)

flowchart LR
    User([User on iOS / Android / Web]) -->|HTTPS| CF[Cloudflare DNS<br/>proxied:false]
    CF -.->|assets.tomoda.life| CFront[AWS CloudFront]
    CFront --> S3[(S3 Bucket<br/>tomoda-assets-prod)]
    CF -->|api/app/www<br/>tomoda.life| LB[GCP Load Balancer]
    LB --> Traefik[Traefik Ingress<br/>in-cluster]

    Traefik -->|/api, /ws| Backend[Backend Pod<br/>Go binary, :8080]
    Traefik -->|/| Frontend[Frontend Pod<br/>Nginx + Expo static export, :8081]

    Backend --> CNPG[(CNPG Postgres<br/>in-cluster + PostGIS)]
    Backend --> Redis[(Bitnami Redis<br/>in-cluster)]
    Backend -.->|presigned PUT| S3
    Backend --> Photon[Photon Pod<br/>in-cluster :2322]

    Backend -->|outbound| Stripe[Stripe API]
    Backend -->|outbound| Resend[Resend / Email]
    Backend -->|outbound| Google[Google APIs<br/>OAuth, Places]

    CNPG -.->|Barman WAL+base| GCS[(GCS<br/>backups bucket)]
    PhotonIndex[(GCS<br/>photon-index bucket)] -.->|FILE_URL polling| Photon

Details worth calling out:

  • Two edges, not one. Static assets (assets.tomoda.life) go through AWS CloudFront in ap-northeast-1. API + web traffic (api.tomoda.life, app.tomoda.life, www.tomoda.life) goes through Cloudflare DNS → GCP Load Balancer → Traefik (in-cluster ingress). Cloudflare is DNS-only (proxied: false) — not an edge cache.
  • One ingress, two backends. Traefik routes /api/v1/* and /ws/* to the Go backend Pod, everything else to the frontend Pod (Nginx serving the Expo web static export). See k8s/apps/tomoda/base/ingress.yaml in the devops repo.
  • Frontend is static. The frontend/ container is a multi-stage build (Node 20 → Nginx Alpine). EXPO_PUBLIC_* env vars bake in at build time — see Cloud Build.
  • WebSocket Hub is single-process. The backend's WS hub holds state in memory; the Deployment runs with replicas: 1 for that reason. Scaling horizontally would require migrating presence/fanout to Redis pub/sub. See backend/internal/websocket/hub.go.
  • Photon is internal-only. The Photon Service is ClusterIP at http://photon.platform.svc.cluster.local:2322. No external ingress.
  • Outbound for third-party APIs. Stripe, Resend, Google OAuth, Google Places — all called from the backend. Webhooks come back inbound through Cloudflare → Traefik → backend.

How the app gets there

Concern App side Infra side (devops repo)
Image build cloudbuild-backend.yaml, cloudbuild-frontend.yaml Cloud Build, Artifact Registry
Deployment n/a — GitOps via Argo CD Argo CD, Tomoda K8s app, Deploy runbook
Postgres DSN env var (DB_HOST, DB_USER, DB_PASSWORD) CNPG cluster, Backups
Redis URL env var (REDIS_*) Bitnami Redis
Object storage S3 SDK + IAM creds via s3-uploader-secret S3, CloudFront, Uploader IAM
Photon PHOTON_URL env var → in-cluster service DNS Photon Deployment, Photon indexer
Secrets envFrom: backend-secrets and s3-uploader-secret Secrets Management, External Secrets
TLS n/a — ingress-level cert-manager, TLS
DNS n/a Cloudflare, External-DNS

Region & project

  • GCP project: development-485000 (single project, dev+prod via K8s namespaces)
  • GKE cluster: gke-tomoda in asia-east1-a
  • Artifact Registry: asia-east1-docker.pkg.dev/development-485000/tomoda-{dev,prod}-repo/
  • AWS region: ap-northeast-1 (static assets only)

What's not in this section