Skip to content

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.com zone 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.

bash
wrangler pages project create esw-landing --production-branch=main
SettingValue
Project nameesw-landing
Production branchmain
Buildrun in CI, not by Pages (direct upload)
Output directorydist
Env varsnone — 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.mjs
  • check-render.mjs renders the real component tree to a string through dist-ssr/ssr-entry.js and 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.mjs asserts 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-600 is 2.5:1 and surface-700 is 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.

yaml
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

RepoNameKindNotes
landingCF_API_TOKENsecretScope: Cloudflare Pages — Edit. Same value as the frontends
landingCF_ACCOUNT_IDsecretSame value, added per repo

No VITE_APP_API_URL. If someone adds one, §2 is being violated.

9. Verification

After the first production deploy:

  1. https://e-safework.com renders. https://www.e-safework.com 301s to it, path preserved.
  2. DevTools → Network, reload with cache disabled: every request is same-origin. One off-origin host means check-landing.mjs was bypassed or a new one slipped past its allowlist.
  3. The two outbound app links resolve — https://app.e-safework.com and https://safety.e-safework.com.
  4. Narrow-viewport pass at 375px, then 768px and 1280px. Nothing in bun run check covers layout.
  5. curl -sI https://e-safework.com | grep -i set-cookie returns nothing. The apex must never set a cookie of its own.

10. Rollback

Pages keeps every deployment. Dashboard → esw-landingDeployments → … → 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)

bash
mkdir -p .github/workflows

Write .github/workflows/deploy.yml with the content in §6.

2 — Run the gate locally first

bash
bun install
bun run check          # build + build:ssr + render + first-party + WCAG AA

A red gate here is a red pipeline there. Fix it before pushing, not after.

3 — Create the Pages project

wrangler login is interactive:

bash
bunx wrangler@4 login
bunx wrangler@4 pages project create esw-landing --production-branch=main

Non-interactive equivalent:

bash
CLOUDFLARE_API_TOKEN=<token> CLOUDFLARE_ACCOUNT_ID=<id> \
  bunx wrangler@4 pages project create esw-landing --production-branch=main

A 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)

bash
gh secret set CF_API_TOKEN  --repo redcats002/smart-work-permit-landing
gh secret set CF_ACCOUNT_ID --repo redcats002/smart-work-permit-landing

5 — First deploy

bash
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-landing

6 — Attach the domains (dashboard)

Dashboard → Workers & Pages → esw-landing → Custom domains → Set up a domain, twice:

  • e-safework.com — canonical
  • www.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

bash
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 NOTHING

Then 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

QuestionFile
Topology, hostnames, secrets inventory10-DEPLOYMENT-OVERVIEW.md
All five pipelines side by side../guide/ci-cd.md
Contractor web deploy12-contractor-web-deployment.md
Safety/Inspector web deploy13-safety-inspector-web-deployment.md