Skip to Content
TestingCustom Domains & TLS

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-ask gate are production-ready. The Caddy on_demand_tls hook 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

  1. Attach the domain. POST /api/app-gateway/domains with app_slug and domain. The service validates the FQDN, rejects platform-owned zones (*.openfactory.tech), and persists a record in pending state. 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.
  2. Add the DNS record at your provider.
  3. 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 to verified. You can also force a check with POST .../verify.
  4. TLS issuance is gated. Caddy’s on_demand_tls ask endpoint (GET /api/app-gateway/tls-ask) returns 200 only for domains in verified, active, or expiring. Unverified names get 403 and never trigger ACME.
  5. Cert lifecycle. Once Caddy issues a cert, the verifier records cert_expires_at. Inside the 14-day renewal window the status flips to expiring so App Observability can alert.

Domain states

StateMeaning
pendingRecord stored; DNS not yet observed at public resolvers
verifiedDNS matches; tls-ask will allow issuance
activeCert issued and cached by the gateway
expiringCert is inside the 14-day renewal window
failedVerification 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.com

Returns 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.com

Returns 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.com

200 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:

  1. Call POST /api/app-gateway/domains with app_slug="myapp" and domain="app.example.com". The service validates the FQDN, detects it is a subdomain, generates the CNAME target myapp.apps.openfactory.tech, and stores the record as pending. Response is 201 with the CNAME instructions.
  2. 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.1 and 8.8.8.8, the status flips to verified.
  3. Load the app detail panel and watch the status change from pending to verified. A CI pipeline can hit POST .../verify to force the check instead of polling.
  4. The next gateway sync calls tls_ask_allowed("app.example.com"). Because the domain is verified, the service returns True. The M1.5 on_demand_tls hook will receive 200 and allow issuance.
  5. Open https://app.example.com in a browser. Caddy intercepts the TLS handshake, consults tls-ask, gets 200, and runs Let’s Encrypt ACME (HTTP-01 or TLS-ALPN-01). The cert is cached.
  6. On subsequent reconciler passes, cert_expires_at is populated from Caddy storage. Inside the 14-day window the status becomes expiring and 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.tech URL 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/hosts overrides will not satisfy the check. Test against the real DNS provider.
  • Caddy integration is M1.5. Until the on_demand_tls hook lands, verified domains route through the platform subdomain. The gate is already enforced so the rollout is a config flip, not a data migration.
  • App Deployment — register an app and get its default https://<slug>.apps.openfactory.tech URL.
  • App Observability — surface expiring cert alerts and DNS verification failures.
Last updated on