Docs/Custom Domain Setup
Editors & DevOps

Custom Domain Setup

Configuring DNS, TLS certificates, and reverse proxy to enable custom domains for journal websites.

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

  1. 1
    An editor sets journal.customDomain = "journal.example.com" via the Website tab in JMS.
  2. 2
    The backend middleware resolves the incoming Host header: if it matches a known customDomain in the DB, req.tenantId is set automatically.
  3. 3
    The frontend JournalPublicByDomainPage (/public/domain) calls GET /v1/public/journals/domain/:domain/homepage using window.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 stable

Example 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 Issuer that 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

ConcernMitigation
Tenant spoofing via Host headerThe 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 hijackingOnly editors of a journal can set customDomain (role-guarded). Editors cannot set a domain already in use by another journal (ConflictException).
Stale domainsWhen a journal is archived or domain cleared, nginx still receives traffic but the DB lookup returns nothing — request gets a 404.
Rate limitingThe 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

VariableExamplePurpose
TENANT_DOMAIN_SUFFIX.alkademy.comPlatform subdomain suffix. Requests to *.alkademy.com use subdomain resolution. Requests to anything else go through custom-domain lookup.

6. Rollout checklist