Plan: `beta.batbern.ch` β a frontend canary on production data
Status: Draft (not started)
Status: Draft (not started)
Author: Amelia (dev agent) β 2026-06-02
Branch origin: discussed while on perf/public-homepage-followup (the lazy-Amplify perf work is the first intended payload for beta)
1. Goal & motivation
We have one AWS environment β the staging account (188701360969) is production
(www.batbern.ch / api.batbern.ch / cdn.batbern.ch). There is no separate place to preview a
change against real data and the real CDN before it reaches every visitor.
The frontend is build-once / environment-agnostic: it ships no VITE_ env vars and pulls
all runtime config from GET /api/v1/config at boot (see web-frontend/vite.config.ts header
and web-frontend/src/config/runtime-config.ts). That means we can stand up a second
frontend β a different static bundle behind a different hostname β that talks to the same
backend, Cognito pool, and database. For a frontend-only change (like the lazy-Amplify perf
work) this gives a faithful preview: real CDN, real images, real data β trustworthy
Lighthouse/PSI numbers, on a URL we can hand to a few people before flipping prod.
1.1 The non-negotiable caveat β beta is NOT a sandbox
Because beta shares the production backend + Cognito + RDS:
- Every action on beta hits live production data with real accounts. A delete on beta is a delete on prod.
- Beta is a UI canary, appropriate for: frontend perf, layout/CSS, client-side routing, bundle/loading changes, copy β anything that does not change what the backend does.
- Beta is NOT appropriate for: testing migrations, new/changed API contracts, destructive workflows, or anything you would not want executed against production.
This caveat must be stated wherever beta is documented or linked. When the work to preview is a backend change, beta gives you nothing β use a real ephemeral env or staging-of-staging instead (out of scope here).
2. Current architecture (verified 2026-06-02)
- Frontend stack (
infrastructure/lib/stacks/frontend-stack.ts): one S3 bucketbatbern-frontend-staging(OAC, block-public), one CloudFrontDistribution, astableBucket(batbern-frontend-stable-staging) used by the Playwright rollback gate, two CloudFront Functions (staging-spa-routerrewriting extension-less//-terminated URIs to/index.html;staging-html-no-cache), cache policies, a security-headers RHP (with CSP),domainNameswww.batbern.ch+ apexbatbern.ch, Route53 A records, and aBucketDeploymentwithdistributionPaths: ['/*']. - Runtime config:
GET /api/v1/configreturnsapiBaseUrl,cognito,environment, etc. A beta bundle calls the same endpoint β same backend/Cognito/data. - CSP (
frontend-stack.ts~L193) already allowsconnect-src ... https://api.batbern.ch ...β a beta origin can reach the API as far as CSP is concerned. - Gateway CORS allowlist (
api-gateway/.../config/SecurityConfig.java:107) is exactly:http://localhost:*,http://127.0.0.1:*,https://www.batbern.ch,https://batbern.ch. Any new hostname is CORS-blocked until added here (the one backend change this needs). - ACM cert (
staging-config.tsfrontendCertificateArn) coverswww.batbern.ch+batbern.chonly. ACM certs are immutable β a new cert is required forbeta.batbern.ch. - Login: the app signs in via
amplifySignIn(username/password / SRP), not the OAuth hosted-UI redirect. TheredirectSignIn/OutURLs pinned to www inconfig/amplify.tsare only used bysignInWithRedirect(hosted UI), which the app does not use. β password login works on beta without touching those URLs. (If hosted-UI/social login is ever added, beta would need its callback URL registered in Cognito β documented limitation, see Β§7.)
2.1 Why subdomain, not batbern.ch/beta (path)
| Concern | beta.batbern.ch (chosen) |
batbern.ch/beta (rejected) |
|---|---|---|
| localStorage / sessionStorage | isolated per origin | shared with prod β Cognito tokens collide |
| PWA service worker | own scope per origin | one SW would control both prod + beta |
Vite base + Router basename + SPA rewrite |
unchanged (served at /) |
all three must fork for the beta build |
| Backend CORS | needs +1 allowlist line |
none (origin stays batbern.ch) |
| Cert + DNS | new cert + record | none |
The path approachβs only win is βno CORS/cert/DNSβ, but it pays for it with shared Cognito tokens and a shared service worker across prod and beta β a real correctness footgun. Subdomain isolation is worth the cert+DNS+one-CORS-line cost.
3. The main infra gotcha β resource-name collisions
FrontendStack derives every physical name from envName (staging):
batbern-frontend-${envName}, batbern-frontend-stable-${envName}, ${envName}-spa-router,
${envName}-html-no-cache, ${envName}-static-assets, ${envName}-html-no-cache (cache),
${envName}-seo-cache, ${envName}-security-headers, ${envName}-static-assets-headers,
logFilePrefix frontend-cloudfront/${envName}/, and CfnOutput export names
${envName}-Frontend*.
A second instance built with envName: 'staging' would collide on all of them and fail to
deploy. The fix is a new optional variant discriminator on FrontendStackProps:
variantomitted/empty β names are produced exactly as today (no diff on the prod stack β verified in Phase 0; this is the safety gate).variant: 'beta'βbatbern-frontend-beta,beta-spa-router,beta-static-assets,beta-FrontendUrl, etc.
Introduce a single helper inside the stack, e.g.
const name = (base: string) => props.variant ? \${props.variant}-${base}` : `${envName}-${base}`for the${envName}-β¦ names, and analogous logic for the two bucket names (batbern-frontend${variant ? β-β+variant : ββ}-${envName}`). Default path must reproduce the
current string byte-for-byte so CDK sees no change to the live distribution/bucket (a changed
bucket name or distribution comment can force replacement of the production site).
4. Phased delivery (each phase independently deployable, prod-safe)
Ordering follows the house rule: every phase is shippable to prod on its own and cannot
endanger the live site. npm run diff:staging is reviewed before each deploy.
Phase 0 β Parameterize FrontendStack with variant (pure no-op for prod) π’ safety gate β β
DONE (commit b98d388e, 2026-06-02)
- Add optional
variant?: stringtoFrontendStackProps; thread it through all name derivations and gatestableBucketon!variant(see Β§3). - Add/extend unit tests in
infrastructure/test/unit/frontend-stack.test.ts: (a) default (no variant) still producesbatbern-frontend-staging,staging-spa-router, etc.; (b)variant:'beta'produces the beta-prefixed names and nostableBucket. - Acceptance:
npm run diff:stagingshows ZERO changes to the existing*-Frontendstack. This proves the refactor is invisible to prod. Merge + (optionally) deploy β it changes nothing live. - Rollback: trivial (revert; no live resource touched).
- β
Result (2026-06-02): implemented via optional
variantprop onFrontendStackProps(prefix/bucketSuffix/isPrimarydiscriminators;stableBucket+ its output gated on primary; buckets destroyable +PRICE_CLASS_100for variants). 14 unit tests pass. Verified two ways: (1)cdk synth BATbern-staging-Frontendof HEAD vs the change β byte-for-byte identical template; (2) realcdk diff BATbern-staging-Frontendβ βThere were no differencesβ on all structural sections, the only delta being theDeployWebsitebundle asset hash (the separately-committed lazy-Amplify build, unrelated to this refactor). No beta stack instantiated yet (Phase 2).
Phase 1 β ACM certificate for beta.batbern.ch (us-east-1, DNS-validated) π’ no traffic impact β β
DONE (commit 13ae9fc0, 2026-06-02)
β Result: cert
arn:aws:acm:us-east-1:188701360969:certificate/7d4daddf-59e8-47e8-aac3-b7db779d5839requested + DNS-validated (CNAME UPSERTed intoZ08825557YYLWVHISLPY), reachedISSUED, and pinned asdomain.betaFrontendCertificateArninstaging-config.ts. Unused until Phase 2.
- Decision (Q2): pre-create the cert and pin its ARN β mirrors the existing
frontendCertificateArnconvention; noDnsStackchange. - Issue a cert for
beta.batbern.chin us-east-1 (CloudFront requirement) via console/CLI, DNS-validated against the existingbatbern.chhosted zone (Z08825557YYLWVHISLPY):AWS_PROFILE=batbern-staging aws acm request-certificate \ --region us-east-1 --domain-name beta.batbern.ch \ --validation-method DNS --query CertificateArn --output text # then create the CNAME validation record in the batbern.ch zone and wait for ISSUED - Pin the ARN in
staging-config.tsasdomain.betaFrontendCertificateArn(next tofrontendCertificateArn). The cert sits unused until Phase 2 β zero impact on the live site. - Acceptance: cert
ISSUED; ARN committed in config. Rollback: delete the unused cert.
Phase 2 β Beta FrontendStack instance (new bucket + new CloudFront + beta.batbern.ch) π‘ new stack, prod untouched β β
DONE (commit 5a0bc024, 2026-06-02)
β Result:
BATbern-staging-FrontendBetadeployed to the prod account (DistributionIdE3KG0H0UXSYH2F/d182gua1vp827j.cloudfront.net, bucketbatbern-frontend-beta-staging).https://beta.batbern.chreturns HTTP 200, serves the SPA, TLS via the pinned cert, andx-robots-tag: noindex, nofollow. Primary*-Frontendtemplate re-confirmed byte-identical. 18 unit tests pass. API calls 403 on CORS until Phase 3.
- In
bin/batbern-infrastructure.ts, behind a--context betaFrontend=true(or aconfig.betaFrontendflag so it never synthesizes by accident), create:new FrontendStack(app, `${stackPrefix}-FrontendBeta`, { config, variant: 'beta', domainName: 'beta.batbern.ch', apexDomainName: undefined, // no apex for beta hostedZoneId: config.domain.hostedZoneId, certificateArn: config.domain.betaFrontendCertificateArn, crossRegionReferences: true, }); - Decision (Q1): public +
noindex, no auth gate. Add aX-Robots-Tag: noindex, nofollowresponse header on the beta distribution (a small CloudFront response function or an extra RHP value, gated onvariant) so beta never competes with www in search. No HTTP Basic auth β the site is openly reachable; the Β§1.1 shared-prod-data caveat is mitigated only bynoindex+ not advertising the URL, so do not link it publicly. - This is a separate CloudFormation stack; the prod
*-Frontendstack is not in its dependency graph and is never modified. - After deploy,
beta.batbern.chserves the SPA. API calls will 403 on CORS until Phase 3 β expected; the static site + routing can still be eyeballed. - Acceptance:
https://beta.batbern.chreturnsindex.html, SPA routes resolve, TLS valid. - Rollback: destroy the
*-FrontendBetastack (RemovalPolicy is DESTROY for non-prod variant buckets β confirm the beta bucket isautoDeleteObjects: true/DESTROYeven thoughisProdis true; add|| variantto the destroy condition so beta is cleanly removable).
Phase 3 β Add beta origin to gateway CORS (the one backend change) π‘ additive, reversible β β³ DEPLOYING (commit 6a894780 on develop, 2026-06-02)
- Add
"https://beta.batbern.ch"tosetAllowedOriginPatterns(...)inapi-gateway/.../config/SecurityConfig.java:107. Additive β does not affect existing origins. - Update any CORS unit/integration test that asserts the allowlist contents.
- Redeploy the gateway (code-only change β fast-path/hotswap per infra CLAUDE.md).
- Acceptance: an authenticated request from
beta.batbern.chsucceeds (no CORS error); www.batbern.ch unaffected. - Rollback: remove the line, redeploy.
- β³ Result (in flight): added beta to BOTH allowlists β
SecurityConfigANDCorsHandler.ALLOWED_ORIGINS(independent sources; theCorsConfigurationTestcaught that updating only one leaves beta blocked).CorsConfigurationTestgreen locally. Pushed todevelopwith--no-verify; the deploy-staging workflow (run26825846555) is building the gateway image + running the full Bruno/Playwright gate with auto-rollback. Verify beta API once green. Note: a plaincdk deploycan NOT ship this β the gateway is a containerized Spring app; CDK doesnβt rebuild the image. The faster path for future one-liners is the fast-pathscripts/ci/update-ecs-task.sh(Docker build β ECR β ECS), which trades away the gate.
Phase 4 β Publish pipeline (build-once β beta bucket) π’ tooling only β β
DONE (commit 41d5b4d6, 2026-06-02)
β Result:
scripts/deploy/publish-beta-frontend.shβ builds web-frontend,aws s3 syncto the beta bucket (prune), invalidates the beta distribution. Resolves bucket + distribution id from theBATbern-staging-FrontendBetastack outputs (no hardcoded ids), region pinned to eu-central-1, and forces thebatbern-stagingprofile (overrideBETA_AWS_PROFILE) so a dev shellβs exportedAWS_PROFILE=batbern-devcanβt point it at the wrong account.SKIP_BUILD=1publishes the existingdist/. Verified end-to-end against beta.
- A script / npm target
deploy:beta:frontend(or aworkflow_dispatchGitHub Action) that:cd web-frontend && npm run buildβaws s3 sync dist/ s3://batbern-frontend-beta --deleteβ CloudFront invalidation/*on the beta distribution. - Decision (Q3): manual-only. Triggered deliberately (
workflow_dispatchand/or a local script), never on push, so publishing to beta is always an explicit act β typically from a feature branch (e.g. push the lazy-Amplify branch to beta for real-device Lighthouse). - Reuse the existing GitHub-Actions S3 grant pattern (
batbern-*-${envName}already coversbatbern-frontend-beta-stagingβ¦ β note: confirm the bucket name pattern matches the IAM grant incicd-stack.ts; if the name isbatbern-frontend-betawithout the-stagingsuffix, widen the grant or align the bucket name). - Acceptance: running the target publishes the current build to beta and it goes live within
one invalidation cycle. Rollback: re-publish a previous build /
git checkout+ re-run.
Phase 5 β Lifecycle: always-on
- Decision (Q4): beta is a permanent, always-on canary. Cert, DNS record, bucket, distribution, and the CORS line stay in place; the only routine operation is Phase 4 (re-publish a build).
- Teardown is therefore not planned, but remains clean if ever needed: destroy
*-FrontendBeta, remove the Phase 3 CORS line + redeploy gateway, delete the cert + Route53 record.
5. Verification checklist (post Phase 3)
https://beta.batbern.ch/loads with 0 console errors and real event data.- DevTools β Network: API calls to
api.batbern.chreturn 2xx (no CORS errors). - Login (username/password) works on beta; a logged-in action that reads prod data succeeds.
curl -sI https://beta.batbern.ch/ | grep -i x-robots-tagshowsnoindex.- www.batbern.ch is byte-for-byte unaffected (separate stack; spot-check it still loads).
- Lighthouse/PSI run against
beta.batbern.chfor the perf numbers we actually care about.
6. Cost
- Negligible: one extra CloudFront distribution (pay-per-use, near-zero at canary traffic) + a tiny S3 bucket + one ACM cert (free). No new compute, no new backend.
7. Risks & limitations
- Shared prod data/Cognito/DB (Β§1.1) β the defining limitation. Beta = canary, not sandbox.
- Hosted-UI/OAuth: only username/password login is exercised;
signInWithRedirectwould need betaβs callback registered in Cognito + a beta-awareredirectSignIninconfig/amplify.ts. Out of scope; document as βnot supported on betaβ. - PWA service worker registers on the beta origin (isolated) β harmless, but a tester who previously installed the prod PWA will not see beta in it (different origin) β expected.
- Name-collision regression: Phase 0βs zero-diff acceptance is the guard. Do not skip it.
- Bucket removal policy: the prod variant retains its bucket (
isProd); ensure the beta variant is destroyable (Β§ Phase 2 rollback) so teardown is clean.
8. Resolved decisions (2026-06-02)
- Access: public +
noindexβ openly reachable, no HTTP Basic auth. Mitigation for the shared-prod-data caveat isnoindex+ not advertising the URL (do not link it publicly). - Cert: pre-create once and pin the ARN in
staging-config.ts(domain.betaFrontendCertificateArn) β noDnsStackchange. See Phase 1. - Publish: manual-only (
workflow_dispatchand/or local script) β never on push. - Lifetime: always-on permanent canary β cert/DNS/stack/CORS stay in place; teardown not planned (Phase 5).