Custom Domains & TLS
Every deployed app already gets https://<slug>.apps.openfactory.tech. Custom
domains let you serve the same app from a domain you own (e.g., app.example.com,
docs.acme.io, shop.mybrand.com) without touching gateway config, generating
CSRs, or copy-pasting cert files. Attach the domain, follow the DNS
instructions, and the platform handles verification end-to-end.
M1 Status: Domain storage, the REST API, real DNS verification against public resolvers, and the
tls-askgate are production-ready. The Caddyon_demand_tlshook and per-domain site blocks ship in M1.5 (gateway code is proven; awaiting operator sign-off on the ingress path). Until then, verified domains are reachable via the platform subdomain and the gate is already enforced so cert issuance cannot race ahead of verification.
When to use this
- You want a branded URL for a shipped app instead of the platform subdomain.
- You operate a CI pipeline that attaches domains for preview environments and needs to poll verification programmatically.
- You need a “no squatting” guarantee. A tenant cannot point arbitrary domains at the gateway and trigger ACME issuance for someone else’s name.
If you only need the default https://<slug>.apps.openfactory.tech URL, you do
not need this feature. See App Deployment.
How it works
- Attach the domain.
POST /api/app-gateway/domainswithapp_sluganddomain. The service validates the FQDN, rejects platform-owned zones (*.openfactory.tech), and persists a record inpendingstate. The response includes DNS instructions tailored to the name:- Subdomain (3+ labels): a single CNAME pointing at
<slug>.apps.openfactory.tech. - Apex (2 labels): a TXT verification token plus an A record once the TXT resolves.
- Subdomain (3+ labels): a single CNAME pointing at
- Add the DNS record at your provider.
- Verifier reconciles. A background loop checks every 60 seconds for the
first 15 minutes, then every 5 minutes, against public resolvers
(
1.1.1.1,8.8.8.8). When the record resolves, the status flips toverified. You can also force a check withPOST .../verify. - TLS issuance is gated. Caddy’s
on_demand_tlsask endpoint (GET /api/app-gateway/tls-ask) returns200only for domains inverified,active, orexpiring. Unverified names get403and never trigger ACME. - Cert lifecycle. Once Caddy issues a cert, the verifier records
cert_expires_at. Inside the 14-day renewal window the status flips toexpiringso App Observability can alert.
Domain states
| State | Meaning |
|---|---|
pending | Record stored; DNS not yet observed at public resolvers |
verified | DNS matches; tls-ask will allow issuance |
active | Cert issued and cached by the gateway |
expiring | Cert is inside the 14-day renewal window |
failed | Verification kept failing past the retry budget; see last_check_error |
REST API
All endpoints live under /api/app-gateway/. Authenticated routes use the
standard UserContext (session cookie or bearer token).
Attach a domain
POST /api/app-gateway/domains
Content-Type: application/json
{
"app_slug": "myapp",
"domain": "app.example.com"
}{
"domain": "app.example.com",
"app_slug": "myapp",
"status": "pending",
"kind": "subdomain",
"method": "cname",
"instructions": {
"records": [
{
"type": "CNAME",
"name": "app",
"value": "myapp.apps.openfactory.tech"
}
],
"notes": "Add a CNAME at 'app' pointing to myapp.apps.openfactory.tech. DNS propagation may take a few minutes; verification polls every minute for the first 15 minutes, then every 5 minutes."
}
}For an apex domain, the response includes a txt_token in the instructions.records array with both the TXT and A record objects.
List domains
GET /api/app-gateway/domains?app_slug=myapp{
"domains": [
{
"domain": "app.example.com",
"app_slug": "myapp",
"status": "verified",
"kind": "subdomain",
"method": "cname",
"txt_token": null,
"created_at": "2026-06-26T14:02:11Z",
"verified_at": "2026-06-26T14:08:42Z",
"last_checked_at": "2026-06-26T14:08:42Z",
"last_check_error": null,
"failure_count": 0,
"cert_expires_at": null
}
]
}Omit ?app_slug= to list every domain you own.
Inspect one domain
GET /api/app-gateway/domains/app.example.comReturns the same shape as a single entry from the list, including all fields shown above.
Force a verification pass
POST /api/app-gateway/domains/app.example.com/verify{
"verified": true,
"status": "verified",
"last_check_error": null,
"last_checked_at": "2026-06-26T14:08:42Z"
}Useful as a “Check now” button or after fixing a DNS typo. Don’t wait for the reconciler.
Detach a domain
DELETE /api/app-gateway/domains/app.example.comReturns 204 No Content. The next gateway sync stops serving the name and the
tls-ask gate refuses any future issuance for it.
TLS ask gate (internal)
GET /api/app-gateway/tls-ask?domain=app.example.com200 if the domain is verified, active, or expiring. 403 otherwise.
This endpoint is unauthenticated by design. It is bound to localhost and only
called by Caddy as part of the on_demand_tls handshake. It is the squatting
defense: a stranger cannot point victim.com at the gateway and trick the
platform into requesting a cert.
Putting it together
A typical attach-to-serving flow:
- Call
POST /api/app-gateway/domainswithapp_slug="myapp"anddomain="app.example.com". The service validates the FQDN, detects it is a subdomain, generates the CNAME targetmyapp.apps.openfactory.tech, and stores the record aspending. Response is201with the CNAME instructions. - Add the CNAME at your DNS provider. The verifier loop runs every 60 seconds
for the first 15 minutes, then every 5 minutes. When the CNAME resolves via
1.1.1.1and8.8.8.8, the status flips toverified. - Load the app detail panel and watch the status change from
pendingtoverified. A CI pipeline can hitPOST .../verifyto force the check instead of polling. - The next gateway sync calls
tls_ask_allowed("app.example.com"). Because the domain isverified, the service returnsTrue. The M1.5on_demand_tlshook will receive200and allow issuance. - Open
https://app.example.comin a browser. Caddy intercepts the TLS handshake, consultstls-ask, gets200, and runs Let’s Encrypt ACME (HTTP-01 or TLS-ALPN-01). The cert is cached. - On subsequent reconciler passes,
cert_expires_atis populated from Caddy storage. Inside the 14-day window the status becomesexpiringand App Observability raises an alert.
Notes
- Platform zones are reserved. Any name ending in
openfactory.tech(or a configured platform suffix) is rejected at attach time. Use the default<slug>.apps.openfactory.techURL for those. - Apex domains need a TXT before the A. The verifier will not accept the A record until the TXT token resolves, to prove zone control.
- DELETE is reversible by re-attaching. Detaching removes the record and
blocks issuance. Re-attaching starts the verification cycle over from
pending. - Verification uses public resolvers. Local
/etc/hostsoverrides will not satisfy the check. Test against the real DNS provider. - Caddy integration is M1.5. Until the
on_demand_tlshook lands, verified domains route through the platform subdomain. The gate is already enforced so the rollout is a config flip, not a data migration.
Related
- App Deployment — register an app and get its
default
https://<slug>.apps.openfactory.techURL. - App Observability — surface
expiringcert alerts and DNS verification failures.