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.