Skip to content

Photon Multilang Rollout

End-to-end deployment guide for the multilingual Photon geocoder used by Location.localizations. Once the index lives in GCS and Photon is pointed at it, monthly refreshes are the only recurring task — see the photon-indexer README on GitHub for that.

Prerequisites

You have:

  • gcloud authenticated against the target project, including ADC (gcloud auth application-default login)
  • kubectl context on the GKE cluster (cluster name in infrastructure/gcp/variables.tf)
  • Docker daemon running locally (Docker Compose v2)
  • Terraform >= 1.0
  • A build machine sized for the chosen region (~8 GB RAM minimum for Canada; planet builds need a cloud VM)

Step 1 — Provision the GCS bucket and indexer service account

The bucket, public-read IAM, lifecycle, and indexer SA are created via the manual bootstrap procedure (Photon resources are deliberately not Terraform-managed so terraform destroy can't delete the ~$500 planet index).

Single GCP project for dev and prod

The project uses one shared GCP project for both environments. Separation is by K8s namespace (tomoda vs prod), not by GCP project. The bucket created here serves both dev compose and prod Photon.

Follow the bootstrap doc → Step 2 (Photon index bucket + service account) to provision:

  • The bucket development-485000-photon-index (STANDARD + NEARLINE@35d + delete@180d lifecycle)
  • The iam.allowedPolicyMemberDomains project-level override
  • The allUsers read binding
  • The photon-indexer service account + IAM bindings
  • The Workload Identity binding for the data/photon-indexer K8s SA

Once that's done, the bucket name development-485000-photon-index and public base URL https://storage.googleapis.com/development-485000-photon-index are hardcoded contracts — no terraform output lookup needed.

Step 2 — Build the photon-indexer image

cd devops/k8s/apps/photon-indexer
docker build -t photon-indexer:local .

Quick sanity check:

docker run --rm photon-indexer:local /usr/local/bin/build-index.sh 2>&1 | head -5
# Should print "GCS_BUCKET is required" and exit

Step 3 — Build and upload the Photon index

cd devops/scripts
./photon-index-local.sh canada

What happens (4-8h on a 32GB laptop):

  1. Pre-flight: Docker, ADC, image present
  2. Brings up the Nominatim + Photon stack via docker-compose
  3. Stage 1 — Nominatim downloads canada-latest.osm.pbf (~3 GB) and imports into Postgres (hours)
  4. Stage 2 — Photon connects to Nominatim's Postgres and runs nominatim-import with languages en,ja,zh,zh-Hans,zh-Hant
  5. Stage 3 — tars photon_data/, uploads versioned + latest aliases to gs://<bucket>/canada/, tears the stack down

The orchestrator heartbeats every 5 min during Stage 1.

Dev — Asia (bigger, for CJK testing)

./photon-index-local.sh asia

~12-24h, ~250 GB disk. Tight on 32 GB RAM but possible.

Switching regions

Nominatim's data volume is per-region. Before switching from Canada to Asia (or back), tear down the volumes:

docker compose -f k8s/apps/photon-indexer/docker-compose.build.yml down -v

Prod — planet

Not viable on a laptop. The fully-tested cloud-VM runbook lives at k8s/apps/photon-indexer/README.md — including IAP firewall setup, local-SSD provisioning, the bootstrap that splits each step so failures are localized, PHOTON_INDEXER_DIR env override for the cloud-VM layout, and the gotchas appendix.

Rough budget: ~$20-30 on n2-highmem-32 with 4 × 375 GB local SSD, ~24-36h wall-clock.

You can defer the planet build — the localization feature ships fine without it, you just won't have ja/zh translations for foreign cities until one lands.

Verify upload

BUCKET=$(cd devops/infrastructure/gcp && terraform output -raw photon_index_bucket)
gsutil ls gs://${BUCKET}/canada/
# Expect:
#   photon-db-canada-multilang-YYYY-MM.tar.bz2
#   photon-db-canada-multilang-YYYY-MM.tar.bz2.md5
#   photon-db-canada-multilang-latest.tar.bz2
#   photon-db-canada-multilang-latest.tar.bz2.md5

Step 4 — Point the Photon manifests at your bucket

The prod manifest has <project-id> placeholders.

BUCKET=$(cd devops/infrastructure/gcp && terraform output -raw photon_index_bucket)
PROJECT="${BUCKET%-photon-index}"

# Prod manifest
sed -i.bak "s|<project-id>|${PROJECT}|g" \
  devops/k8s/apps/photon/manifests.yaml
rm devops/k8s/apps/photon/manifests.yaml.bak

# Dev compose (in the tomoda app repo)
sed -i.bak "s|<project-id>|${PROJECT}|g" \
  tomoda/docker-compose.dev.yml
rm tomoda/docker-compose.dev.yml.bak

cd devops
git add k8s/apps/photon/manifests.yaml
git commit -m "photon: point at multilingual GCS index"
git push origin main

Argo CD picks the manifest up within a few minutes.

Step 5 — Force Photon to re-pull the index

The PARALLEL update strategy on the deployment performs an atomic swap on the next poll. To do it immediately:

# Delete the old PVC so the pod starts fresh
kubectl delete pvc -n data photon-data-pvc

# Restart — pod will recreate the PVC from the manifest and download the index
kubectl rollout restart -n data deployment/photon

# Watch (first download is multi-GB)
kubectl logs -n data -f deployment/photon

Wait for /status to be Ok:

kubectl port-forward -n data svc/photon 2322:2322 &
curl -s http://localhost:2322/status
# { "status": "Ok", ... }

Step 6 — Smoke test multilingual responses

# English
curl -s 'http://localhost:2322/reverse?lat=35.6762&lon=139.6503&lang=en' \
  | jq '.features[0].properties.name'
# -> "Tokyo"

# Japanese
curl -s 'http://localhost:2322/reverse?lat=35.6762&lon=139.6503&lang=ja' \
  | jq '.features[0].properties.name'
# -> "東京都"

# Simplified Chinese
curl -s 'http://localhost:2322/reverse?lat=35.6762&lon=139.6503&lang=zh-Hans' \
  | jq '.features[0].properties.name'
# -> "东京"

kill %1

If any come back in English, the index was built without that language. Re-check LANGUAGES in scripts/photon-index-local.sh and rebuild.

Step 7 — Deploy the backend changes

The backend carries the Location.localizations JSONB column and the parallel Photon-call code in services.locationService.

cd tomoda
git add backend/
git commit -m "loc: persist multi-language translations on Resolve"
git push origin main

Cloud Build builds the image, Argo CD deploys (see Deploy). The schema migration (gorm AutoMigrate) runs on pod startup and adds the localizations JSONB column plus its GIN index. No manual SQL needed.

Verify:

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://api.tomoda.life/api/v1/locations/nearby?lat=35.6762&lng=139.6503&lang=ja" \
  | jq '.results[0].localizations'

Step 8 — Deploy the frontend

Standard mobile + web release. The new localizedLocation() helper and the UI swaps in LocationDetailView, EventDetailView, horizon/events, and (social)/events/[id] are part of the same change.

cd tomoda/frontend
# whatever your release process is (eas build / vercel / etc.)

Rollback plan

If anything in steps 5-8 misbehaves:

# 1. Revert the Photon manifest commit (returns Photon to default index)
cd devops
git revert <commit-sha>
git push origin main
# Then: delete the PVC + rollout restart to force a re-pull of the default

# 2. Revert backend
cd ../tomoda
git revert <commit-sha>
git push origin main

# 3. Frontend — same revert pattern

The schema is forward-compatible — leaving the localizations JSONB column in place is safe even after a backend revert. No down-migration needed.

CronJob status

The in-cluster index-refresh CronJob remains suspended until the Nominatim sidecar setup completes. For now, refreshes are done manually with scripts/photon-index-local.sh and a manifest commit. See k8s/apps/photon-indexer/README.md for when to un-suspend.

Recurring maintenance

OSM data ages out monthly. Recommended cadence is monthly:

cd devops/scripts
./photon-index-local.sh planet

Long-running. When this becomes annoying, un-suspend the CronJob.