Web hosting (CloudFront + S3)
The web SPA (apps/web) is hosted on AWS CloudFront in front of a private S3
bucket, one distribution per stage. This replaced the previous Vercel
deployment (#352) so
every environment is isolated in the same AWS account as the rest of TailSpend
and follows the repo's per-stage tailspend-{stage}-* convention.
Environment model: one distribution per stage
Each stage gets its own bucket + distribution + certificate + DNS alias, all
owned by WebStack (apps/infra/lib/stacks/web-stack.ts):
| Stage | Host | Bucket |
|---|---|---|
| dev | dev.tailspend.kinesis.studio |
tailspend-dev-web |
| beta | beta.tailspend.kinesis.studio |
tailspend-beta-web |
| prod | tailspend.kinesis.studio |
tailspend-prod-web |
Separate buckets/distributions (rather than one distribution with per-path
origins) were chosen for clean isolation: a stage's cache, TLS cert, security
headers, and rollout are fully independent, and a bad prod change can't share
state with dev/beta. This mirrors ApiDocsStack, which the stack is modeled on
(private bucket + Origin Access Control, amazon-CAA-pinned ACM cert, Route 53
alias to the distribution).
WebStack also owns the SPA host's DNS record. It used to CNAME to Vercel
(DnsStack, now removed); it is now an A-record alias to the stage's
CloudFront distribution. Cognito's Hosted UI custom domain (AuthStack) and the
API docs cert (ApiDocsStack) both need that host to resolve, so both stacks
order after WebStack in main.ts (ordering only — nothing is cross-stack
imported).
Environment-specific bundles
The issue's key requirement is that config is baked into the correct
artifact, not resolved at runtime. The web app reads its config from
build-time VITE_* variables (apps/web/src/main.tsx,
apps/web/src/auth/cognito.ts):
| Variable | Source |
|---|---|
VITE_API_BASE_URL |
https://api.<host> — derived from app-urls.ts |
VITE_COGNITO_DOMAIN |
https://auth.<host> — derived from app-urls.ts |
VITE_APP_BASE_URL |
https://<host> — derived from app-urls.ts |
VITE_COGNITO_CLIENT_ID |
/tailspend/<stage>/auth/web-client-id SSM param (minted by Auth) |
apps/infra/scripts/write-web-env.ts resolves these for a stage (URL
derivation is the pure, unit-tested lib/config/web-env.ts; the client id is
read from SSM) and writes apps/web/.env.local, which vite build reads. So
the bundle uploaded to tailspend-<stage>-web already targets that stage — no
runtime environment detection, no shared bundle.
Mobile link verification
apps/web/plugins/mobile-app-links.ts emits the Apple AASA and Android Digital
Asset Links documents at /.well-known/apple-app-site-association and
/.well-known/assetlinks.json during the Vite build. They identify the native
studio.kinesis.tailspend app. Apple limits handling to invite previews and
profile URLs; Android limits those paths in the native intent filter.
The Build Web Bundle workflow step uses the existing APPLE_TEAM_ID stage
deployment parameter and the repository/environment Actions variable
ANDROID_APP_LINKS_SHA256_FINGERPRINTS. Set the latter to the colon-separated
SHA-256 fingerprint from Play Console → TailSpend → Setup → App integrity →
App signing → App signing key certificate. Use the certificate Google Play
signs installed apps with. The upload-key certificate alone will not verify a
Play-installed app. Multiple fingerprints, such as a rotating signing key or
an explicitly supported EAS-signed APK, can be comma-separated.
The workflow sets MOBILE_APP_LINKS_REQUIRED=1: missing signing identities or
malformed fingerprints fail the build before deployment. Configure the new
Actions variable before the first deploy containing this change. For a local
stage build, pass the same three variables to npm run build --workspace=web.
Ordinary local builds can omit signing configuration and emit empty association
documents; those documents do not enable verified links.
A dedicated WebAssociationsDeployment uploads .well-known with
Content-Type: application/json, including the extensionless AASA file, and
Cache-Control: no-cache. The root deployment excludes this directory so it
cannot overwrite the MIME type, and waits for the association upload before
issuing its /* invalidation. Real objects at both keys avoid the SPA 404
fallback. Deploy these endpoints before releasing the new native binary; see
mobile.md for device acceptance checks.
Cache & invalidation strategy
Vite emits content-hashed filenames under /assets/ (e.g.
index-DfaCnkun.js). Those are immutable — a new build produces new filenames.
WebStack uploads them in a dedicated BucketDeployment that stamps a
long-lived Cache-Control: public, max-age=31536000, immutable, so browsers
and the edge cache them permanently and they never need invalidation.
CloudFront's CACHING_OPTIMIZED policy forwards that origin Cache-Control.
index.html (and other non-hashed root files) are uploaded in a second
BucketDeployment with Cache-Control: no-cache, so the edge and browsers
always revalidate and a deploy is seen immediately. That deployment also issues
a CloudFront /* invalidation as belt-and-braces on top of no-cache.
Neither deployment prunes (prune: false): a deploy replaces index.html but
keeps the previous build's hashed /assets/*, so already-open tabs and clients
on a browser-cached old index.html can still fetch the assets they reference
instead of hitting a 403 that the SPA-routing error response would turn into a
200 index.html (a broken page until reload). Superseded assets are also not
aged out by a lifecycle rule: S3 expiry is by object age, not by whether an
asset is still referenced, so an age rule would delete the currently live
assets on a stage that goes a while between deploys. Content-hashed asset files
are small, so superseded ones are left to accumulate; trim them, if ever
needed, with a job that deletes only hashes not referenced by the current
index.html.
Client-side routing: S3 returns 403/404 for any path that isn't a real object
(i.e. every SPA route like /expenses/123). CloudFront rewrites both to
/index.html with a 200 so React Router resolves the deep link — the
equivalent of the old Vercel rewrites catch-all.
Deploy process
Automated in .github/workflows/deploy-stage.yml (called by the manual
Deploy workflow and by main-pipeline.yml). Per stage, after assuming the
deploy role:
npm run write-web-env --workspace=@repo/infra -- <stage>→.env.local.npm run build --workspace=web→apps/web/dist(the workspace's build deps are already built by the earlier "Build (excluding web)" step).cdk deployrunsWebStack; the twoBucketDeployments uploaddist/(assets immutable, rootno-cache) and invalidate/*.
[!IMPORTANT] Brand-new stage bootstrap (chicken-and-egg). The web build reads the Cognito web-client-id from
/tailspend/<stage>/auth/web-client-id, which only exists afterAuthStackdeploys. But two things couple the two stacks: (1)WebStack's constructor throws at synth time ifapps/web/distis missing, and synth builds the whole app, so no stack (Auth included) can be synthesized until adist/exists; (2)main.tsmakesAuthStackdepend onWebStack(the SPA host must resolve before Cognito's custom domain), so a singlecdk deploy --allwould tryWebStackfirst.So a first-ever deploy of a stage is a two-phase operation:
- Produce a placeholder bundle so synth succeeds and deploy only Auth:
This mints the client-id parameter. Withoutnpm run build --workspace=web # any dist; env values don't matter yet cd apps/infra # `--exclusively` deploys ONLY Auth, skipping its WebStack dependency; the # with-ssm-env.sh wrapper loads the GOOGLE_*/APPLE_* secrets main.ts's # config schema requires at synth (AWS_REGION pins SSM to us-east-1). AWS_REGION=us-east-1 scripts/with-ssm-env.sh <stage> \ npx cdk deploy TailSpend-<stage>-Auth --exclusively --context stage=<stage>--exclusively,cdk deployalso pulls inWebStack(Auth depends on it), deploying the placeholder bundle first — which--exclusivelyavoids.- Now run the normal flow (
write-web-env→ build →deploy --all), which reads the real client-id and brings upWebStack.Every subsequent deploy is the single normal flow above. Existing stages (dev/beta/prod) already have the parameter, so this only applies to a genuinely new stage.
Cognito web-client replacement. The same two-phase flow applies if a deploy replaces the Cognito web app client (a CDK change that forces a new client id): the web build reads the client id from SSM before
cdk deployruns, so a single-pass deploy would bake the retired id and the deployed SPA couldn't authenticate until a second deploy. DeployAuthStackfirst (step 1 above) so the new client id is in SSM, then run the normal flow to build and ship the bundle against it. The web client id is stable across ordinary deploys, so this is only needed when a change intentionally rotates it.
Local deploy (mirrors CI, needs AWS creds for the account):
# Build the bundle for a stage, then deploy that stage's stacks.
# AWS_REGION must be us-east-1 — the SSM params (and all TailSpend resources)
# live there, and this box's shell defaults AWS_REGION to us-west-2, so
# `write-web-env` would otherwise hit ParameterNotFound. The `local:deploy:*`
# wrapper already pins the region for the deploy step.
AWS_REGION=us-east-1 npm run write-web-env --workspace=@repo/infra -- dev
npm run build --workspace=web
npm run local:deploy:dev --workspace=@repo/infra
Rollback
Rollback is a re-deploy of the previous bundle. Because deploys don't prune and
assets aren't expired, the last-good build's hashed /assets/* still live in
the bucket, but the reliable path is to re-deploy the whole previous bundle so
index.html and its assets are guaranteed consistent:
Preferred: re-run
deploy-stage.yml(or theDeployworkflow) from the last-good commit. It rebuilds that commit's bundle (regenerating its hashed assets), re-uploads it, and invalidates/*. This is the same path as a forward deploy, so it's well-exercised. Get the distribution id from theWebDistributionIdstack output if you need to invalidate manually:aws cloudfront create-invalidation \ --distribution-id <WebDistributionId> --paths '/*'Break glass: the old Vercel project can be re-pointed by restoring the SPA host to a Vercel CNAME, but that requires reverting
WebStack's DNS alias, so it is a last resort rather than a routine rollback.
Cutover from Vercel (one-time, prod)
Merging this change and deploying a stage flips that stage's SPA host from the
Vercel CNAME to the CloudFront alias (WebStack owns the record).
[!IMPORTANT] On a stage that was already deployed, the old
DnsStackstill exists in CloudFormation with a CNAME at the SPA host, andWebStackcreates an A-alias at the same name. Route 53 rejects a coexisting CNAME and A record, andcdk deploydoes not delete a stack that was removed from the app — so theTailSpend-<stage>-DnsCloudFormation stack must be torn down beforeWebStack's alias can be created.This branch no longer instantiates
DnsStack, socdk destroycan't target it (its stack id isn't in the app anymore). Delete the residual stack directly with CloudFormation:aws cloudformation delete-stack --stack-name TailSpend-<stage>-Dns aws cloudformation wait stack-delete-complete --stack-name TailSpend-<stage>-Dns(Alternatively, from a checkout of the pre-migration commit where the stack still exists in the app,
npx cdk destroy TailSpend-<stage>-Dns --context stage=<stage>also works.) There is a brief DNS gap between removing the CNAME and the CloudFront alias propagating; do this per stage, lowest first.
Sequence per stage to avoid a prolonged outage:
aws cloudformation delete-stack --stack-name TailSpend-<stage>-Dns(wait forstack-delete-complete) to remove the Vercel CNAME.- Deploy the stage — this creates the bucket, distribution, cert, uploads the bundle, and adds the CloudFront A-alias at the SPA host.
- Verify the stage host serves the CloudFront-hosted app (deep links, auth redirect round-trip, API calls).
- Once prod is verified, decommission the Vercel project.
dev and beta are cut over first and validated by the pipeline's
integration + e2e suites before prod is promoted, so the prod cutover is
exercised end-to-end on the lower stages beforehand.