Documentation
Custom Domains
Source: docs/CUSTOM_DOMAINS.md
#Custom Domains
Kindryn lets community admins serve their community from their own domain (e.g. community.acme.com) instead of the default https://your-kindryn.example/<community-slug>/... path. This guide walks through both the admin-facing setup (DNS + verification) and the operator-facing setup (reverse proxy + TLS).
Self-host first — Kindryn ships only the application-layer routing (host header → community lookup). The reverse proxy and TLS pieces are intentionally left to whatever you already run. We provide tested example configs for Caddy and nginx below.
#How it works
- The admin enters their custom domain in
Settings → Custom Domainand clicks Save. - The admin adds a CNAME record at their DNS provider pointing the custom domain at the Kindryn platform host.
- The admin clicks Verify DNS in Kindryn. The server resolves the domain via
dig(node:dns) and confirms the CNAME (or A record) points at this Kindryn instance. - The operator points TLS termination for the custom domain at Kindryn. With Caddy this happens automatically via on-demand TLS; with nginx you'll add a server block and request a Let's Encrypt cert.
- Future requests with
Host: community.acme.comare routed by theserver/middleware/custom-domain.tsmiddleware: it looks up the community bycustomDomain, rewrites the URL to/<community-slug>/..., and stashes the resolved community onevent.context.communityfor downstream handlers.
#Managed TLS on the hosted platform (Cloudflare for SaaS)
On the hosted platform (kindryn.app) the reverse-proxy/TLS step is AUTOMATIC (2026-07-11 fix — customer domains previously died at Cloudflare's edge with an SSL error because nothing issued certs for foreign hostnames). When the operator env is configured, saving a custom domain provisions a Cloudflare Custom Hostname: Cloudflare issues the edge certificate via HTTP validation (~1–2 minutes, no extra DNS steps — the customer's CNAME already resolves through Cloudflare) and proxies the hostname to the origin via the zone's fallback origin. The settings card shows the certificate state (Provisioning → Active); Verify DNS also refreshes it and self-heals domains saved before provisioning existed.
Operator setup (one-time):
| Env var | Value |
|---|---|
CF_SAAS_API_TOKEN | Scoped API token: Zone → SSL and Certificates → Edit, Zone → DNS → Edit (platform zone only) |
CF_ZONE_ID | The platform zone id (kindryn.app) |
CF_FALLBACK_ORIGIN | Optional — defaults to customers.<platform apex> |
CF_FALLBACK_ORIGIN_TARGET | Optional — the origin service hostname (e.g. kindryn-production.up.railway.app); when set, the app creates the proxied fallback-origin DNS record itself |
Also register the fallback origin with the origin host so its certificate covers it: railway domain customers.kindryn.app. The app sets the zone's custom_hostnames/fallback_origin lazily on first provision (ensureFallbackOrigin, idempotent).
Host-header forwarding (WHP-379, live 2026-07-23). Railway's edge only routes hostnames registered with it, and Cloudflare's native Host-header override (Origin Rules) is Enterprise-only. The custom-domain-host-forward Cloudflare Worker on the kindryn.app zone solves this: a single wildcard route (star-slash-star) catches all zone traffic INCLUDING SaaS custom hostnames; for any non-platform hostname the Worker re-issues the origin fetch against the fallback origin URL (so Railway sees Host: customers.kindryn.app — the one hostname it knows) with the real customer hostname in x-original-host, which server/middleware/custom-domain.ts recovers and validates against Community.customDomain. Platform hostnames pass through untouched (response header x-kindryn-worker marks which branch ran).
Result: zero per-domain infrastructure steps — no Railway registration, no TXT records, no Railway plan domain limits. A community admin saves the domain + adds one CNAME; everything else is the app's CF API automation.
Worker source + deploy script: infra/cloudflare/ (deploy via railway run bash infra/cloudflare/deploy-worker.sh; the CF token needs Account → Workers Scripts:Edit + Zone → Workers Routes:Edit). Do NOT add script-null "exclusion" routes for platform hostnames — verified live that an exclusion for the fallback origin stops the Worker running for custom-hostname traffic entirely (CF matches SaaS traffic against fallback-origin routes despite what the docs imply).
Member experience on a custom domain (WHP-380). The domain IS the community's product:
- Root (and every community path) resolves straight into the community — the middleware rewrite maps
/to/<slug>. /auth/*resolves at the platform route level (skip-listed from the rewrite) but renders community-branded sign-in/register pages (logo- name via the middleware context; colors/font/favicon ride
useCommunityBranding), and post-auth navigation stays on the domain. - Email/password + register work natively on the domain (better-auth's dynamic
trustedOriginsincludes every VERIFIED custom domain; the session cookie is host-scoped to the domain). - Google OAuth, magic links, and verify-email complete on the platform origin (provider redirect URIs) and return via the auth handoff: the platform-side mint endpoint (
GET /api/auth-handoff/start) issues a single-use 60-second code bound server-side to{userId, host, redirectPath}and 302s tohttps://<domain>/api/auth-handoff?code=…, which atomically consumes the code, re-validates the domain, mints a session via better-auth's magic-link verify (capture mode — no hand-rolled cookies), and lands the member on their destination. Codes are host-bound (a code minted for domain A is dead on domain B), browser-bound (an httpOnly nonce cookie set on the domain byPOST /api/auth-handoff/preparebefore the flow starts must match at consume — the login-CSRF guard; a magic link opened on a different device refuses and falls back to normal login), and refused for unverified domains. - Two session rows per cross-domain member (platform + domain) is by design; signing out of one origin doesn't end the other. Password-reset emails still run on the platform origin (v1-accepted).
History: before WHP-379 the app registered each domain with Railway via GraphQL (server/utils/railway-domains.ts, now removed) — that path required a per-domain _railway-verify TXT record for CF-proxied domains and hit Railway's per-plan domain cap.
A zone Transform Rule additionally sets x-original-host = http.host, and the custom-domain middleware can recover the customer hostname from it when a request arrives as CF_FALLBACK_ORIGIN (only relevant if Host is ever rewritten — e.g. an Enterprise Origin Rule later; validated against Community.customDomain either way).
Self-host installs: leave the env unset — everything below (reverse proxy
- TLS via Caddy/nginx) continues to apply unchanged.
#Admin: setting up a custom domain
#1. Pick a domain
Subdomain (recommended): community.acme.com. Apex domains (acme.com) work too — see the apex note below.
#2. Enter the domain in Kindryn
Go to Settings → Custom Domain, paste the host, and click Save. You can paste with or without https://; Kindryn normalizes it.
The status will show Unverified until DNS propagates and you click Verify DNS.
#3. Add the DNS record
At your DNS provider, add a CNAME pointing the custom domain at the Kindryn platform host (the one Kindryn shows in the DNS setup card).
| Type | Name | Value |
|---|---|---|
| CNAME | community | your-kindryn.example |
For an apex domain that can't CNAME, add an A record pointing at the same IP your Kindryn host resolves to:
dig +short your-kindryn.example
…and use that IP as the A record value. (Apex CNAME flattening, e.g. Cloudflare's CNAME at root, also works.)
#4. Verify DNS
Wait for the record to propagate (usually 1–10 minutes), then click Verify DNS in Kindryn. On success the status flips to Active and customDomainVerifiedAt is recorded so you can audit when verification last succeeded.
#5. Wait for the operator to wire up TLS
DNS resolution alone isn't enough — until your operator configures their reverse proxy to terminate TLS for the new domain and forward to Kindryn, browsers will hit a cert mismatch. Send your operator the operator section below.
#Operator: reverse proxy + TLS
You need three things on the proxy in front of Kindryn:
- A TLS cert for the custom domain (Let's Encrypt, ZeroSSL, or your internal CA).
- A vhost / server block that forwards traffic for the custom domain to Kindryn's listen address.
- Forwarding the original
Hostheader to Kindryn so thecustom-domain.tsmiddleware can read it.
#Caddy (recommended)
Caddy makes this almost free with on-demand TLS: it provisions a Let's Encrypt cert the first time it sees a request for an unknown host, gated by an HTTP "ask" endpoint that Kindryn could one day expose. For the MVP you can simply allowlist a base set of hosts:
# /etc/caddy/Caddyfile
# Primary platform domain — your default Kindryn deployment.
your-kindryn.example {
reverse_proxy localhost:3000
}
# Catch-all for custom community domains. Caddy will provision a
# Let's Encrypt cert on first request and forward to Kindryn.
*.acme.com, community.partner.io, hub.example.org {
reverse_proxy localhost:3000
}
To use on-demand TLS so you don't have to redeploy Caddy every time a community adds a domain, add a global ask endpoint and let Caddy ask Kindryn whether the host is allowed:
{
on_demand_tls {
ask http://localhost:3000/api/internal/custom-domain-ask
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}
The
/api/internal/custom-domain-askendpoint isn't shipped yet — track it inFEATURES.md. Until then the static allowlist works for any reasonable number of communities.
Caddy automatically forwards the Host header upstream — no extra config needed.
#nginx
# /etc/nginx/sites-available/community-acme.conf
server {
listen 80;
listen [::]:80;
server_name community.acme.com;
# ACME challenges for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name community.acme.com;
ssl_certificate /etc/letsencrypt/live/community.acme.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/community.acme.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
# CRITICAL: forward the original Host header so Kindryn's
# custom-domain middleware can resolve the community.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSockets (notifications, DMs, live events)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
}
}
Provision the cert with certbot:
sudo certbot certonly --webroot -w /var/www/letsencrypt -d community.acme.com
sudo nginx -t && sudo systemctl reload nginx
Repeat the server block per community, or template it with include files driven by an etc directory of *.conf files.
#Traefik
Traefik users can attach a router per host via the dynamic file provider or via Docker labels. The minimum router config is:
# traefik/dynamic/kindryn-custom-domains.yaml
http:
routers:
kindryn-acme:
rule: 'Host(`community.acme.com`)'
entryPoints:
- websecure
service: kindryn
tls:
certResolver: letsencrypt
services:
kindryn:
loadBalancer:
servers:
- url: 'http://kindryn:3000'
passHostHeader: true # important — forwards the original Host
#Troubleshooting
#"Verify DNS" keeps failing
- Wait longer. DNS propagation can take up to an hour (rarely up to 24 hours for some providers).
- Check
digdirectly: ``bash dig CNAME community.acme.com +short dig A community.acme.com +short`` The CNAME should match the platform host shown in the DNS setup card, or the A records should match the platform host's IPs. - Cloudflare proxy ("orange cloud"). Cloudflare's proxied records resolve to Cloudflare's edge IPs, not your platform IPs. Either set the record to "DNS only" (gray cloud) for verification, or rely on the CNAME path — Kindryn will see the underlying CNAME target through Cloudflare's
digresponse.
#Browser shows a TLS cert error
The DNS is pointed correctly but your reverse proxy hasn't been configured with a cert for the new host. Re-read the Operator section above.
#Browser shows the wrong community / 404
- Make sure your reverse proxy forwards the
Hostheader (proxy_set_header Host $host;for nginx,passHostHeader: truefor Traefik). Kindryn resolves communities by host header, so a stripped or rewritten host header will short-circuit the middleware. - Hit
/api/communities/<slug>/custom-domainfrom inside the proxied request and check the response — you should seestatus: "active". - The platform caches resolved communities for 60 seconds. If you just changed the domain via PUT/DELETE the cache is invalidated automatically; otherwise wait up to a minute.
- Check the
X-Kindryn-Communityresponse header — Kindryn sets it on every response that resolved a custom domain. If it's missing the middleware never matched.
#Custom domain conflicts
A custom domain can only be attached to one community at a time. If you get a 409 from PUT /api/communities/:slug/custom-domain, look up the existing owner and detach the domain there first.
#Removing a custom domain
Settings → Custom Domain → Remove clears the value. The community remains reachable via its default /<slug>/ path. The reverse proxy config at the operator layer should be torn down separately so the host stops resolving.
#API reference
All endpoints require ADMIN role on the community.
| Method | Path | Description |
|---|---|---|
| GET | /api/communities/:slug/custom-domain | Get the current setting + status |
| PUT | /api/communities/:slug/custom-domain | Set the custom domain |
| DELETE | /api/communities/:slug/custom-domain | Remove the custom domain |
| POST | /api/communities/:slug/custom-domain/verify | Re-run DNS verification |
Response shape (all of GET / PUT / DELETE):
{
"customDomain": "community.acme.com",
"verifiedAt": "2026-04-11T20:14:33.000Z",
"status": "active",
"expectedTarget": "your-kindryn.example"
}
status is one of not_configured, unverified, or active.
The verify endpoint returns the same shape plus a verification object with the raw DNS resolution details:
{
"verification": {
"ok": true,
"expected": "your-kindryn.example",
"resolvedCnames": ["your-kindryn.example"],
"resolvedIps": [],
"message": "CNAME points at your-kindryn.example."
}
}