Tickety
TOPTABLE / Hosting and deployment

Hosting and deployment

Engineering · updated 16 Sep

Top Table is a static bundle. npm run build emits dist/, about 750K, most of it self-hosted IBM Plex. There is no backend, no API and no database. Data lives in the browser's localStorage and never leaves it.

So hosting is a file copy. Everything below exists to make that file copy repeatable, and to give every pull request somewhere to be looked at before it merges.

What we are building

Where Who publishes it
The live site https://toptable.enablis.tech/ A merge to main
A pull request preview https://toptable.enablis.tech/tt-16/ Every push to an open pull request

One bucket. One distribution. One name. Production sits at the bucket root and each preview sits under a key prefix named for its issue key, lowercased.

s3://toptable-site/
  index.html            assets/…   scenarios/…      ← the live site
  tt-16/index.html      tt-16/assets/…              ← a preview
  tt-38/index.html      tt-38/assets/…              ← another

A preview is deleted when its pull request closes, merged or not.

The shape

Route 53 alias → CloudFront → the S3 website endpoint as a custom origin.

That is one deviation from the obvious sketch, which is Route 53 straight onto the website endpoint with no CloudFront at all. The deviation buys TLS and nothing else. S3 website endpoints are HTTP only and no certificate can attach to one, so without CloudFront the site is served in the clear, the browser shows "Not secure", and any network between the user and the bucket can rewrite the app that is about to read their guest list. For a tool that holds names, dietary notes and who is not speaking to whom, that is not a trade worth making to save a resource.

Keep the website endpoint underneath it. The instinct is CloudFront with Origin Access Control onto the bucket's REST endpoint, private bucket, no public read. It is the better-looking diagram and it breaks the previews. DefaultRootObject applies only to /, so a request for /tt-16/ returns a 404 rather than /tt-16/index.html, and recovering that means a CloudFront Function on every viewer request. The website endpoint applies its index document to every prefix, which is exactly the behaviour the previews need, for free.

The cost of keeping it is a bucket that is public-read. Given the bucket holds a public website and nothing else, that is the correct amount of private.

There is no routing to rescue

Top Table has no client-side router. Tabs are state, not URLs. So there are no deep links to rewrite and none of the usual single-page-app 404 handling applies. The only addressable URLs are / and /tt-nn/, and the website endpoint's index document serves both.

The base path is the whole trick

Assets have to be referenced relative to wherever the app is served from. Vite settles that at build time, not at runtime:

npm run build                    # production, base defaults to /
npm run build -- --base=/tt-16/  # a preview

No source change is needed. --base flows into import.meta.env.BASE_URL, and src/screens/setup/scenarioSource.ts already builds its scenario URL from that rather than hardcoding a leading slash.

Use the explicit prefix rather than --base=./. Relative paths happen to work at /tt-16/ and break at /tt-16 without the trailing slash, and nothing warns you.

Two things that will bite

--delete on production deletes every preview. aws s3 sync dist/ s3://bucket/ --delete removes everything in the bucket that is not in dist/, and the previews are not in dist/. A merge would quietly take down every open pull request's preview. Production syncs with --exclude 'tt-*/*' on the delete, always.

Cache headers have to be split. dist/assets/* is content-hashed, so it is immutable and can be cached for a year. index.html, favicon.svg, mark.svg and scenarios/*.json are not hashed. If index.html is cached long, a returning browser asks for asset hashes that the last --delete just removed, and the app white-screens on a blank page with no error. Two sync passes: hashed assets first with max-age=31536000, immutable, everything else second with no-cache.

Previews use no-cache throughout. They live for days and are looked at once.

Previews

The prefix comes from the branch name. Every branch already carries its issue key — .githooks refuses one that does not — so feat/TT-16-plan-fit-score gives tt-16.

Two open pull requests carrying the same key share one prefix and the later push wins. That is accepted rather than solved. One ticket is normally one branch, and the alternative is a URL nobody can guess from the board.

Previews are not indexed. A robots.txt at the bucket root disallowing /tt- covers every preview and touches no production path, because production has none beginning tt-.

Previews are not access-controlled. Anyone with the URL can open one. There is nothing behind them but a build of a public repository, and the data in a preview is whatever the person looking at it typed into their own browser.

Credentials

This lives in the existing development account. It is not getting one of its own.

GitHub Actions authenticates with OIDC, assuming a role whose trust policy names this repository. No long-lived access keys in repository secrets, and none on a laptop either.

Two roles, not one. The publish role writes to the bucket and invalidates the distribution and can do nothing else. Every merge and every push to a pull request assumes that one. The infrastructure role can create and change the bucket, the distribution and IAM itself, and only the infrastructure workflow assumes it.

One role doing both would hand every publish job the power to rewrite IAM, which is a privilege escalation sitting one careless workflow edit away.

What the stack owns, and what it does not

One CloudFormation stack under infra/ owns everything except the delegation. That one sits in the root account, is done by hand once, and everything else follows from it.

Owned by
Hosted zone toptable.enablis.tech The stack, in the development account
ACM certificate The stack, validated against that zone
Bucket, distribution, IAM roles The stack
toptable.enablis.tech alias record The stack
The toptable delegation in enablis.tech By hand, in the root account

The stack is deployed deliberately, not on every merge. It changes a handful of times a year, and a pipeline that rewrites infrastructure whenever anybody merges anything is a larger blast radius than the thing it automates.

Deliberately is not the same as by hand. Infrastructure is applied by a workflow like everything else. What makes it deliberate is the trigger, not the hands: nothing runs unless infra/ changed, and nothing is applied that has not been read as a changeset on the pull request first.

A pull request that touches infra/ gets a changeset posted on it. That is the same bargain as the previews — something a person can read before they approve it, rather than a diff and a hope.

The order is not a preference

The first deploy stops in the middle and waits for a person. That is the design, not a fault in it.

  1. Deploy the stack. It creates the hosted zone and requests the certificate. The certificate cannot validate yet: ACM checks a record under toptable.enablis.tech over public DNS, and nothing public knows where that zone is until step 2.
  2. Delegate toptable from the root account to the four nameservers the new zone was given. They are a stack output, and they are on the zone in the console the moment it exists — which is well before the stack finishes.
  3. Validation completes on its own, usually within minutes, and the distribution and the alias record create behind it.

A deploy that appears to hang at the certificate is almost always waiting for step 2. It is not broken and it does not want restarting.

The certificate is still issued in us-east-1 whatever region the bucket is in, because CloudFront reads certificates from that region only. Route 53 is global, so the zone has no region to get wrong.

The hosted zone has to outlive the stack. Its nameservers are what the delegation in the root account points at. Delete the stack and deploy it again and the new zone is given different ones, the delegation points at nothing, and the site goes dark in a way that reads as a certificate problem. Retain it on delete.

The first deploy is the exception to everything above. The role the workflow assumes is created by the stack, so it does not exist until the stack has been applied once. That first apply is by hand. Every one after it goes through the workflow.

Configuration

Every value the workflows need — bucket name, distribution id, role ARN, site URL — comes from a GitHub Actions environment. Nothing is written into a workflow file, so moving accounts or renaming a bucket is a settings change rather than a commit.

Two consequences worth knowing before the environment is configured:

  • An environment carries values, not a gate. Required reviewers on one are not available to this account, so nothing waits for an approval after a merge. The review is the changeset on the pull request, and what makes it a review rather than a formality is the protection on main.
  • Were they ever available they would go on the infrastructure environment and never on the previews'. A protected environment holds every job that names it, so each push to a pull request would sit waiting for somebody to approve publishing a preview nobody has been able to look at yet.
  • A job only sees an environment's values if it declares that environment. A job that forgets to gets empty strings, and aws s3 sync dist/ s3:/// fails somewhere less obvious than the line that caused it.

Not in scope

No server, no API, no environment variables at runtime. No staging environment — previews are the staging environment. No per-preview DNS name. No authentication in front of previews. No rollback mechanism beyond re-running a deploy from an older commit, which works because the build is deterministic and the bucket is a file copy.

Sharing a plan is TT-34 and is still out of the MVP. Hosting the app is not sharing a plan: two people opening the same URL get two empty, unrelated browsers.