All docs

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

  1. The admin enters their custom domain in Settings → Custom Domain and clicks Save.
  2. The admin adds a CNAME record at their DNS provider pointing the custom domain at the Kindryn platform host.
  3. 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.
  4. 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.
  5. Future requests with Host: community.acme.com are routed by the server/middleware/custom-domain.ts middleware: it looks up the community by customDomain, rewrites the URL to /<community-slug>/..., and stashes the resolved community on event.context.community for 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 varValue
CF_SAAS_API_TOKENScoped API token: Zone → SSL and Certificates → Edit, Zone → DNS → Edit (platform zone only)
CF_ZONE_IDThe platform zone id (kindryn.app)
CF_FALLBACK_ORIGINOptional — defaults to customers.<platform apex>
CF_FALLBACK_ORIGIN_TARGETOptional — 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 trustedOrigins includes 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 to https://<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 by POST /api/auth-handoff/prepare before 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).

TypeNameValue
CNAMEcommunityyour-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:

  1. A TLS cert for the custom domain (Let's Encrypt, ZeroSSL, or your internal CA).
  2. A vhost / server block that forwards traffic for the custom domain to Kindryn's listen address.
  3. Forwarding the original Host header to Kindryn so the custom-domain.ts middleware can read it.

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-ask endpoint isn't shipped yet — track it in FEATURES.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 dig directly: ``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 dig response.

#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 Host header (proxy_set_header Host $host; for nginx, passHostHeader: true for Traefik). Kindryn resolves communities by host header, so a stripped or rewritten host header will short-circuit the middleware.
  • Hit /api/communities/<slug>/custom-domain from inside the proxied request and check the response — you should see status: "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-Community response 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.

MethodPathDescription
GET/api/communities/:slug/custom-domainGet the current setting + status
PUT/api/communities/:slug/custom-domainSet the custom domain
DELETE/api/communities/:slug/custom-domainRemove the custom domain
POST/api/communities/:slug/custom-domain/verifyRe-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."
  }
}
Kindryn — documentation
© 2026 Capacity in Reserve LLC. All rights reserved.