# Custom Domain

> Point a custom domain at an already-deployed site, end to end: add the domain at the host, create the DNS records at the registrar, fix the settings that break silently when the address changes (auth authorized domains, OAuth redirect URIs, captcha/App Check domain lists, CORS and CSP allowlists), move the site's canonical URL, wait out certificate issuance, and add the www redirect. Use this whenever the user says they bought, registered, or own a domain; whenever they ask to hook up, connect, point, attach, set up, or "use my own" domain, URL, or address for a site; whenever they mention DNS records, nameservers, A/CNAME/TXT records, SSL or HTTPS not working on a new domain, or a domain stuck "pending", "not verified", or "needs setup"; and whenever a site currently lives on a platform subdomain (*.web.app, *.pages.dev, *.vercel.app, *.netlify.app, *.github.io) and they want it somewhere nicer. Also use it when a domain "works but login broke" or "shows the wrong URL in Google" after a move.

- Skill: `nt11111/custom-domain` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add nt11111/custom-domain`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nt11111/custom-domain/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: nt11111 (https://skillmd.com/u/nt11111)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nt11111/custom-domain

---


# Custom Domain

Pointing a domain at a site is four minutes of real work wrapped in up to
twenty-four hours of waiting, and the waiting is where people break things. The
DNS is usually right long before the host admits it. Someone who doesn't know
that will "fix" correct records three times, enable a proxy setting that stops the host
verifying the domain at all, and end up further from working than when they
started.

So the job here is mostly about **order and patience**, plus one category of
failure that is genuinely nasty: settings elsewhere in the stack that were
keyed to the old address and now silently refuse to work. The site loads fine.
Login doesn't. Nothing logs an error that says why.

## The core mindset

**The host tells you the records. Never guess them.** Every platform has its
own values and they change. Add the domain in the host's dashboard first and
copy what it gives you.

**Verify DNS yourself before you believe any dashboard.** `dig` against two
public resolvers is ground truth. A host saying "not detected" while `dig`
returns the right answer means *wait*, not *change something*.

**Assume something else in the stack knows the old URL.** Auth providers,
captcha keys, OAuth apps, CORS allowlists, webhooks, email templates, CSP
headers. Each keeps its own list of "domains I trust". Phase 3 is the one
people skip and it is the one that produces "the site works but I can't log in".

**Don't move the canonical URL until HTTPS actually serves.** Telling search
engines the real address is `https://newdomain.com` while that host has no
certificate is worse than leaving it pointed at the old one for another hour.

## Phase 0 — Find out what is actually moving

Before touching anything, establish these. Most can be read from the repo; ask
only for what you genuinely can't determine.

- **Which host serves the site?** Look for `firebase.json`, `wrangler.toml`,
  `vercel.json`, `netlify.toml`, a `gh-pages` branch or Pages workflow. This
  decides everything downstream — read the matching file in `references/`.
- **Where is the domain registered, and who runs DNS?** Registrar and DNS host
  can differ. What matters is which nameservers the domain actually uses:
  `dig NS <domain> +short`.
- **Is there an apex/www preference?** `example.com` or `www.example.com` as
  the real address. Pick one to serve and redirect the other; serving both is
  a duplicate-content problem.
- **Does the app authenticate users?** If yes, Phase 3 is mandatory, not
  optional.
- **Is the old URL staying alive?** Usually yes and usually free. Say so —
  people worry that existing links will die.
- **Is anything already broken?** If they're mid-attempt, run
  `scripts/check-domain.sh` first and work from real state rather than from
  their description of it.

## Phase 1 — Add the domain at the host, and copy the records it gives you

Do this before touching DNS. The host generates the exact records — often
including a one-time verification TXT unique to that site — and there is no way
to know them in advance.

Read the relevant file in `references/` for the host's exact flow. Typically
you'll get either an **A record** (apex, most Firebase/GitHub setups) or a
**CNAME** (subdomains, and most Vercel/Netlify/Cloudflare Pages setups), plus
sometimes a **TXT** proving ownership.

Record what you were given verbatim before moving on. If you're setting up both
apex and www, note that they are usually **two separate domain entries** with
two different records.

## Phase 2 — Create the DNS records at the registrar

Find out who actually runs DNS first — registrar and DNS host are often
different, and editing records at the wrong one changes nothing while appearing
to work: `dig NS <domain> +short`.

Then read the matching reference: `references/cloudflare-dns.md` if the answer
is Cloudflare (its default proxy setting stops the host from ever verifying the
domain, while the site still appears to work — a genuinely confusing failure),
otherwise `references/registrars.md` for Namecheap, GoDaddy, Squarespace,
Route 53, and the field-name differences between them.

General rules that hold everywhere:

- **Name/host field**: `@` (or blank, or the bare domain) means the apex.
  `www` means `www.example.com`. Never type the full domain into a field that
  is already scoped to the zone — that produces `example.com.example.com`.
- **Delete conflicting records first.** An existing A, AAAA, or CNAME on the
  same name will fight the new one. Parked-domain placeholders are the usual
  culprit. An AAAA record left behind is a classic cause of "works for me,
  broken for them" — IPv6 clients get the stale answer.
- **TTL**: low (300s / "automatic") while you're setting up.
- **Apex CNAME**: not legal in plain DNS. Many providers offer an equivalent
  under another name — ALIAS (Namecheap BasicDNS, DNSimple), ANAME (easyDNS),
  CNAME flattening (Cloudflare), or Alias records (Route 53, for AWS targets).
  If your host wants a CNAME at the apex, look for one of those before assuming
  you have to move DNS.

## Phase 3 — Fix everything that trusted the old address

**This is the phase that separates a working move from a mysterious one.** Each
item below fails *silently*: the page loads, and one feature is dead with no
error that names the cause. Walk the list and check each against the app.

| What breaks | Where it lives | Symptom if missed |
|---|---|---|
| **The list of origins auth will accept** | Firebase: Auth → Settings → *Authorized domains*. Supabase: Authentication → URL Configuration → *Site URL* + *Redirect URLs*. Auth0/Clerk: Allowed Callback/Logout URLs. | Site loads, sign-in popup opens and immediately closes, or the callback lands on an error page. Nothing else breaks. |
| **OAuth redirect URIs** in the Google/GitHub/Apple console | Only if your app receives the callback **directly**. If an auth provider brokers it (Supabase, Auth0, Clerk), the URI points at *their* domain and does **not** change when your site's domain does. | `redirect_uri_mismatch`. Check before editing — changing a brokered URI breaks a working setup. |
| **Captcha / bot-protection domain list** | reCAPTCHA admin console, Turnstile, App Check | Tokens fail validation. With enforcement on, every gated action fails at once. |
| **CORS allowlist** on any API the site calls | your API, or the provider's dashboard | Fetches blocked from the new origin only. |
| **CSP `connect-src` / `frame-src`** | headers or meta tag | Console CSP violations; embedded widgets and auth popups die. |
| **Webhook + callback URLs** | Stripe, payment, third-party dashboards | Events stop arriving; often unnoticed for days. |
| **Email link domains** | often the *same* field as the auth origin (Supabase's Site URL renders both) — don't hunt for a second knob that doesn't exist | Password-reset and magic links still point at the old host. |
| **Analytics property / site config** | GA, Plausible, etc. | Traffic silently stops recording. |

Treat this as a list of *failure modes*, not a list of settings: every provider
names these differently and some collapse several into one field. Find the
provider's actual vocabulary rather than searching for these exact words —
searching Firebase's terms in a Supabase dashboard finds nothing and wastes
real time.

For each one you change, confirm the setting actually persisted — several of
these consoles fail to save without saying so. Reload the page and re-read the
value.

## Phase 4 — Verify DNS before you wait on anything

Run the bundled checker:

```bash
bash scripts/check-domain.sh example.com
```

It queries multiple public resolvers, follows the HTTP→HTTPS redirect, reports
the certificate's issuer and names, and checks `www`. Use it rather than one
`dig` against the local resolver — local caches and the ISP resolver are the
least representative view of what the rest of the world sees.

**Read the result honestly.** If the records are correct from two independent
resolvers, DNS is done. Any remaining "not detected" is the host's own cached
view, and the fix is to wait. Do not start editing correct records.

## Phase 5 — Move the site's idea of its own URL, but don't ship it yet

Most sites hard-code their address somewhere: a `SITE_URL` constant, canonical
tags, `sitemap.xml`, `robots.txt`, Open Graph URLs, JSON-LD, and any absolute
links. Find them:

```bash
grep -rIn "old-host\.\(web\.app\|pages\.dev\|vercel\.app\|netlify\.app\|github\.io\)" --exclude-dir=node_modules .
```

**Also check the host's environment variables, not just the repo.** Vercel,
Netlify, and Cloudflare Pages all commonly hold the canonical URL as a
dashboard env var (`NEXT_PUBLIC_SITE_URL`, `URL`, `SITE_URL`) that no amount of
grepping the tree will surface — a fresh clone doesn't even contain the file.
Two consequences worth stating plainly: build-time-inlined vars (anything
`NEXT_PUBLIC_*`, `VITE_*`) do nothing until a **redeploy**, and that redeploy is
the same one Phase 6 tells you to hold. So change the value now, ship it later.

Framework-specific places the URL hides: `metadataBase` in a Next.js root
layout, `app/sitemap.ts` / `app/robots.ts`, `astro.config` `site:`, and any
sitemap plugin config.

Change them to the new domain, keeping the old value as a named constant if
anything still references it. **Then stop.** Build it, verify the output, and
hold the deploy until Phase 6 confirms HTTPS is live. Canonical tags pointing
at a host that serves a certificate error is the one genuinely harmful state in
this whole process.

## Phase 6 — Wait for the certificate, and don't poison the wait

Certificate issuance starts only after the host has verified DNS, and hosts
poll on their own schedule. Typical is minutes; the documented ceiling is
24 hours and it does sometimes take hours.

What's normal, and worth telling the user so they don't panic:

- `http://` redirecting to `https://` while `https://` still fails. It means
  DNS is reaching the host and only the certificate is outstanding. This is
  the *expected* intermediate state, not a problem.
- The host's dashboard lagging behind reality by hours.
- Apex and www issuing at different times when added separately.

**On `.dev`, `.app`, and `.page`, the redirect reassurance above does not
apply.** Those TLDs are HSTS-preloaded as a whole, so browsers rewrite `http://`
to `https://` before sending anything — during the wait the user gets a hard TLS
error page with no click-through, which looks far more broken than it is.
`curl` ignores preloading, so `scripts/check-domain.sh` still tells the truth;
lean on it rather than on the browser, and warn the user in advance.

The one thing that will genuinely prevent issuance forever is a proxy or
redirect intercepting the validation request. On Cloudflare that is the
orange-cloud proxy — see `references/cloudflare-dns.md`.

Poll with the checker script rather than clicking Verify repeatedly.

## Phase 7 — Add the other half of the pair

Once the primary domain serves, add `www` (or the apex, if you led with www).
Prefer a **redirect** to the canonical host over serving the site twice: two
hosts serving identical content splits SEO signal, and a redirect consolidates
it. Several hosts offer this natively when you add the second domain.

It needs its own DNS record and gets its own certificate, so expect it to trail
the first one.

## Phase 8 — Deploy and confirm

Now ship the Phase 5 changes, then verify against the live site rather than
declaring success:

- The domain serves over HTTPS with a valid certificate.
- `http://` and the www/apex counterpart both redirect to the canonical host.
- Canonical tag, `sitemap.xml`, and `robots.txt` show the new domain.
- **Sign in.** Actually complete a login if the app has one — this is the check
  that catches a missed Phase 3 item, and nothing else will.
- The old platform URL still loads, so existing links survive.

Report what's live, what's still propagating, and anything you changed in
another console that they should know about.

## Traps

**Enabling a CDN proxy on the DNS record.** Blocks certificate validation.
Cloudflare's is on by default and it nags you to turn it on. See the reference.

**Trusting a single `dig`.** Local and ISP resolvers cache aggressively. Two
public resolvers or it didn't happen.

**Leaving a stale AAAA record.** IPv6 clients silently get the old answer while
it works fine for you.

**Re-editing correct DNS because a dashboard says "not detected".** The most
common way people turn a 20-minute wait into a broken afternoon.

**Assuming an auth provider's own domain moves with your site.** Sign-in
popups and callbacks often stay on the provider's host — which is why the fix
is usually adding your new domain to their allowlist, not rewriting URLs.

**Shipping canonical URLs before the certificate exists.**

## What needs the user, not you

You can do nearly all of this. These need them:

- **Buying the domain** and any registrar-level payment.
- **Changing nameservers** at the registrar, if DNS is moving.
- **Consoles you have no session for** — hand them the exact values to paste
  and the exact page, rather than a description of where to look.
- **Deciding apex vs www** as the canonical address, if they have a preference.

## Reference files

- `references/firebase-hosting.md` — Firebase, incl. Auth authorized domains
  and App Check, the two things that break login after a move.
- `references/cloudflare-dns.md` — the proxy trap, apex flattening, and a
  reliable record-creation path when the dashboard UI resists automation.
- `references/vercel.md` — Vercel, incl. the per-project CNAME and the
  build-time env var that holds the canonical URL.
- `references/other-hosts.md` — Cloudflare Pages, GitHub Pages, Netlify.
- `references/registrars.md` — Namecheap, GoDaddy, Squarespace, Route 53,
  Porkbun, and how to tell who actually runs DNS.
- `scripts/check-domain.sh` — resolver-independent DNS, redirect, and
  certificate check. Run it instead of clicking Verify.

