Hosting and deployment
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.
- Deploy the stack. It creates the hosted zone and requests the certificate. The
certificate cannot validate yet: ACM checks a record under
toptable.enablis.techover public DNS, and nothing public knows where that zone is until step 2. - Delegate
toptablefrom 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. - 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.