14 — Landing Site Deployment (smart-work-permit-landing)
Read 10-DEPLOYMENT-OVERVIEW.md first. Deploys as a static bundle to Cloudflare Pages at the apex e-safework.com (plus www.).
This is the one piece of the system with no backend dependency. It reads nothing, posts nothing, and holds no VITE_* variable. It can deploy at any time, in any order, independent of the API contract — the deploy order in 10-DEPLOYMENT-OVERVIEW.md §4 does not apply to it.
1. Prerequisites
- Cloudflare API token scoped Cloudflare Pages — Edit, plus the account ID (same pair the two frontend repos already use).
- The
e-safework.comzone on the same Cloudflare account, so the custom-domain step can write the DNS record itself. - Nothing from the backend. Do not wait on
11-backend-deployment.md.
2. Why the apex is the exception
10-DEPLOYMENT-OVERVIEW.md §2 requires the app hostnames to be subdomains of one apex, because the better-auth session cookie is issued Domain=.e-safework.com. The landing site sits on the apex, which means that cookie is sent to it on every request.
That is safe only because the page is fully static and first-party: it makes no request of its own, so there is no off-origin referrer leaking from a page that carries a session cookie. bun run check enforces this — scripts/check-landing.mjs walks the built bundle for absolute URLs and fails on any host other than the two app subdomains the page deliberately links to.
Consequence: nothing dynamic may be added to the apex later. A form post, an analytics snippet, a CDN font — each one hands a third party a request carrying the production session cookie. Anything dynamic belongs on its own subdomain.
3. Pages project
Direct-upload project, created once from the CLI. pages deploy will not create one non-interactively.
wrangler pages project create esw-landing --production-branch=main| Setting | Value |
|---|---|
| Project name | esw-landing |
| Production branch | main |
| Build | run in CI, not by Pages (direct upload) |
| Output directory | dist |
| Env vars | none — the page has no build-time configuration |
Then attach the domains: Dashboard → Workers & Pages → esw-landing → Custom domains → Set up a domain, once for e-safework.com and once for www.e-safework.com. Wrangler 4.x has no pages domain subcommand; this step is dashboard-only. Cloudflare writes the DNS records itself while the zone is on the same account.
Canonical host is the apex. www is attached only so the redirect below has something to attach to. Add a Redirect Rule (Dashboard → the zone → Rules → Redirect Rules): hostname equals www.e-safework.com → dynamic redirect to concat("https://e-safework.com", http.request.uri.path), 301, preserve query string. Two hosts both answering 200 is a duplicate-content and a cookie-scope surprise, not a convenience.
4. SPA fallback — optional here
The landing is a single page with no router. public/_redirects with /* /index.html 200 is not required, unlike the contractor app (12-… §4). Leaving it off means a typo'd path gets Cloudflare's 404 instead of the marketing page rendering under a wrong URL — which is the more honest answer for a static site. Add it only if the page grows real routes.
5. Verification gate — bun run check
The landing repo's own gate, and the thing CI runs. It is four steps:
bun run build # vue-tsc --noEmit -p tsconfig.app.json && vite build → dist/
bun run build:ssr # vite build --ssr scripts/ssr-entry.ts → dist-ssr/ (gitignored)
bun scripts/check-render.mjs
bun scripts/check-landing.mjscheck-render.mjsrenders the real component tree to a string throughdist-ssr/ssr-entry.jsand asserts on structure and content. It stands in for opening the page — no browser is involved. It does not check layout: a broken grid or a bad breakpoint passes. A human still looks at the page.check-landing.mjsasserts the two gates the build cannot fail on: first-party only (§2 above) and WCAG AA contrast on the enumerated text-on-surface pairs. The surface scale's text range starts too light —surface-600is 2.5:1 andsurface-700is 3.1:1 on white — and the safety app already fails its own build on sub-AA text. Adding a new text-on-surface combination to a component means adding its row to that script.
dist-ssr/ is a prerender assertion target, not a deploy artifact. Only dist/ is uploaded.
There is no lint step (no lint config in the repo) and no test:unit, and there is deliberately no check-contract-sync.mjs — the landing consumes no API.
6. CI/CD — .github/workflows/deploy.yml
wrangler is invoked directly rather than through cloudflare/wrangler-action, for the same reason as the two frontends: the action swallows wrangler's stderr and reports only The process failed with exit code 1.
name: Deploy Landing
on:
push: { branches: [main] }
pull_request:
concurrency:
group: landing-${{ github.ref }}
cancel-in-progress: true
jobs:
build-deploy:
runs-on: ubuntu-latest
permissions: { contents: read, deployments: write }
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with: { bun-version: 1.3.10 }
- run: bun install --frozen-lockfile
- run: bun run check # build + build:ssr + render + first-party/AA gates
- run: bunx wrangler@4 pages deploy dist --project-name=esw-landing
--branch=${{ github.head_ref || github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}bun run check already runs vite build, so there is no separate build step — a second one would just rebuild dist/ identically.
7. PR previews — no decision to make
Previews land on https://<hash>.esw-landing.pages.dev. Unlike the two app repos (12-… §7), this is harmless: the page has no API origin to be refused by, and no login to fail. Previews are visual-only by nature. CORS_ORIGIN stays untouched.
8. Secrets
| Repo | Name | Kind | Notes |
|---|---|---|---|
| landing | CF_API_TOKEN | secret | Scope: Cloudflare Pages — Edit. Same value as the frontends |
| landing | CF_ACCOUNT_ID | secret | Same value, added per repo |
No VITE_APP_API_URL. If someone adds one, §2 is being violated.
9. Verification
After the first production deploy:
https://e-safework.comrenders.https://www.e-safework.com301s to it, path preserved.- DevTools → Network, reload with cache disabled: every request is same-origin. One off-origin host means
check-landing.mjswas bypassed or a new one slipped past its allowlist. - The two outbound app links resolve —
https://app.e-safework.comandhttps://safety.e-safework.com. - Narrow-viewport pass at 375px, then 768px and 1280px. Nothing in
bun run checkcovers layout. curl -sI https://e-safework.com | grep -i set-cookiereturns nothing. The apex must never set a cookie of its own.
10. Rollback
Pages keeps every deployment. Dashboard → esw-landing → Deployments → … → Rollback. Instant, no rebuild. The landing has no contract with anything, so nothing else rolls back with it.
11. First-deploy runbook
Two repos. Every command below names which. The docs repo is the umbrella (work-permit/app); the landing is a separate remote (redcats002/smart-work-permit-landing) that the umbrella gitignores.
Steps 1–5 are scriptable. Step 6 is dashboard-only — verified: wrangler 4.127.1 has no wrangler pages domain subcommand.
1 — Workflow file (repo: smart-work-permit-landing)
mkdir -p .github/workflowsWrite .github/workflows/deploy.yml with the content in §6.
2 — Run the gate locally first
bun install
bun run check # build + build:ssr + render + first-party + WCAG AAA red gate here is a red pipeline there. Fix it before pushing, not after.
3 — Create the Pages project
wrangler login is interactive:
bunx wrangler@4 login
bunx wrangler@4 pages project create esw-landing --production-branch=mainNon-interactive equivalent:
CLOUDFLARE_API_TOKEN=<token> CLOUDFLARE_ACCOUNT_ID=<id> \
bunx wrangler@4 pages project create esw-landing --production-branch=mainA 403 here means the token lacks account scope, not that the project name is taken. The Cloudflare Pages — Edit token may not carry account-list permission.
4 — Repo secrets (repo: smart-work-permit-landing)
gh secret set CF_API_TOKEN --repo redcats002/smart-work-permit-landing
gh secret set CF_ACCOUNT_ID --repo redcats002/smart-work-permit-landing5 — First deploy
git add .github/workflows/deploy.yml
git commit -m "ci: deploy landing to Cloudflare Pages"
git push origin main
gh run watch --repo redcats002/smart-work-permit-landing6 — Attach the domains (dashboard)
Dashboard → Workers & Pages → esw-landing → Custom domains → Set up a domain, twice:
e-safework.com— canonicalwww.e-safework.com
Then the zone → Rules → Redirect Rules: hostname equals www.e-safework.com → dynamic redirect to concat("https://e-safework.com", http.request.uri.path), 301, preserve query string.
7 — Smoke test
curl -sI https://e-safework.com | head -1
curl -sI https://www.e-safework.com | grep -i '^location'
curl -sI https://e-safework.com | grep -i set-cookie # must return NOTHINGThen the manual pass in §9 — off-origin requests, the two app links, and the three viewports. Nothing in bun run check covers layout.
12. Cross-references
| Question | File |
|---|---|
| Topology, hostnames, secrets inventory | 10-DEPLOYMENT-OVERVIEW.md |
| All five pipelines side by side | ../guide/ci-cd.md |
| Contractor web deploy | 12-contractor-web-deployment.md |
| Safety/Inspector web deploy | 13-safety-inspector-web-deployment.md |