Skip to content

Xavia OTA

Self-hosted Xavia OTA server — an expo-updates manifest host that ships JavaScript bundle updates to the mobile app over-the-air. Native binaries are still built locally; most merges are JS-only and reach installed apps through this server without a store round-trip.

Two instances

Xavia has no channel concept: an instance serves the latest bundle per runtimeVersion. To separate internal-tester updates from production, dev and prod run as two independent instances — separate buckets, separate Postgres, separate upload keys — rather than one instance split by channel.

Instance Host Published
xavia-ota-dev ota-dev.tomoda.life every app-repo merge (internal testers)
xavia-ota-prod ota.tomoda.life the controlled v* release tag

The app bakes which host it targets at build time and only accepts an update whose runtimeVersion matches (set in the app's app.config.js); that value bumps only on a native change, which requires a fresh local binary. See the app repo's docs/frontend/native/release.md.

Shape

Each instance is a self-contained manifest set under k8s/envs/platform/xavia-ota-{dev,prod}/ — its own application.yaml (Argo app, namespace platform) and manifests.yaml, discovered by the platform recurse.

Piece Where
Deployment + Service (port 3000) k8s/envs/platform/xavia-ota-{dev,prod}/manifests.yaml
Dedicated Postgres (metadata) same file — single-instance postgres:16-alpine + 5 Gi PVC per instance
Bundle storage GCS buckets tomoda-ota-dev-<project> / tomoda-ota-prod-<project> (infrastructure/gcp/xavia_ota.tf), accessed via Workload Identity (one SA xavia-ota)
Secrets ExternalSecret per instance projects PRIVATE_KEY_BASE_64, ADMIN_PASSWORD, UPLOAD_KEY, POSTGRES_PASSWORD from GCP Secret Manager
Ingress ota-dev.tomoda.life / ota.tomoda.life (cert-manager letsencrypt-prod, ingressClassName: traefik)
Argo apps xavia-ota-dev / xavia-ota-prod (auto-discovered by the platform recurse)

Secrets: shared vs per-env

Both instances share the code-signing key and the admin password; the upload key and DB password are per-env.

Secret Scope Why
tomoda-ota-signing-key shared One signing key → one public cert works in the app, whichever host it targets
tomoda-ota-admin-password shared Single dashboard login
tomoda-ota-{dev,prod}-upload-key per-env Dev-CI and prod-CI publish paths stay isolated
tomoda-ota-{dev,prod}-db-password per-env Each instance has its own Postgres

First-run setup

The secrets are Terraform-generated — nothing is typed by hand:

  • terraform apply mints the RSA code-signing keypair (tls_private_key + a 20-year self-signed cert), a shared admin password, and per-env upload keys + DB passwords, and writes each to Secret Manager.
  • Copy the ota_signing_certificate_pem output into the app repo at frontend/certs/ota-public.pem (the app validates OTA manifests against it). One cert covers both instances.
  • Retrieve the admin password / upload keys from Secret Manager when you need to log into a dashboard or publish (gcloud secrets versions access latest --secret=tomoda-ota-dev-upload-key). The DB passwords are internal-only.

Xavia authenticates to GCS via ADC (the Workload-Identity-bound SA), so only GCP_BUCKET_NAME is set per instance — no key file. The image is pinned to xaviaio/xavia-ota:2.0.3 (latest release); bump deliberately.