The app-side code for custom domains is already shipped. This guide covers the infrastructure configuration required to make it work end-to-end.
How it works
- 1An editor sets
journal.customDomain = "journal.example.com"via the Website tab in JMS. - 2The backend middleware resolves the incoming
Hostheader: if it matches a knowncustomDomainin the DB,req.tenantIdis set automatically. - 3The frontend
JournalPublicByDomainPage(/public/domain) callsGET /v1/public/journals/domain/:domain/homepageusingwindow.location.hostname— no URL parameters needed.
1. DNS — what the journal owner must do
The journal owner (or their DNS admin) must create a CNAME record:
Type: CNAME
Name: journal (or @ for apex)
Value: journals.alkademy.com
TTL: 300 (5 min) while testing, 3600+ once stableExample for journal.example.com:
journal.example.com. 300 IN CNAME journals.alkademy.com.Apex domains (
example.com with no subdomain) cannot use CNAME. Use an ALIAS / ANAME record if the DNS provider supports it, or use a subdomain instead.2. Load balancer / reverse proxy
2a. Accept wildcard hostnames
The load balancer (nginx / Caddy / ALB) must accept any Host header — not just known subdomains.
# nginx example
server {
listen 443 ssl;
server_name _; # catch-all — accept any hostname
ssl_certificate /etc/ssl/certs/wildcard.pem;
ssl_certificate_key /etc/ssl/private/wildcard.key;
location / {
proxy_pass http://jms-api:3000;
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;
}
}The key requirement is
proxy_set_header Host $host so the backend receives the original hostname and can resolve it to a tenant.2b. React frontend (SPA)
Custom domains point at the same static host that serves the JMS SPA. All non-API paths should serve index.html (standard SPA fallback). nginx must redirect bare custom domain requests to the /public/domain route.
server {
listen 443 ssl;
server_name _;
root /var/www/jms-frontend/dist;
index index.html;
location /api/ {
proxy_pass http://jms-api:3000;
proxy_set_header Host $host;
}
# Redirect bare custom domain to /public/domain route
location = / {
return 302 /public/domain;
}
location / {
try_files $uri $uri/ /index.html;
}
}3. TLS certificates
Option A — Let's Encrypt with cert-manager or Caddy (recommended)
Use automatic TLS issuance. The journal owner points the CNAME, then the platform provisions a cert on the first HTTPS request.
- Caddy: built-in automatic TLS. Just use
server_name _— Caddy handles everything automatically. - cert-manager (k8s): deploy an
Issuerthat uses the HTTP-01 or DNS-01 ACME challenge. A short cold-start delay (< 30s) on the very first request is expected.
Option B — Wildcard certificate
A wildcard cert (
*.alkademy.com) only covers first-level subdomains. It does not cover journal.example.com — so it cannot be used for custom domains. A per-domain cert (Option A) is required for every custom domain.4. Security considerations
| Concern | Mitigation |
|---|---|
| Tenant spoofing via Host header | The middleware only trusts the Host header when it matches a customDomain stored in the DB. A forged header for an unregistered domain resolves to nothing. |
| Domain hijacking | Only editors of a journal can set customDomain (role-guarded). Editors cannot set a domain already in use by another journal (ConflictException). |
| Stale domains | When a journal is archived or domain cleared, nginx still receives traffic but the DB lookup returns nothing — request gets a 404. |
| Rate limiting | The custom-domain middleware does a DB query on every public request. Cache the customDomain → tenantId mapping in Redis with a 5-minute TTL to avoid hammering the DB at scale. |
5. Environment variables
| Variable | Example | Purpose |
|---|---|---|
TENANT_DOMAIN_SUFFIX | .alkademy.com | Platform subdomain suffix. Requests to *.alkademy.com use subdomain resolution. Requests to anything else go through custom-domain lookup. |