TailSpend Docs

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.

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:

  1. npm run write-web-env --workspace=@repo/infra -- <stage> → .env.local.
  2. npm run build --workspace=web → apps/web/dist (the workspace's build deps are already built by the earlier "Build (excluding web)" step).
  3. cdk deploy runs WebStack; the two BucketDeployments upload dist/ (assets immutable, root no-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 after AuthStack deploys. But two things couple the two stacks: (1) WebStack's constructor throws at synth time if apps/web/dist is missing, and synth builds the whole app, so no stack (Auth included) can be synthesized until a dist/ exists; (2) main.ts makes AuthStack depend on WebStack (the SPA host must resolve before Cognito's custom domain), so a single cdk deploy --all would try WebStack first.

So a first-ever deploy of a stage is a two-phase operation:

  1. Produce a placeholder bundle so synth succeeds and deploy only Auth:
    npm 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>
    
    This mints the client-id parameter. Without --exclusively, cdk deploy also pulls in WebStack (Auth depends on it), deploying the placeholder bundle first — which --exclusively avoids.
  2. Now run the normal flow (write-web-env → build → deploy --all), which reads the real client-id and brings up WebStack.

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 deploy runs, so a single-pass deploy would bake the retired id and the deployed SPA couldn't authenticate until a second deploy. Deploy AuthStack first (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:

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 DnsStack still exists in CloudFormation with a CNAME at the SPA host, and WebStack creates an A-alias at the same name. Route 53 rejects a coexisting CNAME and A record, and cdk deploy does not delete a stack that was removed from the app — so the TailSpend-<stage>-Dns CloudFormation stack must be torn down before WebStack's alias can be created.

This branch no longer instantiates DnsStack, so cdk destroy can'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:

  1. aws cloudformation delete-stack --stack-name TailSpend-<stage>-Dns (wait for stack-delete-complete) to remove the Vercel CNAME.
  2. Deploy the stage — this creates the bucket, distribution, cert, uploads the bundle, and adds the CloudFront A-alias at the SPA host.
  3. Verify the stage host serves the CloudFront-hosted app (deep links, auth redirect round-trip, API calls).
  4. 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.

View Markdown source