Deploying
The reference deployment targets Google Cloud Run via Terraform. Nothing in the platform requires GCP — the services are plain containers — but this is the path that is actually in production.
What gets created
Section titled “What gets created”| Component | Resource |
|---|---|
cavs-api |
Cloud Run service, public (it does its own Firebase auth, so IAM invoker is allUsers) |
cavs-worker |
Cloud Run service, internal-only, min = max = 1, cpu_idle = false — an always-on Pub/Sub puller |
cavs-web |
Cloud Run service, public nginx serving the SPA and reverse-proxying /api and /readyz |
| Events | Pub/Sub topic cavs.events + subscription cavs.worker |
| Secrets | Secret Manager — one secret per sensitive variable |
| Images | Artifact Registry repository cavs |
External, not provisioned by Terraform: MongoDB (Atlas) and object storage (Cloudflare R2 or any S3). Their credentials are stored as secrets.
Prerequisites
Section titled “Prerequisites”gcloud auth logingcloud config set project <your-project>gcloud auth application-default login # Terraform uses ADCYou need roles/owner (or equivalent) on the project for the first run.
Configure
Section titled “Configure”cd cavs-node/terraformcp terraform.tfvars.example terraform.tfvarsRequired before a deploy can succeed:
| Variable | Notes |
|---|---|
secrets.MONGODB_URI |
Your Atlas connection string |
secrets.STORAGE_ENCRYPTION_KEY |
openssl rand -base64 32 |
secrets.SESSION_TOKEN_SECRET |
openssl rand -base64 32 |
s3_account_id |
Cloudflare account id |
secrets.S3_ACCESS_KEY / S3_SECRET_KEY |
R2 API token, scoped to the bucket |
firebase_project_id |
Must match the web build |
app_base_url |
The origin users sign in on |
Deploy
Section titled “Deploy”cd cavs-node/terraform./deploy.shFour phases:
-
Bootstrap — targeted apply of the required APIs and Artifact Registry.
-
Build —
gcloud builds submitbuilds the images viacloudbuild.yaml, withcavs-node/as the build source. One Go Dockerfile parameterized by--build-arg SERVICE=api|worker; the web Dockerfile bakes the publicVITE_FIREBASE_*build args.cavs-node/.gcloudignorekeeps the upload small. -
Apply — the full Terraform apply.
-
Wire URLs — captures the live service URLs into
urls.auto.tfvarsand re-applies. (A Cloud Run service can’t reference its own URL, and api ↔ web would cycle.)
Custom domain
Section titled “Custom domain”Map your domain to the cavs-web service (done outside Terraform, in the Cloud Run
console or with gcloud). The web tier reverse-proxies /api to the API service, so the
dashboard and API share one origin.
Then set both app_base_url and api_base_url to that origin. api_base_url is the
host the API advertises to clients — the repository Connect tab, the git config lfs.url line and the LFS verify href — so leaving it on the generated run.app URL
writes that URL into every user’s git config. Pointing it at the dashboard origin works
because cavs-web proxies /api; only metadata and CAS chunks (≤1 MiB each) cross that
proxy, since LFS object bytes transfer directly against presigned object-store URLs.
If you prefer the API on its own subdomain, map that domain to the cavs-api service
instead and set api_base_url to it — one hop less, same guard applies.
Health checks
Section titled “Health checks”| Check | From | Notes |
|---|---|---|
/readyz |
anywhere | Pings MongoDB. Use this externally. |
/healthz |
container probes only | Google’s front end reserves this path and answers it itself, so an external request returns a GFE 404 that says nothing about your service. Cloud Run’s own probes hit the container directly, so /healthz probes still work. |
curl -sS https://your-domain/readyz # → readycurl -sS https://your-domain/api/v1/plans # → the plan catalogueBoth work on the dashboard domain because the web tier reverse-proxies them to the API:
/api/ as a prefix, and /readyz as an exact match.
Post-deploy verification
Section titled “Post-deploy verification”- All three services report
Ready = True. GET /readyzreturns 200 and a body ofready— this proves database connectivity end to end.GET /api/v1/plansreturns the catalogue.- The dashboard loads and sign-in works on the canonical origin.
- An invitation email arrives with a link on the canonical origin.
- A test push registers: the generation bumps and objects appear.
- Metrics are arriving in your monitoring backend.
- The billing webhook endpoint is registered and reachable.
scripts/smoke.sh automates the HTTP half of that list against a running deployment. Give
it WEB_BASE_URL as well as BASE_URL to cover the public origin — the /api proxy and
the /readyz body — and CAVS_TOKEN to include the authenticated checks:
BASE_URL=https://<api-host> WEB_BASE_URL=https://your-domain ./scripts/smoke.shKnown deployment gotchas
Section titled “Known deployment gotchas”| Symptom | Cause |
|---|---|
invalid_rapt from Terraform |
Missing gcloud auth application-default login |
PERMISSION_DENIED from gcloud builds submit right after enabling the API |
IAM propagation lag. Retry. |
External /healthz returns 404 |
Google’s front end reserves the path. Use /readyz. |
/readyz on the domain returns HTML 200 |
The web tier’s SPA fallback answered it. It needs an exact-match location = /readyz ahead of that fallback. |
cavs-api container won’t start |
S3/R2 not configured — the API refuses to boot without it |
| Secret Manager rejects a secret | Empty-string value. Omit the key. |
| Worker revision never Ready | No HTTP health server on $PORT |
Invitation links point at run.app |
app_base_url overwritten by urls.auto.tfvars |
Connect tab / lfs.url show run.app |
api_base_url overwritten by urls.auto.tfvars |
| Docker build fails on cache mounts | cloudbuild.yaml needs DOCKER_BUILDKIT=1 per step |
| A stale invitation looks broken after a deploy | Check its created_at — it may predate the fix |
Other platforms
Section titled “Other platforms”Nothing is Cloud Run-specific beyond the Terraform module. The services are plain containers:
| Requirement | Notes |
|---|---|
cavs-api |
HTTP, public, horizontally scalable, stateless |
cavs-worker |
Always-on, one instance is enough to start; needs an HTTP health surface if your platform probes |
cavs-web |
Static SPA + a reverse proxy to the API on the same origin |
| MongoDB | Replica set. Managed (Atlas) recommended. |
| Object storage | Any S3-compatible endpoint with Range GET and multipart |
| Pub/Sub | Google Cloud Pub/Sub, or the emulator |
Kubernetes, ECS or plain Docker Compose all work. Keep the worker singleton-ish until you’ve read Scaling — every stage is idempotent, so multiple workers are safe, but that’s a capacity decision, not a correctness one.
Terraform state
Section titled “Terraform state”Terraform state and terraform.tfvars contain secrets. Both are gitignored in the
reference setup. Use a remote backend with encryption and locking for anything
beyond a single operator.
- Configuration reference — every variable and the production checklist.
- Observability — dashboards and alerts.
- Scaling — where the limits are.

