Bouée
Guides

Brand hostnames and custom domains

Give a brand's knowledge base an address of its own.

Bouée answers at one address, APP_URL, for everything: the console, the /instance pages, the API, and the whole customer side — every team's help center (APP_URL/?brand=<id>), the request form, a customer's requests and customer sign-in. APP_URL/ is where customers start; Home page in Instance settings chooses whether it lists every team or shows one brand's help center.

A brand can also give its knowledge base an address of its own, the way Zendesk maps a help center to a hostname. Reserving the slug alpha under the installation's hosted base domain gives the brand alpha.bouee.dev: one name that is both its knowledge base on the web (https://alpha.bouee.dev) and its support address (support@alpha.bouee.dev). Reserving a slug is the one act that creates both. A brand hostname serves the brand's articles and nothing else: its Contact support, My requests and sign-in links lead to APP_URL/…?brand=<id>, and every email Bouée sends links to APP_URL. Managing the brand happens on APP_URL too.

A brand without a hostname of its own, or whose hostname does not reach the installation yet, loses nothing: its knowledge base is at APP_URL/?brand=<id>, beside everything else.

A customer may additionally point a hostname they own at their brand: help.alpha.com as a CNAME to alpha.bouee.dev. That is the only place a customer hostname may point. Pointing it at APP_URL's host would land the request on a host that names no brand.

A brand's hostname keeps APP_URL's scheme and port: with APP_URL=http://203.0.113.5:3000, alpha's links are http://alpha.example.com:3000/…, because the same listener serves every name.

The base domain

The hosted base domain is the installation's, the same for every brand. First-run setup proposes the registrable part of APP_URL's host (help.acme.example.com proposes example.com) and folds the field away as a correction. It proposes nothing for a platform's shared hostname (bouee-acme.fly.dev) or an installation reached by an IP address; there it asks for the domain instead, with the preview, the suffix and the Reserve … button following what is typed.

An intranet base such as acme.internal works like any other: it.acme.internal is a brand's hostname. The proxy cannot read the configured base, so it goes by shape: only a bare name, an IP literal, or a single name under a private suffix (bouee.local, host.internal) is taken for the installation reached locally. A hosted name always has a slug in front of a base of at least two labels, so it is never mistaken for one.

The base domain cannot change once any brand has reserved an address under it. Every brand's website and mailbox live under it: a new base would stop them resolving, leave their brands unable to reserve again, and keep their old slugs taken across the installation. saveHostedMailBaseDomainAction refuses any base that would strand a reserved brand, and /instance/settings shows the base read-only. Serving brands under several base domains is separate, later work.

Reserving alpha.bouee.dev makes nothing reachable, and neither does pointing help.alpha.com at it. Until a DNS record, a proxy route and a certificate exist, a link printed there goes nowhere, or to whoever answers for the name. So knowledge base links use a brand's hostname only once the installation has proved it reaches this installation. Until then they stay on the next origin down: the hosted hostname for a customer's own, and APP_URL/?brand=<id> for the hosted one. Portal, ticket, magic-link, acknowledgment and company sign-in links, and portal sessions, never use a brand's hostname: they are always on APP_URL.

The proof (src/lib/brand-host-check.ts): the installation fetches <origin>/.well-known/bouee-host-check?challenge=<random> over the origin links would use, without following redirects, and expects bouee-host-check=<HMAC>: an HMAC of the brand, the hostname and the challenge, under a key derived from BETTER_AUTH_SECRET. Only this installation can compute it, and the challenge is fresh each time, so no cached or copied answer passes. Because the brand and the hostname are both inside the HMAC, one brand's answer never proves another brand's hostname, and a hostname's answer never proves another hostname — not even the brand's own hosted hostname, which is what a proxy that rewrites the customer's Host header to it would return. The route answers only on a hostname a brand holds, for that brand, deciding it the way the help center does: a customer's hostname DNS has verified, then a hostname the installation issued. A proxy that rewrites the Host header to APP_URL's gets a 404. The proxy allows that one path on every brand hostname, a customer's included (GET and HEAD).

The check looks the name up once, checks every address it resolves to, and connects only to those addresses, with TLS and the Host header still using the name (src/lib/http/outbound.ts). A hosted hostname may resolve to private addresses, as it does on a self-hosted network, but never to a link-local, cloud metadata (169.254.169.254 and the like), multicast or unspecified one. A customer's hostname has to point at this installation anyway, so it is connected to only at an address the brand's hosted hostname also resolves to from this server; a customer cannot aim the check anywhere else.

Each brand's BrandHostCheck row holds both proofs, each naming the hostname it was made for: hostname, verifiedAt, checkedAt and outcome for the hosted hostname, and the custom* columns for the customer's. A proof about any other hostname — a customer hostname the brand has since replaced, a hosted one issued under a base domain since dropped — proves nothing, and the next check starts that name over.

Checks run in the same 10-minute job that re-checks custom hostnames' DNS (sweepHelpCenterDomains), whenever an owner presses Check now (setup's Brand address step, Hosted address on the brand page, or Your own hostname there; the hosted address's also checks the customer's hostname, which links use only while the hosted one is live), and as soon as the server is up (src/instrumentation.ts), repeating while a bounded pass comes back full, so a deploy proves its hostnames within seconds rather than leaving links on the fallback until the first scheduled pass:

StatusMeaning
Not checked yetNo check has run.
Waiting for DNS (hosted) / Not resolving here (customer's)The name does not resolve from this server.
Not reachable yetIt resolves, but nothing answered, or the proxy answered 5xx.
Waiting for a certificateIt answered over HTTPS without a valid certificate for the name.
Not routed hereSomething answered, but not this installation: another server, a redirect, or a proxy that does not keep the Host header.
Resolves elsewhereNothing was fetched: the name resolves to an address the check does not connect to. For a customer's hostname, that is any address the brand's hosted hostname does not also have from this server; for either, a link-local, cloud metadata, multicast or unspecified address.
Live (hosted) / Answering here (customer's)It answered as this installation within the last day.

A hostname that is not live is checked on every pass. A live one proves itself again every six hours; a definitive miss (DNS, certificate, routing, resolving elsewhere) takes it out of links at once, while one that merely could not be reached keeps its last proof until it is a day old. The check runs from this server, so the server must be able to reach its own public names: without NAT loopback, add a hosts entry or split-horizon DNS for them, a customer's hostname included. A customer's hostname must resolve here to the same addresses as the brand's hosted hostname, so give it the same entry: one that resolves elsewhere reads "Resolves elsewhere" and is not used.

A customer's own hostname is used only while all three hold: DNS has proved it within the last two days (below), its own check has proved it within the last day, and the brand's hosted hostname is live — it is a CNAME to that name. It is checked over the web only once DNS has verified it, and only while the installation issues the brand's hosted hostname.

What a hosted hostname needs

Setup and the brand page list these for the exact name once it is reserved:

  1. DNS: an A record (and AAAA if the server has IPv6) for alpha.bouee.dev, pointing at the server APP_URL runs on. Not a CNAME, and not a wildcard. The same name carries the brand's mail records — Resend's receiving MX and its SPF and DKIM records — and a CNAME cannot share its name with any other record, while a wildcard such as *.bouee.dev stops covering a name as soon as that name has records of its own (RFC 4592). A wildcard that serves the website today stops serving it the moment the mail records are added.
  2. Proxy: if a reverse proxy sits in front of Bouée, a route that sends alpha.bouee.dev to this installation and keeps the original Host header. The bundled proxy needs none (below).
  3. Certificate: a TLS certificate for the name, when APP_URL is https. One for *.bouee.dev covers every brand. The bundled proxy obtains one by itself.

Add these records for me (Cloudflare) adds the website's record along with the mail records when the brand's mail lives on its hosted name and there is a reliable source for the address: APP_URL's host when it is an IP address, or APP_URL's own A/AAAA records when they are in the same zone, copied with their proxy setting. Otherwise the review says exactly which record to add by hand. It refuses to add mail records next to an existing CNAME and says why; nothing existing is ever replaced.

What each host may serve

src/proxy.ts decides this by the Host header alone, with no database lookup. On any host that is not APP_URL's — a brand's hosted hostname and a customer's own hostname alike — only the brand's knowledge base answers: / and /articles/…, /categories/…, /sections/…, /search, Next's assets under /_next/, the by-convention files, and /.well-known/bouee-host-check. GET and HEAD only, plus the browser's policy reports to /api/csp-report: the knowledge base has no forms that post.

Everything else answers on APP_URL's host only, whatever a drifting proxy configuration forwards: the portal (/requests, /sign-in, email sign-in links, company sign-in, /api/attachments/<id>), /login, /preflight, /setup, /instance/**, /workspaces, /workspace/<id>/**, /agent and the agent authentication API. The portal's pages and routes also refuse a brand's hostname themselves. Which brand a hostname means is decided where the page renders, by resolveHelpCenterContext(), which fails closed when no brand holds the name. So a site block does not need to restrict paths itself, and cannot drift away from the rule.

Registering a hostname the customer owns

In the brand's settings, under Your own hostname, save an unused web subdomain such as help.example.com. The page then shows two records:

TypeNameValue
CNAMEhelp.example.comthe brand's own hostname, e.g. alpha.bouee.dev
TXT_bouee-verification.help.example.combouee-verification=<unique token>

Publish both, then select Check now. It checks the two records, and once they verify, that https://help.example.com answers as this installation (above); the page then shows each result on its own line, DNS and HTTPS, with what is still missing: the records, the proxy route, or the hostname's certificate. Checks are limited to once a minute per brand. A CNAME cannot share its DNS name with MX or other records: a name that receives mail — the brand's support domain, its public support address's domain, or any domain the installation routes to a brand — is refused, so choose another hostname (see Cloudflare's CNAME explanation). Use a DNS-only CNAME while verifying; flattened or proxied answers do not expose the CNAME this check reads.

If the brand has not reserved a slug, there is no CNAME to publish. The page says so, shows the ownership TXT alone, and offers no check — a check that cannot pass is not offered. Reserve a hosted subdomain first; the CNAME target appears as soon as the brand has one.

Hostnames under the installation's own base domain are refused here. alpha.bouee.dev is a brand's hostname, issued with its slug — it is what a custom hostname points at, not something a brand registers as one.

What a pass proves, exactly

That the hostname resolves through a single CNAME to the brand's own hostname, and that the unique ownership TXT for that registration is published beneath it. A CNAME alone is not ownership and an arbitrary Host header is not a registration.

It proves nothing about HTTPS, about a certificate, or about whether the operator has added the hostname to the proxy: the check over the web does that, once DNS has passed. Keep both records in place: a verified mapping re-reads them once a day, on a schedule as well as on traffic, and only a definitive miss un-verifies it — a resolver that is merely unreachable never takes a working site down. Knowledge base links are stricter: they use the hostname only while DNS proved it within the last two days, its check over the web proved it within the last day, and the brand's hosted hostname is live, and fall back to the next origin down otherwise, because they send readers to whoever holds the name. Pages are served on a verified hostname either way: they answer requests that have already arrived. Replacing a hostname clears its verification and reissues the ownership token, and its old proof over the web stands for nothing; removing it stops the app resolving that hostname, and the brand goes on answering on its hosted hostname.

Reserved slugs and names under the base

src/lib/hosted-mail-names.ts refuses the labels a brand must not take under the base domain, plus whatever this installation itself occupies there (installationLabels()). The test a name has to pass is could a page here be mistaken for the installation's own sign-in, billing or infrastructure — not could it be mistaken for the installation's content. So the sign-in and credential names, payment names, console and account names, mail and DNS names, asset hosts, deployment names (staging, demo, …, because a slug is permanent and the operator needs its own) and names that claim not to be public (localhost) stay reserved. help, support, docs, kb, blog, legal and the like are free: this is a help desk, and those name what a brand publishes.

First-run proposes the brand's name as the slug, with accents dropped rather than read as separators (Bouée is bouee). It proposes nothing from the name setup gives a workspace before anyone has named it.

A subdomain another brand already holds is refused as taken. A name under the base is had only by reserving it: the free-form support-domain field refuses any name under the base that is not the brand's own hosted name (or a name beneath it), so no brand holds x.bouee.dev for mail without the website that goes with it.

A slug is permanent. It cannot be changed or released once reserved, because it is a live website as well as a mailbox: moving it would take a help center out from under every published link, every bookmark and every customer hostname pointed at it, and would free the old name for a different brand.

The sign-in sender

Sign-in codes go out from the sign-in sender: the one saved in Instance settings, else the environment (INBOUND_DOMAIN/MAIL_FROM), else — borrowed, with nothing saved to say so — the first default brand's support address. When the first brand's hosted address is reserved and neither settings nor the environment name a sender, the reservation saves that address as the sign-in sender, as saving the brand's domain by hand in setup already did. /instance/settings shows the sender actually in use, and says when it is borrowed.

A brand with no sending identity has no sender at all: nothing is ever sent from an invented support@localhost.

The bundled proxy

bin/install runs Caddy in front of Bouée (docker compose --profile proxy, configured by docker/Caddyfile). It obtains the certificate for APP_URL's hostname when it starts, and one for each brand hostname on that name's first visit, with Caddy's on-demand TLS:

  • Caddy asks Bouée before each certificate. GET /api/tls/ask?domain=<name> answers 200 only for a name this installation serves, by the same rule the help center applies to a request: APP_URL's own host, a hosted hostname issued to a brand under the current base domain, or a customer's hostname once DNS has verified it. Any other name someone points at the server gets no certificate, so it cannot spend the installation's Let's Encrypt limits.
  • It answers only on the compose network. A request that arrives through the proxy carries a public Host, and gets a 404.
  • Nothing is registered by hand. Reserving a slug, or verifying a customer's hostname, is all a new name needs besides its DNS record. The first visit waits a few seconds while the certificate is issued, and the host check does the same.
  • The certificates are kept in the caddy-data volume. Keep it: issuing them all again runs into Let's Encrypt's rate limits.

A Caddy of your own can do the same, with ask http://127.0.0.1:3000/api/tls/ask in its on_demand_tls options and a catch-all https:// site with tls { on_demand }, as docker/Caddyfile has.

Example: Caddy in front of Bouée

Without the bundled proxy, Bouée does not configure the proxy, DNS or certificates. With Caddy, each hostname is a site block importing one shared snippet that reverse-proxies to the application; an HTTP upstream preserves the Host header by default:

(bouee) {
	# Above the app's own limit (60 MiB in next.config.ts). Caddy's MB is 10^6 bytes.
	request_body {
		max_size 64MB
	}
	reverse_proxy 127.0.0.1:3000
}

support.example.com { import bouee }
alpha.example.com { import bouee }
help.alpha.com { import bouee }

Do not override the Host header to APP_URL's host, or every brand hostname would be read as the installation (and its check answer "Not routed here"). Keep the app bound to loopback, keep the proxy's protection against spoofed forwarding headers, and validate the complete configuration before reloading it.

Adding a brand, and whether a wildcard certificate is needed

With one site block per name, each gets its own certificate over HTTP-01 or TLS-ALPN-01, which needs only public DNS for that name and ports 80/443 reaching the proxy. Adding a brand then means adding one site block and reloading. A wildcard certificate for *.example.com removes the per-brand edit but needs the DNS-01 challenge: a Caddy build with a DNS-provider module and an API token for the zone. It covers exactly one label, which is what a slug is, and it can never cover a hostname the customer owns: help.alpha.com is outside the base domain and needs its own certificate whatever else is configured. On-demand TLS, asking Bouée which names to accept, is the other option: it is what the bundled proxy does.

Confirm https://<brand hostname>/ loads the right brand without a certificate warning; the brand's status turns Live at the next check, or at once with Check now, and a customer's hostname the same way under Your own hostname. See Caddy automatic HTTPS and reverse-proxy header behavior.

Cloudflare alternatives

No Cloudflare Tunnel is configured by this feature. If an operator deploys one, add the exact public hostname route and its edge certificate, and preserve the original host at the app. Cloudflare Tunnel routing explains its hostname-to-service mappings.

For customer domains outside the operator's managed zones, Cloudflare for SaaS also needs custom-hostname registration, ownership validation, a fallback origin, and certificate activation. A CNAME alone supplies none of that TLS configuration. Follow Cloudflare for SaaS setup and hostname/certificate validation. Bouée's ownership TXT is separate from Cloudflare's certificate-validation records.

Besides the checks over the web above, this feature performs bounded DNS lookups only. It does not change ingress or request certificates; the only records it creates are the ones an owner reviews and confirms in Add these records for me. The one request it makes to a hostname a customer owns is that check: a GET of /.well-known/bouee-host-check with a fresh challenge, made only once DNS has verified the hostname as a CNAME to the brand's own and only to an address the brand's hosted hostname also resolves to, following no redirect, reading at most 512 bytes within eight seconds and keeping only which of the statuses above it found.

On this page