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:
gcloudauthenticated against the target project, including ADC (gcloud auth application-default login)kubectlcontext on the GKE cluster (cluster name ininfrastructure/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.allowedPolicyMemberDomainsproject-level override - The
allUsersread binding - The
photon-indexerservice account + IAM bindings - The Workload Identity binding for the
data/photon-indexerK8s 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¶
Dev — Canada (recommended starting point)¶
cd devops/scripts
./photon-index-local.sh canada
What happens (4-8h on a 32GB laptop):
- Pre-flight: Docker, ADC, image present
- Brings up the Nominatim + Photon stack via docker-compose
- Stage 1 — Nominatim downloads
canada-latest.osm.pbf(~3 GB) and imports into Postgres (hours) - Stage 2 — Photon connects to Nominatim's Postgres and runs
nominatim-importwith languagesen,ja,zh,zh-Hans,zh-Hant - Stage 3 — tars
photon_data/, uploads versioned +latestaliases togs://<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.