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.
#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."
}
}