Machine-translated draft — terminology + flow still being reviewed.

Custom domains

Mortar workspaces are reachable at <tenant>.mortar.app out of the box. For production deployments, project owners want their app served from their own hostname (api.mybrand.com, *.mybrand.app).

This doc covers the Mortar-side feature: claim, verify, and the operator wiring with Caddy on-demand TLS. For the Caddy operator runbook see keel/docs/custom-domain.md.

Flow

1. POST /v1/_accounts/projects/{id}/domains           ← claim
   → returns the TXT record to publish
2. Publish DNS:
     _mortar.api.mybrand.com  TXT  "mortar-verify=<project_id>"
     api.mybrand.com          CNAME  <tenant>.mortar.app
3. POST /v1/_accounts/projects/{id}/domains/{hostname}/verify
   → Mortar resolves _mortar.api.mybrand.com TXT and stamps verified_at
4. Caddy notices the new verified hostname and provisions a cert
   on-demand on the first TLS handshake.

Domain ownership verification

Mortar uses the standard “publish a token under a _<service>. subdomain” pattern (same as Let’s Encrypt DNS-01, Google webmaster verification, etc.):

Record nameTypeValue
_mortar.<hostname>TXTmortar-verify=<project_id>

The verifier (internal/feature/customdomain/verify.go) issues a LookupTXT via the system resolver and accepts any TXT record whose trimmed value equals mortar-verify=<project_id> exactly.

DNS propagation can take a few minutes — Verify returns 412 txt_not_found until the record is visible. Caching: we re-look up on every Verify call; no negative cache.

Why a sub-record (not the apex)

Apex TXT records often collide with SPF / DMARC / DKIM. Tucking under _mortar. keeps tenants out of each other’s way and matches ACME DNS-01’s _acme-challenge. pattern.

TLS cert provisioning

Mortar itself does not issue certs. The operator-side gateway (Caddy 2 with on-demand TLS) provisions Let’s Encrypt certificates lazily on the first TLS handshake for a hostname.

Caddy is configured with an on_demand_tls.ask endpoint pointed at Mortar’s /v1/_internal/domains/allowed?host=<hostname> (admin-only, not in the public OpenAPI). That endpoint returns 200 only when the hostname has verified_at IS NOT NULL — preventing random domains from triggering free cert issuance against Mortar’s Let’s Encrypt quota.

{
  on_demand_tls {
    ask https://api.mortar.app/v1/_internal/domains/allowed
  }
}

:443 {
  tls {
    on_demand
  }
  reverse_proxy mortar-api:8080
}

Cert renewal

Caddy handles renewal automatically — it triggers ACME renewal at ~2/3 of the cert lifetime (Let’s Encrypt = 90d → renewed at ~60d). No Mortar-side action required. The cert_status column on mortar_custom_domains is informational; Caddy is the source of truth.

Rate limits

Let’s Encrypt rate-limits new orders to 50/week per registered domain. For a tenant publishing a wildcard subdomain pattern that’s ample; for tenants onboarding many distinct apex domains in a short window the operator should batch issuance. See keel/docs/custom-domain.md for details.

Subdomain wildcard support

Wildcard (*.mybrand.app) requires DNS-01 ACME (HTTP-01 can’t prove ownership of a wildcard). Two paths:

  1. Per-subdomain verification (recommended). Each subdomain is a separate row in mortar_custom_domains; the user publishes _mortar.api.mybrand.app for api.mybrand.app, _mortar.www.… for www.…, etc. Caddy issues a per-subdomain cert on demand.

  2. Apex-delegated wildcard. Future feature — the user delegates _acme-challenge.mybrand.app NS to Caddy via a CNAME chain so Caddy can answer the DNS-01 challenge programmatically. Not shipped yet.

Today we ship (1); (2) lands when a tenant asks.

API summary

MethodPathScope
POST/v1/_accounts/projects/{id}/domainsaccount
GET/v1/_accounts/projects/{id}/domainsaccount
POST/v1/_accounts/projects/{id}/domains/{hostname}/verifyaccount
DELETE/v1/_accounts/projects/{id}/domains/{hostname}account

Create

POST /v1/_accounts/projects/proj_abc/domains
Authorization: Bearer <account JWT>

{ "hostname": "api.mybrand.com" }

Response 201:

{
  "hostname": "api.mybrand.com",
  "project_id": "proj_abc",
  "verified": false,
  "cert_status": "pending",
  "txt_record": "_mortar.api.mybrand.com",
  "txt_value": "mortar-verify=proj_abc",
  "created_at": "2026-05-15T10:00:00Z"
}

Verify

POST /v1/_accounts/projects/proj_abc/domains/api.mybrand.com/verify
  • 200 → verified, verified_at stamped
  • 412 → TXT record not found (detail echoes the expected record)
  • 502 → DNS lookup failed (transport error, retry)

Delete

DELETE /v1/_accounts/projects/proj_abc/domains/api.mybrand.com

Removes the claim. Caddy drops the on-demand permission on its next reconcile cycle (≤ 60s); existing TLS connections drain.

Schema

CREATE TABLE mortar_custom_domains (
  hostname    TEXT PRIMARY KEY,
  project_id  UUID NOT NULL REFERENCES mortar_projects(id),
  verified_at TIMESTAMPTZ,
  cert_status VARCHAR(16) NOT NULL DEFAULT 'pending',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_custom_domains_project ON mortar_custom_domains(project_id);

hostname is globally unique — only one project can claim a given hostname. cert_status is pending | active | failed | disabled; updated by the operator-side Caddy reconciler.

Trade-offs vs alternatives

ApproachProsCons
TXT verificationStandard, no scripted DNS writeManual DNS edit by the user
HTTP-01 verifyNo DNS editApex domain must already CNAME to Mortar
CNAME-only verifyZero extra recordsTrivial to MITM if CNAME is stale

TXT is the lowest-friction option that doesn’t compromise on safety. HTTP-01 is plausible as a planned alternative for tenants who’d rather update one CNAME than two records.