Files
heliosian/docs/deploy.md
T

7.8 KiB

Production deployment

The server runs as Cloud Run service heliosian in project heliosian, region us-west1, at https://heliosian-489539474126.us-west1.run.app, with https://who2.heliosian.com mapped on top. Administration is driven with the gcloud CLI (brew install --cask gcloud-cli) authenticated as a project owner (gcloud auth login).

Build and deploy pipeline

Every push to main on github.com/heliosian/heliosian deploys production. The Cloud Build trigger build-main (region us-west1, attached through the github connection) carries its build config inline — nothing in the repo — and runs three steps:

  1. docker build of the repo's Dockerfile, tagged with the commit SHA and latest
  2. push the SHA tag to Artifact Registry (us-west1-docker.pkg.dev/heliosian/heliosian/heliosian)
  3. gcloud run deploy heliosian --image …:<sha> — image only; every other service setting persists from the configuration below

The trigger names its build service account (the project's default compute service account) explicitly; trigger creation in this project refuses to infer one. Builds ship the pushed commit, never a working tree. To rebuild and redeploy current main without a push:

gcloud builds triggers run build-main --region=us-west1 --branch=main

The Dockerfile builds in two stages: a golang stage compiles the static binary (CGO_ENABLED=0), and gcr.io/distroless/static-debian12 — CA certificates and tzdata, nothing else — carries the binary plus web/, whose templates and static assets are read from disk at runtime. sampledata/ is deliberately excluded: production always sets DIRECTORY_SHEET, and a misconfigured server fails at startup rather than silently serving sample data. creds/ never enters the image (.dockerignore, .gcloudignore), and the image holds no credentials of any kind — the runtime identity below is the only Google identity in play.

Service

The service was created once with:

gcloud run deploy heliosian \
  --image us-west1-docker.pkg.dev/heliosian/heliosian/heliosian:latest \
  --region us-west1 --allow-unauthenticated \
  --service-account directory@heliosian.iam.gserviceaccount.com \
  --min-instances 1 --max-instances 1 --memory 4Gi --no-cpu-throttling \
  --set-env-vars DIRECTORY_SHEET=<spreadsheet id>,GOOGLE_CLIENT_ID=<oauth client id> \
  --set-secrets "SESSION_KEY=heliosian-session-key:latest,GOOGLE_MAPS_SERVER_KEY=heliosian-geocoding-key:latest,GOOGLE_MAPS_BROWSER_KEY=heliosian-maps-browser-key:latest" \
  --quiet

Each flag is load-bearing:

  • --service-account — the runtime identity. Application-default credentials inside the container resolve to directory@ through the metadata server; there is no key file anywhere in the system.
  • --min-instances 1 — startup preloads every media object before listening (well under a minute); still far too slow for scale-to-zero.
  • --max-instances 1 — the directory model and blob store live in per-instance memory with no cross-instance coherency; a self-service edit refreshes only the instance that handled it, so a second instance would serve stale data.
  • --memory 4Gi — the blob store holds all media and thumbnails in RAM, and the preload peaks well above the steady-state footprint (a roughly 600 MB blob store OOMed a 2 GiB instance during preload). The startup log line blob store: … MB in memory reports the steady state; resize when boots start failing or the footprint approaches half the limit.
  • --no-cpu-throttling — the directory model and blob store refresh on five-minute tickers between requests; default throttling would starve them.
  • --allow-unauthenticated — the app enforces its own Google sign-in; Cloud Run must let everyone reach the login page.

Cloud Run injects PORT; the server honors it.

Configuration

Plain environment variables:

  • DIRECTORY_SHEET — the production spreadsheet id: the Directory sheet living in the community shared drive.
  • GOOGLE_CLIENT_ID — the OAuth web client id; not a secret (it is embedded in the login page).

Secret Manager secrets, delivered as environment variables per --set-secrets above. Values are used raw, so payloads must not carry trailing newlines:

  • heliosian-session-key — session-cookie HMAC key (any long random string); losing or rotating it signs everyone out
  • heliosian-geocoding-key — the Geocoding API server key, mirrored locally as creds/geocoding.key
  • heliosian-maps-browser-key — the Maps JavaScript browser key, mirrored locally as creds/maps.key

Media storage

Photos and pronunciation recordings live in gs://heliosian-media (us-west1, uniform access, public access prevented, object versioning on), the single source of truth for media — see docs/data.md for the naming convention and stored thumbnails. It sits in the same region as the service, so the whole set preloads into memory in about half a minute with no per-request throttle to work around, and versioning carries the upload history that used to need an archive folder.

Because the bucket is private and every read goes through the app's own sign-in gate, no object is ever publicly readable; the service reads and writes it as directory@.

IAM

directory@heliosian.iam.gserviceaccount.com is both the data identity and the runtime identity:

  • Data access: content manager on the community shared drive, which covers editing the Directory sheet inside it (self-service edits write cells and append to the Change Log tab). Shared in Drive directly, never through project IAM.
  • Media: roles/storage.objectAdmin on gs://heliosian-media, granted on the bucket.
  • Runtime: the Cloud Run service runs as it, and it holds Secret Manager Secret Accessor on each secret individually.
  • Humans: roles/iam.serviceAccountTokenCreator on this account enables the local impersonation that real-data development uses (docs/dev.md).

The build service account (the default compute service account) holds cloudbuild.builds.builder, run.developer, and Service Account User on directory@ — the last because deploying a service that runs as an account requires permission to act as it.

The heliosian.com organization ships Google's secure-by-default org policies, two of which matter here:

  • iam.managed.disableServiceAccountKeyCreation stays enforced. The keyless design depends on it staying enforced: no key for directory@ can exist.
  • iam.allowedPolicyMemberDomains is overridden to allow-all at the project scope only, because --allow-unauthenticated requires granting run.invoker to allUsers.

OAuth

The consent screen lives in this project, audience External and published to production. Internal is not an option: the app restricts sign-in to heliosschool.org accounts (internal/auth), and those live outside the heliosian.com org. Published-External keeps the basic sign-in scopes free of verification friction.

The web client's authorized JavaScript origins are http://localhost:8080, the run.app URL, and https://who2.heliosian.com; sign-in fails on any origin not listed, and edits take a few minutes to propagate. The browser maps key is rendered into every page, so it carries an HTTP-referer restriction for the same three origins.

Domain

who2.heliosian.com is a Cloud Run domain mapping on the service. DNS carries who2 CNAME ghs.googlehosted.com.; Google provisions and renews the certificate once the record resolves. The run.app URL stays live alongside it.

Verifying a deploy

The startup log (Cloud Run → Logs, or gcloud logging read) shows the full boot sequence: the blob store footprint line, geocoding count, directory model load, then listening — about two and a half minutes after the instance starts. After any deploy, an existing session should still work — if everyone got signed out, SESSION_KEY stopped reaching the server.