Skip to content

~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·

npx vercel domains add app.example.com

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:

[vars]
APP_URL = "https://app.example.com"

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 old workers.dev host still serves the worker. A custom domain adds a hostname; it does not remove workers.dev unless you disable it, so leave it enabled.
  • Device replies carrying images are checked against the worker origin the app knows (MEDIA_ORIGINS in lib/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 new TINY_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.