Skip to content

The public demo

A single, disposable instance of Trusty Track that somebody evaluating it can click through without installing anything: a populated race, standings, a championship field that filled itself, and a heat still left to run.

There is one running at demo.trusty-track.com. Anything you do there is undone the next time it restarts, so click about freely.

It is the same image as every other install. What makes it a demo is one environment variable.

Running one

docker run --rm -p 8000:8000 \
  -e TRUSTYTRACK_DEMO_MODE=1 \
  -e TRUSTYTRACK_DEMO_SEED=trusty-track-demo \
  ghcr.io/dknowles2/trusty-track:latest

No volume. The demo keeps nothing, seeds itself on first boot, and a restart is its reset.

The environment

Variable What it does
TRUSTYTRACK_DEMO_MODE Turns the demo on. Absent means an ordinary install, which is every install that exists
TRUSTYTRACK_DEMO_SEED Fixes the invented data, so every visitor sees the same roster and the same times
TRUSTYTRACK_ALLOWED_ORIGINS Narrows CORS to the demo's own hostname, or several separated by commas. Defaults to *, which is right on a LAN and wrong on the internet
TRUSTYTRACK_DATA_DIR Where it writes. On a host with no disk, point it somewhere ephemeral
PORT The port to listen on. Defaults to 8000; Cloud Run and Railway set it for you

What the demo refuses

A visitor can create races, run heats, edit results, delete things and hand out awards. What it will not do:

  • set a PIN. An install with no operator PIN treats every caller as the operator, so without this the first visitor to open System Settings would own the demo until somebody reset it;
  • create or edit a track. Both can point a track's timer at a real serial port, which is the same door back into probing USB devices that booting the demo otherwise closes;
  • accept a photograph. Uploads are refused outright, which keeps the demo from holding a picture of somebody's child. Every photo control — the racer form's file picker, camera and Rotate/Recrop, the roster's Upload Photos, Populate Test Data's own photo checkboxes — shows as disabled, with a title saying why, rather than letting you pick a file only to be refused afterwards;
  • import a roster, or create a practice race. These make rows in bulk with no credential, on an instance other people are looking at;
  • download or restore a backup, or connect a timer over the browser.

Populate Test Data is allowed, capped at 40 racers a call (and always without photos, for the same disk-write reason uploads are refused) — filling a race with test racers is the single most natural thing to try, and a visitor who creates their own race would otherwise have no way to fill it.

Everything else works, including deleting a race — the reset undoes it. Track setup is the one exception beyond the PIN itself: System Settings is otherwise read-only for tracks on the demo.

What it keeps

Nothing. Storage is ephemeral, so anything a visitor types is gone by the next restart. That is the demo's data-retention answer as well as its reset.

It lets go when nobody is using it

After five idle minutes — or twenty minutes in total — the demo closes its subscription connection and offers to start again. The page says so.

This is not tidiness. The app's live screens are built never to stay disconnected, because a display frozen mid-event is the failure that matters at a venue. On a host that bills for a running instance, that same policy means one forgotten browser tab keeps the demo awake indefinitely. The demo therefore disposes its connection rather than retrying, and starting again is a reload.

Deploying it

On a release

Tagging a stable version pushes it to the demo. The release workflow publishes the image, and its Public Demo job then points Cloud Run at that exact version and waits for /health to answer before it calls the deploy good.

Three things about it:

  • It deploys the published image; it never builds one. What a visitor clicks through is the same image the release page hands out.
  • A release candidate is skipped, exactly as one does not claim the latest Docker tag. The demo is where this site sends an evaluator, so it follows stable versions only.
  • A failed deploy does not fail the release. The installers and the release page do not wait on it, and the job can be re-run on its own from the Actions tab — or run by hand from there against any published version.

It stays switched off until the repository has somewhere to deploy to: the job is skipped unless a DEMO_CLOUD_RUN_PROJECT repository variable names a project, so a fork releases without it.

Setting it up is four repository variables and one Google Cloud identity. Authentication is Workload Identity Federation — GitHub proves who it is with a short-lived token, so there is no service account key in this repository to leak or rotate. In the Google Cloud project: create a Workload Identity pool with a GitHub provider, restrict it to this repository, and let it impersonate a service account holding Cloud Run Admin (roles/run.admin) and Service Account User (roles/iam.serviceAccountUser) — the first to deploy the service, the second to let it act as the runtime account. Then, under Settings → Secrets and variables → Actions → Variables:

Variable What it holds
DEMO_CLOUD_RUN_PROJECT The project id. Absent, nothing deploys
DEMO_WORKLOAD_IDENTITY_PROVIDER The provider's full resource name, projects/…/providers/…
DEMO_DEPLOY_SERVICE_ACCOUNT The service account to impersonate
DEMO_ALLOWED_ORIGINS The demo's own URL, so CORS stops being the LAN wildcard
DEMO_CLOUD_RUN_SERVICE, DEMO_CLOUD_RUN_REGION Optional; empty takes the script's own defaults
DEMO_MAIN_ALLOWED_ORIGINS, DEMO_MAIN_CLOUD_RUN_SERVICE The same two for the main demo below. The service defaults to trusty-track-demo-main

Variables rather than secrets, and not by accident: none of them is a secret once the key is gone, and GitHub will not let a job decide whether to run based on a secret — which is what lets the job skip quietly on a repository that has no demo instead of failing somebody's release.

The workflow calls deploy/cloudrun/deploy.sh rather than repeating its flags, so there is one description of what the demo is.

Tracking main

There is a second demo for developers, and it follows main rather than releases: every merge builds the image at that commit, publishes it to GHCR as ghcr.io/dknowles2/trusty-track:main, and deploys it to its own Cloud Run service. A change is on a real host a few minutes after it lands, weeks before the next tag would put it on the stable demo.

It is the same image, the same deploy.sh and the same demo mode — a second service that scales to zero like the first, so an idle one costs nothing. What differs:

  • It is built for linux/amd64 only. Cloud Run is amd64, and the arm64 half of a release build is minutes of emulation nobody would pull from this tag. So :main is not an install image: on a Raspberry Pi it does not run, and the Docker install should keep pulling a version.
  • It is deployed by digest. :main moves on every push; the digest names the one build the merge produced, so a deploy that queued behind another cannot be handed a later commit's image by a tag that moved under it.
  • /health reports 0.0.0-dev-<short sha>, the same stamp a local checkout carries, and the deploy asserts it — the version in the navigation bar is which commit you are looking at.
  • A docs-only merge skips it. Nothing under docs/, www/ or .claude/ reaches the image, so there is no cold start for a page nobody will see there.

It shares the project, the identity and the region with the stable demo and adds two optional variables, DEMO_MAIN_ALLOWED_ORIGINS and DEMO_MAIN_CLOUD_RUN_SERVICE. It is switched on by the same DEMO_CLOUD_RUN_PROJECT — a repository with a demo has both.

Both instances go through one workflow, deploy-demo.yml, which takes an instance of stable or main and decides the service and the CORS origin from that in one place. A caller never names a service, and that is the point: deploy.sh's own default service is the stable demo, so a main deployment that could fall through to it on an empty variable would eventually do so. Running it by hand from the Actions tab offers the same choice.

By hand

deploy/cloudrun/deploy.sh deploys to Cloud Run, two ways:

./deploy/cloudrun/deploy.sh

builds the current working tree with Cloud Build, which is what you want whenever the demo is ahead of the last release — and it is, unless a tag has been cut since the demo work landed. To deploy a published image instead:

IMAGE=ghcr.io/dknowles2/trusty-track:1.2.0 ./deploy/cloudrun/deploy.sh

Use a version tag rather than latest, which follows releases and would change the demo under you on somebody else's merge.

The build is a step of its own rather than gcloud run deploy --source, and deploy/cloudrun/cloudbuild.yaml is why: --source uses Cloud Build's legacy docker builder, where the Dockerfile's --platform=$BUILDPLATFORM expands to nothing and the build fails on its first line. That line keeps the frontend build off QEMU in the release workflow's multi-arch build, so the build config turns BuildKit on rather than the Dockerfile giving it up.

You need gcloud, a project with billing, and three APIs — run, cloudbuild, artifactregistry. On a project that has never deployed from source, the script creates its Artifact Registry repository itself. Google no longer grants the default Compute Engine service account what Cloud Build needs, so a first deploy may also want:

gcloud projects add-iam-policy-binding PROJECT   --member=serviceAccount:PROJECT_NUMBER-compute@developer.gserviceaccount.com   --role=roles/cloudbuild.builds.builder

Every flag in the script is load-bearing and commented; the two that matter most are --max-instances=1, which is both a hard cost ceiling and what keeps the app's in-process pub/sub correct, and --min-instances=0, which lets the instance die when nobody is there — which is the reset.

Set a billing budget alert as well. The instance cap bounds the worst case; the alert is what tells you if you reach it.

Fly.io, Hetzner and Railway all run the same image with a different ten lines of configuration, because the demo has no durable state and calls no platform APIs. That, and not an abstraction layer, is what makes moving cheap.

For maintainers

The rules an agent needs are in .claude/rules/auth-and-demo.md under "The public demo", which records what was built, what was measured and decided against, and why. CLAUDE.md indexes it.