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 name | Type | Value |
|---|---|---|
_mortar.<hostname> | TXT | mortar-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:
-
Per-subdomain verification (recommended). Each subdomain is a separate row in
mortar_custom_domains; the user publishes_mortar.api.mybrand.appforapi.mybrand.app,_mortar.www.…forwww.…, etc. Caddy issues a per-subdomain cert on demand. -
Apex-delegated wildcard. Future feature — the user delegates
_acme-challenge.mybrand.appNS 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
| Method | Path | Scope |
|---|---|---|
| POST | /v1/_accounts/projects/{id}/domains | account |
| GET | /v1/_accounts/projects/{id}/domains | account |
| POST | /v1/_accounts/projects/{id}/domains/{hostname}/verify | account |
| 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_atstamped - 412 → TXT record not found (
detailechoes 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
| Approach | Pros | Cons |
|---|---|---|
| TXT verification | Standard, no scripted DNS write | Manual DNS edit by the user |
| HTTP-01 verify | No DNS edit | Apex domain must already CNAME to Mortar |
| CNAME-only verify | Zero extra records | Trivial 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.
Cross-links
keel/docs/custom-domain.md— Caddy operator runbookarchitecture— where this fits in the feature stack