Custom domains
Workspaces on their own domains: DNS verification, automatic TLS through an on-demand proxy or certificates the app gets itself over ACME, the domain workspace resolver, managed DNS zones and certificate expiry reports.
On this page
Introduction
Featuredomains A workspace of your app lives at acme.app.example.com, or under /w/acme. Many customers want it on a domain of their own: crm.acme-corp.com for their team, go.acme-corp.com for the links they send, book.acme-corp.com for a public booking page. That takes three things: proof that the domain is theirs, a certificate for it, and a way for each request to find its workspace from the host name.
navree-domains does all three:
- Domains per workspace.
Domains::addchecks the domain, refuses your own domains and ones another workspace has, and answers with the DNS records to create: a TXT record that proves ownership, and a CNAME (A records for an apex domain) that points the domain at your app. - DNS verification. A scheduled command looks the records up with hickory-resolver until they appear, then marks the domain verified. Verified domains are checked again every day.
- Automatic TLS, two ways. With an on-demand TLS proxy in front of the app (Caddy, which Harbor sets up), the app answers the proxy's ask endpoint,
GET /_domains/allow?domain=, and the proxy gets each certificate on the domain's first visit. Without a proxy, the app gets certificates from an ACME CA such as Let's Encrypt itself (instant-acme on ring), renews them, and serves them over HTTPS with a rustls certificate resolver. - Requests on a domain. With
domaininworkspace.resolvers, a request to a verified domain names its workspace forCurrentWorkspace. Public pages takeCurrentDomaininstead, with no sign-in. - Managed zones. With a Cloudflare API token, domains under a zone you own (
acme.links.example.com) get their record from the app and are verified at once. - Expiry reports. Certificates that come close to expiry are logged, emitted as an event and, with Lighthouse, reported as an issue.
Installation
Turn on the domains feature of the navree facade. It is part of the default full feature.
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["domains"] }Add DomainsModule to your app:
Navree::app()
.module(ConfigModule::for_root(sources))
.module(CryptoModule::from_config()) // seals certificate keys (tls = "acme")
.module(HttpModule::from_config())
.module(DatabaseModule::from_config())
.module(EventsModule::for_root())
.module(ScheduleModule::for_root()) // runs domains:verify --due
.module(AccessModule::for_root(access)) // the routes' permission
.module(AuthModule::from_config())
.module(WorkspaceModule::from_config()) // domains belong to workspaces
.module(DomainsModule::from_config()) // section `domains`
// ...Only ConfigModule and DatabaseModule are required. WorkspaceModule is needed for the routes and the domain resolver, CryptoModule with an application key for certificates the app gets itself, ScheduleModule for the checks to run on their own, and EventsModule for the events.
The module contributes the migration set domains, which creates four tables: custom_domains (the domains, their status and certificate dates), custom_domain_certificates (certificates obtained over ACME, their private keys sealed), custom_domain_challenges (open ACME challenge answers) and custom_domain_acme_accounts (the ACME account of each directory, sealed). They run on PostgreSQL (with or without TimescaleDB), MySQL, MariaDB and SQLite.
Configuration
Tell customers where to point their domains: a host name of yours for CNAME records, and the addresses for apex domains, which can't have a CNAME.
[domains]
target = "domains.example.com" # the CNAME customers create
addresses = ["203.0.113.10"] # A records for apex domains
tls = "on_demand" # on_demand | acme | off
[domains.verification]
prefix = "_example-verification" # TXT record at <prefix>.<domain>Name the verification record after your product: a customer of example.com sees _example-verification.crm.acme-corp.com. Every key also comes from the environment: APP_DOMAINS__TARGET=domains.example.com, APP_DOMAINS__ADDRESSES=203.0.113.10,2001:db8::10. A target that is not a host name, an address that is not an IP address, an ask path without a leading /, or tls = "acme" without domains.acme.terms_agreed = true fails the boot, naming the key. DomainsModule::for_root(DomainsOptions { .. }) takes the same options in code. The reference lists every key.
Adding a domain
Workspace admins add domains from your settings page through the routes, or you add them in code:
let domains = ctx.get::<Domains>().await?;
let domain = domains
.add(workspace_id, NewDomain::new("crm.acme-corp.com"))
.await?;
for record in &domain.dns_records {
println!("{} {} {}", record.kind, record.name, record.value);
}TXT _example-verification.crm.acme-corp.com 3k9w2m1x7q8v0z5r4t6y1u3i8o2p7a9s
CNAME crm.acme-corp.com domains.example.comThe host is stored in its normal form: lower case, without a trailing dot, an international name as its xn-- form (bücher.example is xn--bcher-kva.example). The domain starts pending, with a random 32-character verification token. NewDomain::new(host).purpose("links") labels what the workspace uses it for (app by default), which CurrentDomain passes on to your handlers.
An apex domain (acme-corp.com) gets A and AAAA records of domains.addresses instead of the CNAME. With neither target nor addresses set, only the TXT record is asked for and checked.
add refuses:
| Refusal | Code | When |
|---|---|---|
| Not a domain | invalid_domain (400) | A URL, a port, a path, a wildcard, an IP address, a single label, a purpose that is not 1 to 32 lower-case letters, digits, - or _ |
| Reserved | domain_not_allowed (400) | The host of app.url, workspace.base_domain, domains.target, an entry of domains.reserved, or a subdomain of one: a workspace can't claim admin.app.example.com |
| Taken | domain_taken (409) | Another workspace (or this one) has the host |
| Too many | domain_limit_reached (409) | The workspace has domains.max_per_workspace domains (default 10; 0 for no limit) |
remove(id) deletes a domain, its certificate and, in a managed zone, its record. When a workspace is deleted, its domains go with it.
Verification
The scheduler runs domains:verify --due every minute. It checks each domain whose next check is due:
- Ownership: a TXT record at
{domains.verification.prefix}.{host}holds the domain's token. - Routing: the domain points at the app: a CNAME to
domains.target, or an address thatdomains.targetordomains.addresseshas. Nothing is checked while both are unset;domains.verification.require_routing = falseverifies on the TXT record alone.
A domain that passes becomes verified and DomainVerified is emitted. One that doesn't stays pending, with what is missing in last_error, and is checked again domains.verification.interval_mins later (default 5):
TXT record _example-verification.crm.acme-corp.com with the value 3k9w2m1x7q8v0z5r4t6y1u3i8o2p7a9s not found; crm.acme-corp.com does not point at domains.example.com, 203.0.113.10A domain still pending domains.verification.give_up_after_hours after it was added (default 72) becomes failed, DomainVerificationFailed is emitted, and the scheduler stops checking it. verify(id) checks a domain now, whatever its status, which is what a "Check now" button calls (POST /api/domains/{id}/verify); a failed domain whose records are in place becomes verified.
A resolver failure (a timeout, SERVFAIL) is not a missing record: it is recorded in last_error, the domain is checked again at the next interval, and verify returns it as a 502 dns_lookup_failed.
Verified domains are checked again every domains.verification.recheck_hours (default 24; 0 never). A domain that no longer points at the app stays verified, so its workspace keeps working through a DNS change, but points_to_app turns false, last_error says why, and a warning is logged.
resolve(host) answers which verified domain a host is, from memory for domains.cache_secs (default 60). Adding, verifying and removing a domain clear its cached answer on that server; other servers see the change within cache_secs.
Requests on a custom domain
The workspace resolver
Put domain first in workspace.resolvers, so a request to a verified domain names that domain's workspace:
[workspace]
resolvers = ["domain", "header"]CurrentWorkspace, InWorkspace and every route behind them then work on https://crm.acme-corp.com/api/... with no X-Workspace header, and the membership check still applies. Requests to your app's own host fall through to the next resolver. See resolving the workspace.
Public pages
A landing page or a booking form on a workspace's domain has no signed-in user. Take CurrentDomain, which needs none:
async fn landing(domain: CurrentDomain) -> Result<Json<serde_json::Value>> {
Ok(Json(json!({
"workspace": domain.workspace_id,
"host": domain.host,
"purpose": domain.purpose,
})))
}It answers 404 domain_not_found for any host that is not a verified domain; Option<CurrentDomain> gives None instead. It carries the domain's id, workspace_id, host and purpose.
Links to the workspace
make_primary(id) marks a verified domain as its workspace's primary domain (the previous one stops being primary; an unverified domain is a 409 domain_not_verified). primary_url(workspace) gives https:// and that host, for links in mail and pages:
let base = domains
.primary_url(workspace_id)
.await?
.unwrap_or_else(|| "https://app.example.com".to_owned());Automatic TLS
domains.tls says how domains get certificates.
On-demand TLS (the default)
A proxy in front of the app gets each domain's certificate when the domain is first visited, after asking the app whether the domain is one of yours. The app answers at domains.ask_path (default /_domains/allow), outside the API prefix, without authentication:
GET /_domains/allow?domain=crm.acme-corp.com HTTP/1.1{ "domain": "crm.acme-corp.com" }Any other domain is a 404 domain_not_found, so the proxy never gets a certificate for a domain nobody verified. With Harbor, turn on [web.custom_domains] and Caddy is set up for you. For a Caddy of your own, ask through its local address:
{
on_demand_tls {
ask http://127.0.0.1:8080/_domains/allow
}
}
https:// {
tls {
on_demand
}
reverse_proxy 127.0.0.1:8080
}Verified domains show tls: "active". To learn when the proxy's certificates expire, the module reads them over TLS: see certificate expiry.
Certificates the app gets itself
Without a proxy, the app gets certificates from an ACME CA (RFC 8555; Let's Encrypt by default) and serves HTTPS itself:
[domains]
tls = "acme"
[domains.acme]
contact = ["[email protected]"]
terms_agreed = true # you agree to the CA's terms of service
https_addr = "0.0.0.0:443" # serve HTTPS next to `serve`
hosts = ["app.example.com"] # your own hosts get certificates too- Issuing.
domains:verify --dueorders a certificate for each newly verified domain right after its check. The CA validates the domain with an HTTP-01 challenge: the app serves the answer at/.well-known/acme-challenge/{token}onhttp.addr(port 80 in production) from thecustom_domain_challengestable, so whichever of your servers the CA reaches can answer. The key and signing request are made by instant-acme with rcgen, on ring. - Storing. The certificate chain and its private key go to
custom_domain_certificates, the key sealed with the application key (app.key); a missing key fails the issuance before anything is ordered. The ACME account is created on the first order and stored, sealed, per directory. - Serving. With
domains.acme.https_addrset,servealso listens for HTTPS there: the same router, every layer and route, behind rustls with the certificate of the server name each client asks for. It loads the certificates from the database at start and everydomains.acme.reload_secs(default 60), so a renewal made by one server reaches the others. A name with no certificate gets no handshake. - Renewing.
domains:certificates --due(hourly) renews each certificate withindomains.acme.renew_daysof expiry (default 30), checking each domain everydomains.certificates.check_hours(default 24). - Failures. A refused order sets the domain's
tlstofailedwith the CA's answer intls_error, and is tried again a day later.issue_certificate(host)orders one now (502certificate_not_issuedwhen the CA refuses).
Try the setup against Let's Encrypt's staging directory first (directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory", the constant LETS_ENCRYPT_STAGING). A private CA, or a test CA, goes in directory_url with its root in domains.acme.ca_file.
To serve HTTPS from a server of your own, leave https_addr unset and hand it the certificates: domains.certificates() is a DomainCertificates, a rustls ResolvesServerCert, and server_config() builds a rustls::ServerConfig around it (ring, TLS 1.2 and 1.3, ALPN h2 and http/1.1). HttpsServer::bind(&ctx, addr) and serve() run the module's listener yourself.
Off
tls = "off": something else terminates TLS. Domains are still verified and resolve their workspace; no ask endpoint and no challenge route are mounted.
Certificate expiry
domains:certificates --due runs hourly. Besides ACME renewals, with tls = "on_demand" it reads each verified domain's certificate over TLS on port 443 (TlsProbe: it connects, says the domain's name, and reads the dates of the certificate presented, which is read, not trusted) and records its expiry in certificate_expires_at. A domain that can't be read gets tls: "failed" and the reason in tls_error. Customers control their domains' DNS, so a domain that resolves only to loopback, private, link-local or shared addresses is not connected to: its probe fails with that reason. domains.certificates.probe = false turns the probes off.
A certificate that expires within domains.certificates.warn_days (default 14) is reported once per check:
- a warning in the log;
- the event
DomainCertificateExpiring, for a mail to the workspace's admins; - with the
lighthousefeature (on when bothdomainsandlighthouseare) andLighthouseModulein the app, a Lighthouse warning,The certificate for crm.acme-corp.com expires in 9 days, tagged with the domain and grouped into one issue per domain.
Managed zones
Domains under a zone you own, such as acme.links.example.com for each workspace's tracked links, need no customer DNS work. Give the module a DNS provider and the zone:
[domains.dns]
provider = "cloudflare"
zone = "links.example.com"
[domains.dns.cloudflare]
api_token = "..." # Zone.DNS:Edit on the zone
zone_id = "023e105f4ecef8ad9ca31a8372d0c353"Keep the token in the environment: APP_DOMAINS__DNS__CLOUDFLARE__API_TOKEN. A domain added inside the zone gets its CNAME to domains.target (or A and AAAA records of domains.addresses) through the Cloudflare API and is verified at once, with no TXT record to check (managed: true). Removing it deletes the record. The zone itself can't be added, and a provider failure (502 dns_provider_failed) adds nothing. domains.dns.cloudflare.proxied proxies the records through Cloudflare; ttl sets their TTL (1 is automatic).
A provider of your own implements DnsProvider (set(kind, name, values) and delete(kind, name)) and replaces Cloudflare's with override_value::<dyn DnsProvider>(..).
Routes
With domains.routes on (the default), the module mounts a workspace's domain settings under {http.api_prefix}/domains. Each route acts on the request's workspace (the InWorkspace guard) and requires the permission domains.manage, which the workspace owner role has through all.*; give it to other roles under workspace.roles.
| Route | Answer |
|---|---|
GET /api/domains | {data: [CustomDomain]} |
POST /api/domains {host, purpose?} | 201 CustomDomain with its dns_records |
GET /api/domains/{id} | CustomDomain |
POST /api/domains/{id}/verify | CustomDomain after a DNS check |
POST /api/domains/{id}/primary | CustomDomain, now the primary domain |
DELETE /api/domains/{id} | 204 |
{
"id": "0199a4c2-7c1e-7b6a-9e0d-5f3b2a1c0d9e",
"workspace_id": "0199a4c2-1f00-7d2e-8a11-0b9c3e4f5a6b",
"host": "crm.acme-corp.com",
"purpose": "app",
"status": "pending",
"primary": false,
"managed": false,
"points_to_app": null,
"tls": "pending",
"certificate_expires_at": null,
"tls_error": null,
"last_error": null,
"last_checked_at": null,
"next_check_at": "2026-10-28T09:00:00Z",
"verified_at": null,
"dns_records": [
{ "type": "TXT", "name": "_example-verification.crm.acme-corp.com", "value": "3k9w2m1x7q8v0z5r4t6y1u3i8o2p7a9s", "purpose": "verification" },
{ "type": "CNAME", "name": "crm.acme-corp.com", "value": "domains.example.com", "purpose": "routing" }
],
"created_by": "0199a4c1-55aa-7c3d-9b8e-1a2b3c4d5e6f",
"created_at": "2026-10-28T09:00:00Z",
"updated_at": "2026-10-28T09:00:00Z"
}status is pending, verified or failed; tls is pending, active, failed or off. Another workspace's domain is a 404 domain_not_found.
Events
Each is emitted after its change commits, when the app has EventsModule:
| Event | Name | Payload |
|---|---|---|
DomainAdded | domains.added | domain (a CustomDomain) |
DomainVerified | domains.verified | domain |
DomainVerificationFailed | domains.verification_failed | domain, reason |
DomainRemoved | domains.removed | domain_id, workspace_id, host |
DomainCertificateIssued | domains.certificate_issued | host, domain_id, expires_at, renewal |
DomainCertificateExpiring | domains.certificate_expiring | host, domain_id, expires_at, days_left |
#[injectable]
pub struct DomainLog;
impl Listener<DomainVerified> for DomainLog {
fn handle<'a>(
&'a self,
event: &'a DomainVerified,
_ctx: &'a AppContext,
) -> BoxFuture<'a, Result<(), BoxError>> {
Box::pin(async move {
tracing::info!(
host = %event.domain.host,
workspace = %event.domain.workspace_id,
"the workspace's domain is live"
);
Ok(())
})
}
}Register it with m.listen::<DomainVerified, DomainLog>() in your module.
Errors
| Code | Status | When |
|---|---|---|
invalid_domain | 400 | The input is not a domain, or the ask endpoint got no ?domain= |
domain_not_allowed | 400 | The domain is reserved |
domain_not_found | 404 | No such domain (or another workspace's); a host that is not a verified domain |
domain_taken | 409 | Another workspace has the domain |
domain_limit_reached | 409 | The workspace has domains.max_per_workspace domains |
domain_not_verified | 409 | Making an unverified domain primary, or ordering its certificate |
dns_lookup_failed | 502 | The resolver failed (not a missing record) |
dns_provider_failed | 502 | The DNS provider refused a change |
certificate_not_issued | 502 | The ACME CA refused the order |
Commands
navree domains:list # every domain: host, status, tls, expiry, workspace
navree domains:list --status pending --workspace 0199a4c2-1f00-7d2e-8a11-0b9c3e4f5a6b
navree domains:verify crm.acme-corp.com # check one domain now (host or id)
navree domains:verify --due # what the scheduler runs every minute
navree domains:certificates --due # issue, renew or probe; report expiring certificates
navree domains:certificates crm.acme-corp.comThe scheduler runs domains:verify --due every minute and domains:certificates --due hourly, without overlapping, when ScheduleModule is in the app.
Testing
Replace the drivers with the module's fakes: DnsFake answers lookups from records you set, AcmeFake issues certificates from a test root without checking challenges, ProbeFake says what each host's certificate is, and DnsProviderFake is a zone in memory.
use std::sync::Arc;
use navree::domains::{
AcmeClient, AcmeFake, CertificateProbe, DnsFake, DnsLookup, DomainStatus, Domains,
DomainsModule, NewDomain, ProbeFake,
};
use navree::prelude::*;
use navree::testing::TestApp;
#[tokio::test]
async fn a_customer_brings_their_domain() {
let dns = DnsFake::new().with_address("domains.example.com", "203.0.113.10");
let app = TestApp::builder()
.temp_database()
.config("domains.target", "domains.example.com")
.config("domains.routes", false)
.config("workspace.routes", false)
.config("workspace.users_table", "")
.module(DatabaseModule::from_config())
.module(WorkspaceModule::from_config())
.module(DomainsModule::from_config())
.override_value::<dyn DnsLookup>(Arc::new(dns.clone()))
.override_value::<dyn AcmeClient>(Arc::new(AcmeFake::new()))
.override_value::<dyn CertificateProbe>(Arc::new(ProbeFake::new()))
.build()
.await;
let domains = app.get::<Domains>().await;
let workspace = uuid::Uuid::now_v7();
let domain = domains
.add(workspace, NewDomain::new("crm.acme-corp.com"))
.await
.unwrap();
assert_eq!(domain.status, DomainStatus::Pending);
// The customer creates the records.
dns.set_txt(&domain.dns_records[0].name, &domain.dns_records[0].value);
dns.set_cname("crm.acme-corp.com", "domains.example.com");
let domain = domains.verify(domain.id).await.unwrap();
assert_eq!(domain.status, DomainStatus::Verified);
app.client()
.get("/_domains/allow?domain=crm.acme-corp.com")
.send()
.await
.assert_status(200);
}| Fake | Does |
|---|---|
DnsFake | with_txt, with_cname, with_address (builders); set_txt, set_cname, set_address, remove(name) after boot; fail(name, reason) makes lookups of a name fail; lookups() lists them. Addresses follow CNAMEs |
AcmeFake | Issues certificates signed by its own root (ca_pem(), to trust in a TLS client); expire_at(at), fail_next(reason), keep_challenges() (leave the answer published, to fetch it from the app), orders(), challenges() |
ProbeFake | expires(host, at), fail(host, reason), probed() |
DnsProviderFake | records(kind, name), all(), fail_next(message) |
Use a FixedClock (override_value::<dyn Clock>(..)) and clock.advance(..) to walk through the schedule: the five-minute retries, giving up after 72 hours, daily rechecks and renewals. HttpsServer::bind(app.ctx(), "127.0.0.1:0") serves the app over TLS in a test, with AcmeFake's certificates.
InstantAcme itself is tested against Pebble, Let's Encrypt's test CA, when NAVREE_TEST_PEBBLE_URL and NAVREE_TEST_PEBBLE_CA are set (CI runs it).
Reference
| Key | Default | Meaning |
|---|---|---|
domains.routes | true | Mounts the /api/domains routes |
domains.tls | on_demand | on_demand, acme or off |
domains.ask_path | /_domains/allow | The ask endpoint (tls = "on_demand") |
domains.target | none | The host name customers point a CNAME at |
domains.addresses | [] | The addresses apex domains point at (a list, or a comma-separated string) |
domains.reserved | [] | Domains no workspace may add, with their subdomains |
domains.max_per_workspace | 10 | Domains per workspace (0: no limit) |
domains.cache_secs | 60 | How long a host lookup is kept in memory (0: never) |
domains.verification.prefix | _domain-verification | The label of the TXT record in front of the domain |
domains.verification.interval_mins | 5 | Minutes between checks of a pending domain |
domains.verification.give_up_after_hours | 72 | A domain still pending this long fails |
domains.verification.recheck_hours | 24 | Hours between routing checks of verified domains (0: never) |
domains.verification.require_routing | true | A domain must point at the app to be verified |
domains.certificates.warn_days | 14 | Report certificates that expire within this many days |
domains.certificates.check_hours | 24 | Hours between certificate checks of a domain |
domains.certificates.probe | true | Read on-demand certificates over TLS |
domains.acme.directory_url | Let's Encrypt | The ACME directory |
domains.acme.contact | [] | The account's contact addresses |
domains.acme.terms_agreed | false | You agree to the CA's terms (required with tls = "acme") |
domains.acme.https_addr | none | Where serve also listens for HTTPS |
domains.acme.renew_days | 30 | Renew this many days before expiry |
domains.acme.hosts | [] | Your own hosts that get certificates too |
domains.acme.ca_file | none | A PEM root for the directory's HTTPS (a private or test CA) |
domains.acme.reload_secs | 60 | Seconds between certificate reloads of the HTTPS listener |
domains.dns.provider | none | cloudflare |
domains.dns.zone | none | The zone the provider manages |
domains.dns.cloudflare.api_token | none | An API token with Zone.DNS:Edit |
domains.dns.cloudflare.zone_id | none | The zone's id |
domains.dns.cloudflare.api_url | https://api.cloudflare.com/client/v4 | The API |
domains.dns.cloudflare.proxied | false | Proxy the records through Cloudflare |
domains.dns.cloudflare.ttl | 1 | The records' TTL (1: automatic) |
| Type | What it is |
|---|---|
Domains | The service: add, list, all, get, get_for, find, resolve, allows, verify, verify_due, make_primary, primary, primary_url, remove, remove_workspace, issue_certificate, issue_pending, check_certificates, reload_certificates, challenge_answer, certificates, dns_records |
DomainsModule | for_root, from_config |
CustomDomain, DnsInstruction, DomainStatus, TlsStatus, NewDomain, CertificateRun | What the service takes and returns |
CurrentDomain | The extractor of public pages |
DomainWorkspace | The request extension the domain resolver reads |
DnsLookup, SystemDns | DNS lookups (hickory-resolver) |
AcmeClient, InstantAcme, AcmeOrder, IssuedCertificate | ACME orders |
DomainCertificates, HttpsServer | Serving certificates |
CertificateProbe, TlsProbe, ProbedCertificate | Reading a host's certificate |
DnsProvider, CloudflareDns, RecordKind | Managed zones |
models::{Domain, Certificate, Challenge, AcmeAccount} | The tables as navree-orm models, to read |
normalize_host, request_host | Host names |