~6 min readgrounded in apps/web/lib/config.ts · lib/public-config.ts · app/api/auth/route.ts · app/api/auth/webauthn/*/route.ts · lib/auth.ts · apps/worker/wrangler.toml · apps/worker/src/{site,firmware,push}.ts · docs/DEPLOY.md
Custom domain·
Vercel does the DNS and the certificate. What this page covers is everything in this project that has the old origin written into it — because a deployment that works on <project>.vercel.app and breaks on app.example.com almost always broke at one of the five places below, not at DNS.
What depends on the origin·
| Where | Reads | Breaks if stale |
|---|---|---|
Vercel env NEXT_PUBLIC_APP_URL |
appUrl() in lib/config.ts |
share links, the per-tiny PWA manifest, OG cards, the .mobileconfig for UDID enrollment, the host shown in UI prose, robots/sitemap |
| GitHub OAuth App → Authorization callback URL | GitHub, not the code — /api/auth sends no redirect_uri, so GitHub returns to whatever the App has registered |
sign-in: GitHub redirects to the old host, which sets a cookie for the old host |
Worker var APP_URL (wrangler.toml [vars]) |
siteUrl(env) in apps/worker/src/site.ts |
links the worker writes itself — vcard/QR, DM and push copy, the default VAPID subject mailto:admin@<host>, and the firmware-pointer host allowlist (firmwareHosts: only APP_URL's host and WORKER_URL's may be named in POST /api/firmware/manifest) |
The tiny_session cookie |
Path=/; HttpOnly; Secure; SameSite=Lax, no Domain |
nothing breaks — but a cookie set on the old host is not sent to the new one, so everyone signs in again once |
| Passkeys (WebAuthn) | rpID = the request's Host; expectedOrigin = the request's Origin |
credentials registered on the old host do not verify on the new one — see below |
The worker's own origin (TINY_WORKER_URL, NEXT_PUBLIC_TINY_WORKER_URL, worker WORKER_URL) is a separate decision; you can move the app and leave the worker on workers.dev. The last section says why you usually should.
Order of operations·
Do these in order; each step leaves the site working.
1 · Add the domain in Vercel·
Follow the DNS instructions it prints (a CNAME to cname.vercel-dns.com, or Vercel nameservers). Wait for the certificate — npx vercel domains inspect app.example.com shows it. Both hosts serve the same deployment from here on; nothing has changed for users yet.
2 · Add the new callback to the GitHub OAuth App·
GitHub → Settings → Developer settings → OAuth Apps → your app → Authorization callback URL → https://app.example.com/api/auth.
One GitHub OAuth App holds one callback URL. During the switch, either point it at the new host now (sign-in on the old host stops working from this moment) or create a second OAuth App for the new host and swap GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET in step 3. The first is simpler; the second has no downtime.
3 · Update the app's env and redeploy·
printf '%s' https://app.example.com | npx vercel env add NEXT_PUBLIC_APP_URL production --force
# only if you created a second OAuth App in step 2:
# npx vercel env add GITHUB_CLIENT_ID production --force / GITHUB_CLIENT_SECRET …
npx vercel --prod
NEXT_PUBLIC_* values are baked at build time — a redeploy is required, not just a variable change. Check https://app.example.com/api/health → appUrlConfigured: true.
4 · Update the worker's APP_URL·
In apps/worker/wrangler.toml:
then npx wrangler deploy from apps/worker. If any firmware channel points at a media URL on the app's host, re-publish it — the allowlist now names the new host.
5 · Redirect the old host·
In Vercel → project → Domains, set <project>.vercel.app to redirect to app.example.com (308). Old share links and bookmarks keep working, and the old host stops minting sessions for itself.
Passkeys are bound to the host·
WebAuthn scopes a credential to the relying-party id. This project derives rpID from the request's Host header (rpFrom() in the register/login routes), so a passkey created on <project>.vercel.app has rpID = <project>.vercel.app and will not be offered by the authenticator on app.example.com — the browser will not even show it.
What that means for the switch:
- Nobody is locked out. GitHub sign-in still works on the new host (after step 2), and a signed-in user can enroll a new passkey from the avatar menu (add passkey). The old credential rows stay in D1 and are simply never matched.
- Tell users in whatever channel you have that passkeys need re-enrolling once. There is no migration that moves an rpID; that is the security model, not a gap.
- Pick the host you will keep before anyone enrolls a passkey. If you are still deciding on a domain, leave add passkey alone until you have.
Subdomains: a credential with rpID = example.com is valid on app.example.com too, but this project always uses the full request host as the id. If you want an apex-scoped rpID (so app. and www. share passkeys), change rpFrom() to return your apex for both rpID and the expectedRPID check — it is one function, used by both routes.
Moving the worker too·
You can put the worker behind api.example.com with a Cloudflare custom domain:
# apps/worker/wrangler.toml
routes = [{ pattern = "api.example.com", custom_domain = true }]
[vars]
WORKER_URL = "https://api.example.com"
then set TINY_WORKER_URL and NEXT_PUBLIC_TINY_WORKER_URL on Vercel to the same value and redeploy both. Before you do, know what is written down with the old worker origin:
- Media URLs in stored histories, DMs and archives are absolute (
https://<worker>/media/<uuid>.png) — they keep working only while the oldworkers.devhost still serves the worker. A custom domain adds a hostname; it does not removeworkers.devunless you disable it, so leave it enabled. - Device replies carrying images are checked against the worker origin the app knows (
MEDIA_ORIGINSinlib/chat/tools/platform.ts). A daemon that uploaded to the old host and replies with that URL is refused after the app switches — restart daemons after the change so they read the newTINY_WORKER_URL. - Firmware pointers may name
WORKER_URL's host — re-publish any that point at the old one.
For most deployments the worker's address is not user-visible, so the honest recommendation is: move the app, leave the worker on workers.dev.
Verify·
curl -s https://app.example.com/api/health # appUrlConfigured:true
curl -sI https://app.example.com/api/auth | grep -i location # …github.com/login/oauth/authorize…
curl -sI https://<project>.vercel.app/ | grep -iE "^(HTTP|location)" # 308 → app.example.com
Then sign in with GitHub on the new host, open the avatar menu → add passkey, sign out and sign back in with it.