Deploy your site & connect a domain
Nukipa runs the marketing layer (CMS, leads, CRM, newsletters, analytics) behind your website. The website itself can live wherever you want. This article covers the two ends of getting it live: deploying the site and connecting a custom domain, plus the failure states you'll actually hit while doing it.
There are two surfaces Nukipa can put on a domain, and they're served by different infrastructure:
| Surface | What it is | Project | tenant_domains.target |
|---|---|---|---|
| Blog | The multi-tenant Nuxt app (apps/public) that renders your published posts |
nukipa-public |
blog |
| Site | A per-tenant app (your Next.js marketing site) the deployer provisions | nukipa-<slug> (Vercel) |
site |
Most of this article is about the site. The blog is already live on your platform subdomain (<slug>.<platform-host>) the moment you publish a post; you only touch it when you want the blog on a custom domain too.
[!NOTE] The platform host is environment-driven (
PUBLIC_PLATFORM_HOSTS, defaultnukipa.com,localhost). Production usesnukipa.com; dev/staging differ. Examples below usenukipa.comfor the blog subdomain andnukipa.iofor the site host (PLATFORM_APEX) because that's what production runs — but neither is hardcoded.
How a site goes live
There are two ways your site gets deployed.
1. You host it yourself. If you keep the site in your own repo and deploy it on your own Vercel/Netlify/whatever, Nukipa isn't in the deploy path at all. It only needs to reach the gateway at runtime — see host→tenant resolution below. Skip the deployer section.
2. The platform deployer. If you connect a GitHub repo through Nukipa, the deployer service provisions and builds a Vercel project for you. The trigger is connecting the repo:
POST /github/connect → sets tenants.github_repo
(nukipa_connect_github_repo) → enqueues the `deploy-tenant-site` pg-boss job
The enqueue is best-effort: if pg-boss is unavailable (dev without PGBOSS_DATABASE_URL, or a transient outage), the connect call still succeeds — the job just isn't queued, and you re-fire it from the dashboard's redeploy button.
The deploy-tenant-site worker then reconciles your Vercel project. It's idempotent — re-running on the same tenant is safe and no-ops past project creation, except when you've changed tenants.github_repo, in which case it does real work (relink + redeploy from the new repo). In order, it:
- Loads the tenant. If there's no
github_repo, it stops (nothing to deploy). - Upserts a
tenant_deploymentsrow withstatus='pending'. - Ensures a Vercel project named
nukipa-<slug>exists, linked to your currentgithub_repo. If you changetenants.github_repoafter the first deploy, it re-links the existing project (parseRepoUrlcompares owner/repo case-insensitively) so the next build comes from the new repo. - Sets two env vars on the project:
NUKIPA_GATEWAY_URLandNUKIPA_TENANT_HOST. See the idempotency note below. - Attaches the project's platform domain:
<slug>.<PLATFORM_SITES_HOST>, which derives fromsites.<PLATFORM_APEX>(e.g.<slug>.sites.nukipa.ioin production). This is deliberately kept off the Fly-owned*.<PLATFORM_APEX>wildcard that Edge claims for blog tenants, so site and blog tenants don't collide on DNS. Vercel issues the cert — provided the operator-owned Cloudflare wildcard CNAME already resolves (see the prerequisite note). - Explicitly triggers a production deployment. This step matters: linking the repo at project-create time only registers the source for future pushes. Commits already on the branch don't auto-build. Without the explicit trigger, a freshly-pushed repo sits idle and the row hangs in
buildingforever. - Updates
tenant_deploymentstostatus='building'with the deployment URL.
A Vercel webhook flips the row to ready or failed when the build finishes (tenant_deployments.status allows pending | building | ready | failed). On any step failure the worker writes status='failed' and the error message (truncated to 1000 chars) onto the row, then re-throws so pg-boss records the failure and can retry.
[!NOTE] DNS prerequisite (operator-owned). Cloudflare must already have a wildcard CNAME
*.<PLATFORM_SITES_HOST> → cname.vercel-dns.comfor Vercel's cert to validate. The deployer doesn't create this — it's a one-time platform setup step, not something the deploy job guarantees.
[!NOTE]
NUKIPA_TENANT_HOSTis set to the platform domain (<slug>.sites.…), not your eventual custom domain. That's intentional: it's a fallback host for contexts that have no request headers (build time, edge) — not the host your live site identifies as. It's also the source of one of the gotchas below.
[!WARNING] Step 6 needs the GitHub repo's numeric id (
project.link.repoId), which Vercel only records when its GitHub App can see the repo. If that's missing, the worker fails early with a clear message: install Vercel's GitHub App on the org that owns the repo, grant it access to that specific repo, and re-run. A "Vercel did not start a deployment" failure means the same thing — the App can't see the repo, or the branch has no commits.
[!TIP] Env vars after a re-deploy. The deployer sets
NUKIPA_GATEWAY_URLandNUKIPA_TENANT_HOSTon first creation and does not overwrite them on re-runs — but the mechanism is specific: it calls Vercel's add-env API and only swallows the error when it matches an already-exists signature (HTTP 409, an*already_exists*error code, or an "already exists" message). Any other failure (auth, quota) re-throws and fails the deploy. Two edge cases: an env whose value resolves empty is skipped entirely — so ifNUKIPA_GATEWAY_URLcomes back undefined (it falls back throughPUBLIC_BASE_URL, which can be unset), it's silently never set. And to change either value later, edit it in Vercel directly; re-running the job won't.
Host → tenant resolution
Every public request — whether it hits the blog or your site — carries a Host header, and a resolver maps that host to a tenant. There are two resolver implementations with different behaviour; which one runs depends on what you're calling.
Public reads (/public/v1/tenant, posts, forms) — exact host, no www-toggle. This is the resolver in services/cms/src/modules/public/service.js. It checks two paths, in order:
- Custom domain. A
tenant_domainsrow whosedomainexactly equals the hostname (.eq('domain', hostname)) and hasverified_at IS NOT NULL. Nowww.toggling —www.example.comandexample.comare looked up literally, not interchangeably. It also returns the per-domaingoogle_verification_token/google_verification_statusoff the matched row (see GSC below). - Platform subdomain.
<slug>.<platformHost>, whereslugis matched againsttenants.slug. The host list comes fromPUBLIC_PLATFORM_HOSTS(comma-separated, defaults tonukipa.com,localhost). The slug must be a single label —a.b.nukipa.comwon't resolve.
This resolver does not filter on target — the blog (apps/public, target=blog) and the Next.js site (target=site) both call /public/v1/tenant to get their tenant id and verification token, and filtering out site rows would stall GSC verification for sites. The blog-vs-site split is applied downstream instead: link generation (publicHostForTenant, used to build a post's public URL) filters to target IN ('blog','both') — site-only rows deliberately don't qualify there, because the blog can't serve that URL.
Ingest (visit/newsletter signals) — www-toggling. The shared resolver in packages/shared/src/tenantResolver.js builds www-toggled candidates (hostname, its apex, and www.<apex>) and matches any verified row, so www.X and X resolve to the same tenant on the ingest path. This is the one that keeps a visit from www.example.com from being dropped when the row is stored as the apex.
If neither path matches, the resolver returns null and the caller decides whether to 404 or render a marketing page.
[!WARNING] Because the read resolver is exact-match, the
www.vs apex distinction matters for what serves your site (unlike the ingest path, which toggles). Combined with domain normalisation below, this has a real edge — covered in the custom-domain section.
How your site app picks the host it sends
Your Next.js site app resolves the host it tells the gateway about. The chain that ships in the starter's src/lib/nukipa.ts is the actual visitor host first, then NUKIPA_TENANT_HOST as a fallback:
getHost: () => h.get('x-forwarded-host')
|| h.get('host')
|| TENANT_HOST // NUKIPA_TENANT_HOST — fallback only
|| ''
The @nukipa/site-sdk client itself doesn't contain this priority logic — getHost is a callback you inject, and this is the implementation the starter injects. There is no dev-only host override variable; in local dev, localhost doesn't resolve to any tenant, so you pin a real host by setting NUKIPA_TENANT_HOST (read as TENANT_HOST) to a value the gateway can resolve.
[!WARNING]
NUKIPA_TENANT_HOSTmust stay a fallback, not a hard override. If a custom client lets the env var win unconditionally, then when Google crawls your custom domain the app still asks the gateway about<slug>.sites.nukipa.io. The resolver takes the subdomain path, which carries nogoogle_verification_token, the verification meta tag renders empty, and Google reports "verification token could not be found on your site." If you write your own client, resolve the real request host (x-forwarded-host/host) first.
Connecting a custom domain (site)
Custom domains for the site are managed through the deployer (/api/deployer/domains or its MCP equivalent). The flow:
- POST the domain to
/api/deployer/domains. It's normalised first — lowercased, protocol/path/port stripped, leadingwww.removed. - The service attaches it to your tenant's Vercel site project (
tenant_deployments.vercel_project_id). - Vercel returns the DNS challenge; the service writes a
tenant_domainsrow withtarget='site',vercel_status, and adns_recordssnapshot. - You create those records at your DNS provider.
- The UI polls
GET /api/deployer/domains/:id(orPOST …/verify). Each call re-reads Vercel and flipsverified_at+vercel_status='available'once DNS resolves and the cert issues.
The DNS records you need depend on whether it's an apex or a subdomain:
| Domain type | Record | Name | Value |
|---|---|---|---|
Apex (example.com) |
A |
the domain | 76.76.21.21 |
Subdomain (www. / blog.) |
CNAME |
the domain | cname.vercel-dns.com |
Vercel sometimes recommends a region-specific CNAME instead — when it does (recommendedCNAME rank 1), that value is surfaced in dns_records with a reason and overrides the generic one. Vercel may also demand verification records (TXT or extra CNAMEs) to prove ownership; those appear in the same list until verified=true.
[!WARNING]
www.is normalised away at attach time. If you attachwww.example.com, the stored row isexample.com. The public read resolver is exact-match and doesn't togglewww., so a visitor hittingwww.example.comwon't resolve through the read path even though you may have been told to wire awww.CNAME — the row (and what serves) is the apex. Only the ingest resolver toggleswww.. If you genuinely want the site served onwww., attach the barewww.host knowing it'll be stripped, and make sure your hosting redirectswww.→ apex (or vice versa) at the edge.
[!NOTE] Apex detection is a simple two-label heuristic (
domain.split('.').length === 2), so multi-level TLDs like.co.ukmay be mis-classified. The suggested record might be off, but Vercel accepts either route — wire the CNAME it shows you and it'll verify.
A domain is live only when both Vercel booleans are true: verified (ownership proven, typically via TXT) and configured (DNS pointing at us). The dashboard maps that pair to a single status: available when both hold, otherwise pending_verification. verified_at is the canonical "is this live?" flag.
The 409 you'll hit before the project exists
Every domain call needs a vercel_project_id. If the tenant has no tenant_deployments row with one, you get a 409:
No Vercel project yet for this tenant. Connect a GitHub repo first…
The subtlety: the project id only lands after the deploy worker creates the Vercel project, which happens asynchronously and best-effort after you connect a repo. So you can have github_repo set and still hit this 409 if the first deploy hasn't completed (or the enqueue was dropped because pg-boss was down). The fix is "connect a repo and wait for the first deploy," not just "connect a repo."
Uniqueness and the blog/site split
domain is globally unique in tenant_domains (case-insensitive citext). A domain belongs to exactly one tenant and one surface.
- Re-attaching the same domain to the same site no-ops and returns the current row.
- Attaching a domain that's already on another tenant — or already serving as a blog row — fails with 409 (and the Vercel attach is rolled back so it isn't stranded on the team).
- The site flow only ever reads, writes, and detaches
target='site'rows. It can't silently steal a domain that's pointed at the multi-tenant blog.
[!NOTE] Connecting the blog to a custom domain is a separate flow (the
nukipa-publicproject,target='blog'), done outside the deployer. Don't expect the Settings → Site domain panel to list or manage blog domains — by design it filters totarget='site'only.
On-demand revalidate webhook
When a domain first reaches verified, the service auto-populates two fields so the CMS can bust the site's ISR cache on publish/unpublish:
revalidate_webhook_url→https://<domain>/api/revalidaterevalidate_webhook_secret→ a fresh 32-char hex secret (crypto.randomBytes(16).toString('hex'))
These are written only when the row doesn't already carry a secret — re-verifies don't rotate the secret under a working deployment. The receiving site (the starter ships an /api/revalidate handler) verifies the incoming X-Nukipa-Revalidate-Secret header against its own NUKIPA_REVALIDATE_SECRET env. You set that env yourself after the first deploy (Vercel dashboard or vercel env add) — the deployer generates the secret but does not push it to the project. The secret is per-domain, so a leak on one site doesn't compromise another. If it's missing or mismatched, republishes still go live eventually on the natural ~60s ISR window — they just won't update instantly.
Google Search Console verification on custom domains
Nukipa pulls Search Analytics through a single platform service account, and proves ownership of your domain with the HTML meta-tag verification method. This is tracked on tenant_domains in columns separate from Vercel/DNS verification:
| Column | Meaning |
|---|---|
google_verification_token |
The meta content value from Google's /token endpoint |
google_verification_status |
not_registered → pending → verified (terminal-happy) / failed (terminal-sad) |
google_verified_at |
When Google first accepted the tag |
google_verification_error |
The error string when registration or verification didn't succeed |
[!IMPORTANT] GSC verification is gated on Vercel/DNS verification. The
gsc_verifyworker only loads domains whereverified_at IS NOT NULL— it never touches a row that isn't already Vercel-available. So the independence between the two systems is one-directional in practice: a domain can be Vercel-availablebut still GSC-pending/failed; Nukipa's own flow never produces a GSC-verified domain that isn't first DNS-verified. (A row could be GSC-verified independently only if it came from a property you already owned in your own Search Console — the running worker won't advance it.)
The state machine, per tick, per domain:
not_registered(or no token yet) →pending. The worker callsregisterSite(), saves the returned token, and flips topending. IfregisterSite()throws (e.g. Google's/tokencall fails), the row is written straight tofailedwith the error — it never reachespending.failedis terminal-sad: the worker no-ops on it and an operator must reset the row to retry.pending→verified. The worker callsverifySite(domain, 'META'). On success it then registers the property withaddSiteToSearchConsole()and flips toverified. Two ways this can stall:- Verify still failing: Google can't see the tag yet. The row stays
pending, the error is saved for the dashboard, and the next tick retries. This is the normal eventual-consistency wait while Google's bot crawls the freshly-rendered tag. - Verify OK but property registration failed:
verifySitesucceeds butaddSiteToSearchConsole()fails. The row stayspendingwith errorverify ok but addSite failed: …and the next tick retries only the cheaper addSite step (it doesn't redoregisterSite). This is a distinct stuck-pendingstate worth recognising by its error string.
- Verify still failing: Google can't see the tag yet. The row stays
verified/failedare terminal — the worker no-ops on both.
The token is per-domain, which is why it lives on tenant_domains and is returned through /public/v1/tenant only when the request host matches a verified custom domain.
The meta tag differs between the blog (Nuxt) and your site (Next.js)
The two surfaces render the same token through different code, with different failure modes.
Blog (Nuxt,
apps/public). Renders a raw meta tag from the resolved company object, unconditionally when the token is present:if (company.value?.google_verification_token) { metaTags.push({ name: 'google-site-verification', content: token }); }It also adds
noindex,nofollow,noarchiveon any non-custom_domainresolver source (platform subdomains stay out of the index); custom domains stay indexable.Site (Next.js). Renders it through Next's metadata API in
generateMetadata, which emits the same<meta name="google-site-verification">:return googleVerification ? { ...baseMetadata, verification: { google: googleVerification } } : baseMetadata;The token is fetched at request time via the SDK's
getTenant(). If the gateway is flaky thetry/catchswallows the error and the page renders without the tag rather than 500ing — so a transient gateway error during a Google crawl can leave verification stuck atpending.
The net output is identical (<meta name="google-site-verification" content="…"> at the document root), but the rendering path, the noindex behaviour, and the failure mode differ. Don't assume one surface's behaviour describes the other.
Common failures
[!WARNING] Empty content / blank page = the host isn't resolving to a tenant. Every public read keys off the
Hostheader. If the gateway can't map the host to a tenant, reads come back empty and the page renders with nothing. Check, in order:
- Is the request host a real tenant host?
localhostresolves to nothing — pin a resolvable host viaNUKIPA_TENANT_HOSTin dev.- For a custom domain, is
verified_atactually set, and are you hitting the exact stored host? Path 1 of the read resolver requiresverified_atand matches the hostname literally — apending_verificationdomain, or awww.variant of an apex-stored row, resolves to nothing.- For a platform subdomain, is the host in
PUBLIC_PLATFORM_HOSTS, and is the slug a single label?
[!WARNING] SSO / login wall instead of your site. Vercel's Deployment Protection (Vercel Authentication / password protection) sits in front of the deployment and intercepts every request — including Google's crawler and the GSC verification fetch. The deployer doesn't touch this setting, so if it's on for the project, turn it off in Vercel (Project → Settings → Deployment Protection) for the production domain. A site behind the SSO wall will never verify in Search Console and won't be publicly reachable.
[!WARNING] Env clobber — the
NUKIPA_TENANT_HOSToverride. Covered above: if a custom client (or an older site template) letsNUKIPA_TENANT_HOSTwin over the real request host, every gateway read is scoped to the platform subdomain. Symptom: GSC verification never advances pastpendingand the verification meta tag is empty on the custom domain even though the token exists on the row. Fix: resolve the actualx-forwarded-host/hostfirst and only fall back toNUKIPA_TENANT_HOST.
Domain shows failed after it was working. refreshDomain marks a row failed when Vercel no longer reports the domain attached to the project — typically because it was removed manually in the Vercel dashboard. Re-attach it through the Nukipa domains flow.
FAQ
Do I need the platform deployer to use Nukipa?
No. If you host the site yourself, Nukipa never deploys anything. The site just needs NUKIPA_GATEWAY_URL set and needs to send the real visitor host on its gateway reads. The deployer is only for the connect-a-GitHub-repo path.
My blog is on <slug>.nukipa.com. How do I put it on my own domain?
That's a separate flow from the site domains in this article — blog domains attach to the nukipa-public project with target='blog', done outside the deployer service. The site domain panel (Settings → Site) only manages target='site' rows.
Why is my site on <slug>.sites.nukipa.io and not <slug>.nukipa.io?
Site projects are deliberately placed under the sites. host (PLATFORM_SITES_HOST, derived from sites.<PLATFORM_APEX>) so they don't collide on DNS with the Fly-owned blog wildcard. This is the platform URL; your custom domain is what visitors use once it's verified.
I connected a repo but the domains panel still 409s — why? The Vercel project doesn't exist until the first deploy worker run finishes, and that run is enqueued asynchronously (and best-effort — a pg-boss outage drops the enqueue silently). Wait for the deployment to complete, or re-fire it from the dashboard, then retry attaching the domain.
Republished a post but the site shows the old version.
The site's ISR cache only busts instantly if the revalidate webhook is wired: the revalidate_webhook_secret on the verified domain must match the site's NUKIPA_REVALIDATE_SECRET env, which you set manually after first deploy. Without it, the change still appears on the natural ~60s ISR window.
Is GSC verification the same as the domain being "live"?
No — they're tracked in separate columns. verified_at (Vercel/DNS) means the domain serves your site. google_verification_status (GSC) means Google accepted ownership for Search Analytics. But they aren't fully independent: the GSC worker only ever runs against domains that are already Vercel-verified, so through Nukipa's flow you get DNS-verified-without-GSC (the normal transient state), never the reverse.