marketing-domain-split.md9.5 KBView on GitHub
# Marketing domain split — landing page to `cedarcopilot.com`

Move the marketing site to the apex domain and leave `mail.cedarcopilot.com` as the app.

## Current state

> **Hosting is fully on AWS. DNS is not.** The app migration moved every serving surface to
> CloudFront/S3 — `mail`, `api.mail`, `mail-staging` and `api.mail-staging` all answer with
> `server: AmazonS3` / `via: …cloudfront.net` and no `cf-ray`, so Cloudflare is nowhere in
> the request path. What stayed behind is the **DNS zone**: the domain is registered at
> Porkbun but delegated to `ethan.ns.cloudflare.com` / `savanna.ns.cloudflare.com`, and the
> apex record is still proxied (orange-cloud) with a redirect rule attached. That rule is
> the only reason the apex serves nothing, and it lives in the Cloudflare dashboard.

- `cedarcopilot.com` — Cloudflare-proxied (`104.26.x.x`) and **301s to
  `https://mail.cedarcopilot.com/`**. The redirect is generated at Cloudflare's edge, not by
  an origin — the response body is Cloudflare's own error page
  (`<hr><center>cloudflare</center>`), so nothing in this repo or in AWS produces it. It is a
  Redirect Rule / Page Rule / Bulk Redirect on the zone.
- `docs.cedarcopilot.com` — resolves to the same Cloudflare-proxied IPs, so the apex is not
  the only record still riding the proxy. Check it before making zone-wide changes.
- `www.cedarcopilot.com` — does not resolve.
- `mail.cedarcopilot.com` — CloudFront `E2XFZ6WD1M2YYX` (`d3dcq98iblzth4.cloudfront.net`),
  serving the React Router SPA out of S3. Aliases: `mail.cedarcopilot.com`,
  `api.mail.cedarcopilot.com`. Cert: `arn:aws:acm:us-east-1:619071350399:certificate/7207eff2-28e5-4260-954f-89db621e69b2`
  (covers `mail.cedarcopilot.com` only — **not** the apex).
- One bundle serves both surfaces: `/` renders the landing page when logged out and
  redirects to `/home` when logged in.

## Target state

Both hostnames keep pointing at the **same** CloudFront distribution and the **same**
bundle. Nothing is forked or duplicated. Which half of the site each origin serves is
decided in the client:

| Origin | Serves | Anything else |
| --- | --- | --- |
| `cedarcopilot.com` | landing, `/pricing`, `/blog`, `/bookdemo`, `/legal/*`, `/roadmap`, `/hr` | → `mail.cedarcopilot.com` |
| `www.cedarcopilot.com` | same as apex | — |
| `mail.cedarcopilot.com` | the signed-in app | marketing paths → apex; `/` → `/home` signed in, → apex signed out |

## Code (already merged — inert until step 5)

- `apps/mail/lib/marketing-host.ts` — the single gate. Every helper reports "not split"
  and all routing behaves exactly as today **until `VITE_PUBLIC_MARKETING_URL` names a
  host that differs from `VITE_PUBLIC_APP_URL`**.
- `apps/mail/hooks/use-host-routing.ts` — called from `app/root.tsx`; bounces a path that
  lands on the wrong origin to the right one.
- `apps/mail/app/page.tsx` — on the app host, `/` no longer renders the landing page: a
  signed-in visitor goes to `/home`, a signed-out one is handed to the apex, which is the
  front door now. (Electron still goes to `/login` — the desktop app has no website half.)
  Sign-in is not stranded: the apex's Login button points back at `mail.*/login`.
- `apps/mail/app/(full-width)/navigation.tsx` — the Login button hands off to the app
  origin when clicked on the marketing origin, so OAuth runs on the origin that will hold
  the session cookie.
- `aws/lib/config.ts` + `aws/lib/stacks/app-stack.ts` — `CEDAR_AWS_PROD_MARKETING_DOMAINS`
  (comma-separated) appends extra aliases to the frontend distribution.
- `apps/mail/lib/site-config.ts` — `CANONICAL_ORIGIN` follows `VITE_PUBLIC_MARKETING_URL`,
  falling back to the app URL. It deliberately does **not** point at the apex before the
  cutover: a `rel=canonical` aimed at a host that 301s back here is a canonical loop.

## Cutover

### 1. Request a certificate covering the apex (us-east-1)

CloudFront only reads certs from `us-east-1`.

```bash
aws acm request-certificate \
  --region us-east-1 \
  --domain-name cedarcopilot.com \
  --subject-alternative-names www.cedarcopilot.com \
  --validation-method DNS \
  --key-algorithm RSA_2048
```

Then read the validation records to add to Cloudflare:

```bash
aws acm describe-certificate --region us-east-1 --certificate-arn <NEW_ARN> \
  --query 'Certificate.DomainValidationOptions[].ResourceRecord'
```

### 2. Validate in Cloudflare

Add each returned `CNAME` (name → value) in the `cedarcopilot.com` zone as **DNS-only
(grey cloud)** — proxied validation records do not resolve for ACM. Wait for
`Certificate.Status == ISSUED`.

### 3. Attach the apex to the distribution

The aliases are defaulted in the deploy workflows — there is no variable to set:

```
CEDAR_AWS_PROD_MARKETING_DOMAINS  defaults to  cedarcopilot.com,www.cedarcopilot.com
```

The certificate is the exception and **must** be set explicitly:

```
CEDAR_AWS_PROD_CERTIFICATE_ARN=<a cert covering mail.*, api.mail.*, apex and www>
```

Do not rely on `cedar_certificate_arn_for_domain` to find it. That helper matches on ACM's
`DomainName`, and the old `mail.cedarcopilot.com`-only certificate is still ISSUED with
exactly that `DomainName` — so an automatic lookup keyed on the app domain returns the
**old** cert, which does not cover the apex, and CloudFront rejects the new aliases.

> The distribution takes **one** cert for all aliases. Either request the cert in step 1
> with all four names, or replace the existing one. Do not expect two certs to coexist.

Verify before deploying: `cd aws && CEDAR_AWS_ENV=prod pnpm run synth:prod`.

### 4. Point the apex at CloudFront

In Cloudflare, `cedarcopilot.com` zone:

1. **Delete the existing apex → `mail.cedarcopilot.com` redirect rule.** While it exists
   the apex can never serve content. This is the step that actually flips the site.
2. `cedarcopilot.com` → `CNAME` to `d3dcq98iblzth4.cloudfront.net` (CNAME flattening
   handles the apex).
3. `www.cedarcopilot.com` → `CNAME` to `d3dcq98iblzth4.cloudfront.net`.

Proxied (orange) works — CloudFront accepts the request because the Host is now an alias.
**Prefer grey cloud (DNS-only) for both records.** While the apex is proxied, Cloudflare
serves its own *managed* `robots.txt` at that hostname — it answers `200` at the edge and
CloudFront never sees the request, so the app's generated `robots.txt` (see
`apps/mail/vite.config.ts`) would be shadowed the moment the apex starts serving the
landing page. The managed file blocks nine training crawlers (`GPTBot`, `ClaudeBot`,
`Google-Extended`, `CCBot`, `Bytespider`, `Amazonbot`, `Applebot-Extended`,
`meta-externalagent`, `CloudflareBrowserRenderingCrawler`) and sets
`Content-Signal: ai-train=no`. It does **not** block `Googlebot`, `PerplexityBot`,
`OAI-SearchBot` or `Claude-SearchBot`, so search and AI-answer retrieval keep working
either way — but the app's own `Disallow` list for authenticated routes would stop being
served on that origin, which is the part that matters. If the apex must stay proxied, turn
off the managed `robots.txt` in the Cloudflare dashboard first and confirm
`curl -s https://cedarcopilot.com/robots.txt` returns the app's version.

### 5. Flip the frontend build

`publish-frontend-assets.sh` derives the marketing origin from
`cedar_marketing_url_for_env` (prod → `https://cedarcopilot.com`, staging → empty), so the
flip is carried by the code rather than by a deploy variable. Redeploying the prod frontend
**is** the switch.

That makes merge order load-bearing, and it is the one hazard this design trades for the
lost variable. Land the frontend half of this change (`deploy-utils.sh`,
`publish-frontend-assets.sh`) **only after step 4 resolves**. Merge it earlier and the next
prod frontend deploy — including one shipped for an unrelated reason — starts bouncing
marketing paths to an apex that still 301s back, which is a redirect loop on `/pricing`,
`/blog` and `/bookdemo`.

Land the alias half (the workflow defaults, step 3) whenever you like: extra CloudFront
aliases are inert until DNS points at them.

### 6. Verify

```bash
curl -sI https://cedarcopilot.com/            # 200, the landing page
curl -sI https://www.cedarcopilot.com/        # reaches the marketing site
curl -sI https://mail.cedarcopilot.com/       # SPA shell; the client sends / to /home or the apex
curl -s  https://cedarcopilot.com/ | grep -o '<title>[^<]*'
curl -s  https://cedarcopilot.com/robots.txt | head -5
```

In a browser: `mail.cedarcopilot.com/pricing` should land on `cedarcopilot.com/pricing`,
and `cedarcopilot.com/home` should land on `mail.cedarcopilot.com/home` still signed in.
Signed out (or in a private window), `mail.cedarcopilot.com` should land on
`cedarcopilot.com`, and its Login button should come back to `mail.cedarcopilot.com/login`.

### 7. Search Console

- Add `cedarcopilot.com` as a property; keep `mail.cedarcopilot.com` so the redirects are
  observed rather than treated as dropped pages.
- Request re-indexing of `/`, `/pricing`, `/bookdemo`.
- The old `mail.cedarcopilot.com` marketing URLs are client-side redirects, which Google
  follows but weights less than a 301. If ranking transfer matters, move the redirect for
  marketing paths into a CloudFront Function (viewer-request, keyed on the `Host` header)
  so it becomes a real 301.

## Rollback

Point `cedar_marketing_url_for_env` at the app origin (or return empty, as staging does)
and redeploy the frontend — `VITE_PUBLIC_MARKETING_URL` still wins if it is set in the
environment, so an urgent rollback can also be done without a commit. Both origins
immediately go back to serving the whole site with no redirects; DNS and certs can stay in
place.