Skip to content

Use your own domain

Instead of the automatic {app}-{username}.{domain} hostname, you can put an HTTP service on a domain you own, like blog.example.com, over HTTPS. DedNets checks that you control the domain and that its DNS points at the right place, then provisions a Let’s Encrypt certificate for you automatically. (Since v0.2.3.)

It is two steps: you add the domain and prove you own it, then you point a hostname on it at one of your services. (Since v0.7.0; before that the two were one step, see Upgrading at the end.) Splitting them is what lets one wildcard domain host one service per label, and lets you own a name before you have decided what to publish on it.

You choose where TLS terminates:

  • DedNets shared edge - you point the domain at our edge with a CNAME (or an A record for an apex domain). Our servers terminate TLS. This is the zero-infrastructure option.
  • Your own node - you point the domain at one of your own hosts running the entrypoint role, and that host terminates TLS. Traffic never passes through our edge in plaintext.

(Since v0.10.0) an app that requires a DedNets login needs the shared edge front: the login gate runs in our edge, and a node front never reaches it. DedNets refuses the combination rather than serving the app unprotected - you cannot bind a node-front hostname to a gated app, and you cannot gate an app that already has one.

On the Domains page, click Add domain. It is two steps - Name it, then Prove & point - and you are not asked for a service in either: a domain is yours first, and what it serves comes next. Enter the domain, pick a front, and the dialog shows the exact DNS records to create:

  1. A TXT record at _dednets-challenge.<your-domain> with a token. This proves you own the domain. This one is always required.
  2. One record pointing the domain at the chosen front. For the shared edge the dialog offers a CNAME and an A record as alternatives - create only one of them, whichever your DNS provider allows for that name. (A CNAME is usually right for a subdomain; an apex like example.com normally cannot hold one, so use the A record there. Publishing both is not allowed by DNS: a name carrying a CNAME cannot carry any other record.)

Create both records at your DNS provider, then click Verify. DedNets checks the token and that the domain resolves to the expected target. When both pass, the domain is verified and yours.

DNS changes can take a few minutes to propagate. DedNets re-checks your domains periodically, so a domain will activate on its own once the records are visible; you can also click Verify again at any time.

A verified domain with nothing on it yet is a normal state, not an error: the Domains page shows it as no hostname bound yet, and reports no certificate problem - nothing has been issued because nothing is being served. Nothing is served until you do the next step.

(Since v0.7.0.) Open the domain’s row on the Domains page and click Bind a hostname under this zone. The same flow is on the Services page (Add hostname on a service’s detail pane, which locks the service and lets you pick the domain). (Since v0.10.1 that pane also lists every hostname already reaching the service, with its own controls.)

Pick the service and the domain. For an exact domain like blog.example.com there is nothing more to choose: the domain is the hostname. For a wildcard you also type a label, and the dialog previews the hostname live as you type (api + .lab.example.com becomes https://api.lab.example.com).

Click Bind. The hostname goes live and its certificate is issued on the first HTTPS request; a green https:// chip appears on the service and on the domain’s row.

Two rules worth knowing:

  • One hostname serves exactly one service. Binding a hostname that already serves something else moves it, and the dialog says so before you confirm (the button reads Re-point).
  • A wildcard domain hosts one service per label, so api.lab.example.com and web.lab.example.com can reach two different services under one domain.

Unbinding (Unbind on the hostname’s row on the Domains page, or the ✕ on the Services page) stops that hostname. The domain itself stays yours and every other hostname on it keeps working.

(Since v0.6.0.) Instead of one hostname you can add a wildcard: *.lab.example.com covers every hostname exactly one label under lab.example.com, including names that do not exist yet. Add it once, and every CI branch, pull request or preview environment gets its own HTTPS hostname with nothing to create per build and nothing to clean up afterwards.

(Since v0.7.0) a wildcard hosts one service per label: you point each label you care about at a service, and two labels under one zone can reach two different services.

  • pr-1234.lab.example.com is covered.
  • a.b.lab.example.com is not covered: a wildcard covers exactly one label.
  • lab.example.com itself is not covered. Add it separately if you need it.

It is a DNS wildcard, the same rule your resolver and a TLS certificate follow, not a regular expression. A wildcard costs one slot of your domain quota, exactly like a single hostname.

In the Console, the Add domain dialog opens on a choice between one hostname and any subdomain; picking Any subdomain rewrites the field to *.<zone> and previews exactly what the domain will and will not cover, along with the DNS records it will need. After it verifies, use Point a hostname at a service once per label you want to publish.

Two DNS records, both created once:

  1. TXT at _dednets-challenge.lab.example.com, on the zone itself and not on the * name (a record under a * label is not a name DNS can hold). This proves you own the zone.
  2. CNAME (or A) on the literal *.lab.example.com name, pointing at your chosen front.

DedNets verifies a wildcard by resolving a fresh random hostname under your zone on every check, so it is proving the whole zone answers rather than one name you were told in advance. The Console shows the hostname it tested. If a check misses, the domain keeps serving: it stays live through two misses while DNS propagates and is only taken down on the third.

An exact domain you add yourself always wins over your wildcard, so api.lab.example.com can be a domain of its own pointing somewhere else, while labels bound under the wildcard keep working.

Terminal window
dednetsctl domain add '*.lab.example.com' -front edge
dednetsctl domain verify '*.lab.example.com'
dednetsctl domain bind '*.lab.example.com' -label api -target alice/vm1/api
dednetsctl domain bind '*.lab.example.com' -label web -target alice/vm1/web
dednetsctl domain hosts -domain '*.lab.example.com'

Quote the wildcard. Unquoted, your shell tries to expand *.lab.example.com into filenames before dednetsctl ever sees it: zsh (the default on macOS) refuses to run the command with no matches found, and bash passes it through only as long as no file in the current directory matches. If quoting is awkward (a Makefile, a YAML pipeline), write it with a leading dot instead, which means exactly the same thing and no shell touches it:

Terminal window
dednetsctl domain add .lab.example.com
dednetsctl domain bind .lab.example.com api -target alice/vm1/api

A label never contains a dot, which is what makes that second line unambiguous. You can also name the full hostname instead, which is the identical bind: dednetsctl domain bind api.lab.example.com -target alice/vm1/api.

In a pipeline, poll with domain verify and read the machine-readable form:

Terminal window
until dednetsctl domain verify '*.lab.example.com'; do sleep 10; done
base=$(dednetsctl domain verify '*.lab.example.com' -json | jq -r .base)
dednetsctl domain bind '*.lab.example.com' -label "pr-${PR_NUMBER}" -target alice/vm1/web
curl -sf "https://pr-${PR_NUMBER}.${base}/healthz"
# when the pull request closes:
dednetsctl domain unbind '*.lab.example.com' -label "pr-${PR_NUMBER}"

domain verify exits 0 once the domain is live (including while it is riding out a missed DNS check), 1 while it is not live yet, so the loop above is safe, and 2 when retrying cannot help: there is no such domain, the name is not usable, or you are out of domain slots. domain bind uses the same three codes, with one deliberate exception each way: “the domain is not verified yet” and “the certificate window is full” are 1, because DedNets re-checks verification every five minutes and the certificate window rolls, so retrying really can succeed without you doing anything.

  • New certificates are rate-limited. A certificate is issued the first time each hostname is requested, and to stay inside Let’s Encrypt’s own limits DedNets meters brand-new hostnames under a wildcard. On the shared edge three limits apply at once:

    • 10 per hour, per wildcard (CONSOLE_WILDCARD_CERT_BURST).
    • 40 per 7 days, per registered domain (CONSOLE_WILDCARD_CERT_WEEK). This is the one a busy project meets first, and it is counted against example.com as a whole rather than per pattern, because that is how Let’s Encrypt counts its own limit of 50: *.a.example.com and *.b.example.com draw on the same 40.
    • 100 per 3 hours, across the whole platform (CONSOLE_WILDCARD_CERT_GLOBAL).

    On your own node only the hourly cap applies, enforced by your own daemon at the same 10 per hour per wildcard, and it is not configurable today.

    Renewals and hostnames that already have a certificate are never counted and never refused, so a stable set of environments costs nothing after its first day. Size a CI workload against new hostnames instead: roughly 40 a week under one registered domain, and no more than 10 in any one hour. Beyond that the extra hostnames fail TLS until the window rolls over. If your build volume needs more, get in touch.

  • A pattern is claimed platform-wide, once. If someone else already claimed *.example.com, even without ever verifying it, that exact pattern cannot be claimed again, and there is no automatic expiry. Exact hostnames under the zone (www.example.com) and a wildcard one level down (*.lab.example.com) are unaffected. If you own the domain and the pattern is held by someone else, get in touch.

(Since v0.4.5.) Domains in the Console sidebar shows every domain on your account in one place - one row per domain, and the row opens to show everything that domain holds.

Each row carries the domain, how many hostnames are on it, which front serves it, and a four-step chain: owned → pointing → certificate → serving. The steps are in the order they depend on each other, so the first one that is not green is where setup stopped, and the row names it in words right beside the chain (“waiting for the ownership record”, “not pointing here”, “no hostname bound yet”). A domain that is fully set up shows four green links and nothing else.

Open a row and you get, side by side:

  • Hostnames - every hostname bound under the domain and the service each one reaches, with Open, the bot-check toggle and Unbind on the row itself, plus Bind a hostname under this zone. A hostname that is not answering carries the reason underneath it.
  • DNS at your registrar - which front serves the domain, and the exact records you were asked to create, each with a copy button. No dialog to open: if a step is stuck, the records that would fix it are already on screen.

The footer of an open row has Re-check now and Remove domain. Above the list, a search matches domains, hostnames and services (and opens every domain it matches), and four filters - All zones, Needs attention, Serving, Wildcards - carry their own counts, so “what needs me right now” is one click. An Add domain button starts the flow above.

From the command line, dednetsctl domain hosts is the same view (-domain and -target filter it, -json makes it machine-readable), and dednetsctl domain list shows each domain with how many hostnames are on it.

(Since v0.11.2.) The DNS at your registrar card names the front that serves the domain and carries a Change button. It opens a picker with every door available to you: the shared DedNets edge, each of your own hosts running the entrypoint role, and (since v0.12.0) My Global Web Servers when at least one of your hosts runs the gateway role’s web option.

You are not asked to prove ownership again - the _dednets-challenge TXT record and its value do not change, so leave it exactly where it is. Only the pointing record changes, and the dialog (and the API response) gives you the new one. Publish it, then press Re-check now.

One move is refused: you cannot put a domain on your own node while a hostname under it reaches an app that requires a DedNets sign-in. That check runs at the DedNets edge, so an entrypoint node has no way to enforce it. Turn the sign-in off for that app, or unbind the hostname, first. The global door has no such limit: your web-enabled gateways enforce the sign-in themselves.

From the command line:

Terminal window
dednetsctl domain front blog.example.com -front node -node alice/vps
dednetsctl domain front blog.example.com -front global
dednetsctl domain front blog.example.com -front global -hosts alice/gwa,alice/gwb
dednetsctl domain front blog.example.com -front edge

(Since v0.12.1.) On the global door the dialog also lists your web-enabled hosts with a checkbox each; untick one and that host stops serving the domain. Changing only that list is not the outage above: the hosts you keep carry on serving, the ones you removed stop at once, and you remove their A records. The button reads Apply rather than Move the domain in that case. See Fronting on your global web servers.

(Since v0.7.1.) If this DedNets console runs the browser check (a short “checking your connection” page that visitors pass once), it also blocks everything that is not a browser: curl, webhooks, API clients, uptime probes, CI. On a domain you own you can turn it off for a single hostname.

In the Console, the shield toggles it: on the Domains page, and on a service’s hostname list under Services, where each name carries a labelled Browser check switch. It reads n/a for a name fronted by your own entrypoint - our edge never sees that traffic, so there is nothing for it to check - and it is locked on for your DedNets hostname, which has no stored binding to keep a waiver on. From the command line:

Terminal window
dednetsctl domain bot-protection api.example.com off # API clients can reach it
dednetsctl domain bot-protection api.example.com on # put the check back
dednetsctl domain hosts # the BOT PROT column

Two things worth knowing:

  • It applies to that hostname only. Turn it off on api.example.com and the same service is still checked on its {app}-{username}.{domain} DedNets hostname. If you want your API reachable, publish it on your own name.
  • The DedNets hostname can never be exempted. That name is ours, and the check is what protects the shared edge from being used for scanning and phishing. Your domain is yours to decide about; ours is not.

The setting survives re-pointing the hostname at another of your services, and a hostname you have not bound yet always starts with the check on.

A CNAME is not allowed on an apex domain (example.com with no subdomain). For the shared edge, use the A records shown in the dialog instead. For your own node, an apex is always an A record to the node’s address.

To offer the My own node option, run the entrypoint role on one of your hosts (a VPS with a public IP and a local Caddy). That host then appears as a node front, and DedNets verifies the domain resolves to its address before pushing the site to it. Removing the domain removes the site from your node.

(Since v0.12.0.) Turn on the web option on one or more of your gateway hosts and the picker offers My Global Web Servers. Every one of those hosts then serves the domain over HTTPS: you publish one A record per host (the dialog lists them), and DedNets treats the domain as pointing correctly as soon as it resolves to any of them. One host obtains the Let’s Encrypt certificate and shares it with the others end-to-end, so your certificate keys never pass through our servers in the clear; the hosts keep serving through a DedNets outage. Apps that require a sign-in stay protected on this door. The per-browser bot check is not available on it.

(Since v0.12.1.) You can also pick which of those hosts serve a domain. Under My Global Web Servers the dialog lists every web-enabled host of yours with a checkbox; leave them all ticked and every host serves the domain (the default, and what every existing domain keeps doing), or untick the ones that should not. The choice is per domain, so every hostname under it follows it. A host you leave out is told nothing about the domain: it never receives the certificate’s key, and if it was serving the domain before it deletes the certificate and key it held. The DNS at your registrar card then lists an A record only for the hosts you picked, and if the name still resolves to a host you removed the check passes but names that record so you can remove it. Turning the web option off on a picked host, or removing that host, does not widen the domain to the others: it is simply served by fewer hosts until you edit the list, and the dialog marks such a host no longer available.

Terminal window
dednetsctl domain add shop.example.com -front global -hosts alice/gwa,alice/gwb
dednetsctl domain front shop.example.com -front global -hosts alice/gwb # gwa stops serving it now
dednetsctl domain front shop.example.com -front global -hosts all # every web host again

(Since v0.13.4.) A domain on the DedNets edge door is served by your web-enabled gateways too, without moving it: every hostname bound under it gets its own certificate on each gateway, and a client that reaches a gateway with that name (a split-horizon resolver inside your network) is served there instead of at the edge. Public DNS stays on the edge and nothing about the domain changes. See Gateway hosts.

(Since v0.13.6.) You may also point such a domain at one of your gateways on the public internet, without moving it off the edge door: the pointing check passes when the name resolves either to the DedNets edge (the records the Console shows) or to any of your web-enabled gateways that serve the domain, and the certificate is then checked on the gateway your DNS names. A common shape is one CNAME hop you own (dednets.example.com) that every hostname points at, so switching between the edge and a gateway is a single record change. If the gateway a name points at is offline, the check reports that instead of failing the domain, so a reboot does not take your hostnames down. Before v0.13.6 a domain pointed at a gateway was deactivated within a few minutes and stopped being served anywhere.

(Since v0.4.5.) The whole flow also works from dednetsctl:

Terminal window
dednetsctl domain add blog.example.com -front edge
# prints the DNS records to create; re-running it prints them again
dednetsctl domain verify blog.example.com
dednetsctl domain bind blog.example.com -target alice/vm1/blog

domain verify exits non-zero until the domain is live and serving a valid certificate, so a script can poll it. domain records re-prints a domain’s DNS records, domain list shows every domain with its state and how many hostnames are on it, domain hosts lists the hostnames themselves, domain unbind stops one, and domain rm removes a domain. For your own node, pass -front node -node user/daemon.

(Since v0.6.0) the domain is a positional argument on every one of these subcommands (the older -domain flag still works), and domain verify and domain list accept -json for a stable machine-readable form; (since v0.7.0) so do domain bind and domain hosts. domain list -json keeps its target field working for an exact domain and adds bound and hosts[]. See wildcards above for the CI recipe.

(Since v0.4.6) verification also probes the certificate your domain actually serves. If DNS is correct but the cert is not publicly trusted (for example your node’s [entrypoint] section sets issuer = "acme-staging" or "internal"), the domain shows cert invalid in the Console and CLI with the exact fix - for a node, set issuer = "acme" (the default) and restart the daemon; the check re-runs automatically every 5 minutes. One gotcha: Caddy caches issued certificates, so after switching issuers it may keep serving the old one - remove the old issuer’s directory under Caddy’s certificates/ storage and restart Caddy, and the next request obtains the production certificate.

On My Global Web Servers the probe handshakes a web server your domain actually resolves to (since v0.13.1; earlier releases could probe a host you never published an A record for, which is why a zone with a web server behind NAT could show cert invalid while every visitor got a valid certificate). A failure there names the host that was probed; dednetsctl gateway web shows that host’s certificate state and any issuance error.

The same four calls are on the stable automation API with a services:manage token (Script the API), and an AI agent with the DedNets MCP server can do it for you (Let an AI agent run DedNets).

Use the trash action on the Domains page, or dednetsctl domain rm.

(Since v0.7.0) a domain that still has hostnames on it is not removed silently: the Console lists them and asks you to confirm with Delete domain and its hostnames, and the CLI refuses until you pass -force:

Terminal window
dednetsctl domain unbind api.lab.example.com # stop one hostname, keep the domain
dednetsctl domain rm '*.lab.example.com' -force # the domain AND every hostname on it

Routing stops immediately either way.

(Since v0.7.0.) Domains and services used to be attached in one step. Nothing you already had stops working:

  • Existing domains keep serving. When the Console starts, every verified exact domain is converted automatically: the hostname it was serving becomes a hostname bound to the same service, and the routing is unchanged.

  • A migrated wildcard keeps its catch-all. A wildcard that pointed a whole zone at one service keeps doing that for every hostname you have not bound explicitly, so nothing goes dark. It is the one case where the wildcard pattern itself shows up as a hostname on a service, badged catch-all. Bind the labels you want, then retire it.

    (Since v0.11.3.) Retire it from the Console. On Domains, open the zone and use Retire on the catch-all row; on Services, the same row in the hostname list carries the control. Either one confirms first, listing the hostnames that keep serving and warning that every other name under the zone starts returning 404 — so bind a hostname for the app the catch-all is carrying before you retire it, and nothing changes for that app. Retiring is not a delete: the domain, its verification and its bindings are untouched. Before v0.11.3 this needed support.

  • dednetsctl domain add -target ... now fails with exit 2 and prints the commands that replace it (domain add, domain verify, domain bind). It makes no changes at all, so a stale script fails cleanly rather than half migrating. Update the CLI to v0.7.0 or later at the same time as your scripts.

  • domain list’s TARGET column is now HOSTNAMES, a count. Use domain hosts for the names. Scripts reading domain list -json | jq -r .[].target keep working for exact domains.