Running a domain that does not link back to you
This doc is for running a domain whose ownership should not be discoverable from public data, while still keeping its records in your own git-managed DNS and its traffic on your own servers. It covers what links a domain to its owner, the options that were tried, and the design that was built on 2026-10-30.
TL;DR:
- WHOIS privacy is the easy part. The links that give a domain away are addresses: the IP its nameservers resolve to, and the IP its A record points at. Passive-DNS services index every name ever seen on an address.
- Keep the zone on your own authoritative server as a hidden primary that refuses every public query for it, and publish it through a secondary whose address serves nothing else.
- Front the service with the same single-purpose host, and let the real origin accept only that host’s client certificate.
- On Fly.io this needs a separate app: one app cannot tell which of its dedicated IPv4 addresses a UDP packet was sent to, so it cannot serve a zone on one address and refuse it on the other.
What links a domain to its owner
Section titled “What links a domain to its owner”An observer who wants to know who runs quiet.example does not need anything private. Each row is a public lookup.
| Link | What the observer does | Closed by |
|---|---|---|
| Registrant data | WHOIS / RDAP | registrar privacy service (fields read REDACTED) |
| Nameserver address | dig NS quiet.example, then resolve the NS and look the address up in passive DNS | NS on an address used by no other zone |
| A / AAAA | dig A quiet.example, then passive DNS or a direct request to that IP | the same single-purpose address; the origin hidden behind it |
| Asking your own server | query your main authoritative server directly for quiet.example | the primary answers REFUSED for that zone |
| SOA | MNAME and RNAME in dig SOA | both names inside quiet.example |
| Certificates | certificate transparency search | CT records the name and the CA, not the ACME account; no ACME contact email |
| Mail headers | read the raw source of a mail sent from the domain | the mail provider’s own relay hops only, DKIM and Return-Path on quiet.example |
| Reverse DNS | PTR of the public address | the hosting provider’s generic PTR |
Two links stay open by design. The registrar, the hosting provider and the mail provider can each tie the domain to the accounts that pay for it. Nobody outside them can.
Options that fell short
Section titled “Options that fell short”| Option | Why it failed |
|---|---|
| Point the domain at your existing nameservers | Their address is the address of every other zone you serve. One passive-DNS query lists them side by side. |
| Cloudflare as a proxied secondary | Cloudflare secondary DNS is an Enterprise feature1, and proxying transferred records needs Secondary DNS override2. On the account that had Enterprise, a new zone inherited the account’s vanity nameservers (names under the owner’s own domain), which links the zone outright; a second Enterprise account refused to create a secondary zone (both observed on the author’s accounts, see Evidence). It also reintroduces a third-party DNS host the setup had just moved away from. |
| A second dedicated IPv4 on the existing Fly.io DNS app | Fly delivers UDP to the app on a special fly-global-services address and expects replies from it3. A probe app with two dedicated IPv4 addresses logged the destination the kernel reported for each UDP packet (IP_PKTINFO): queries sent to the two addresses arrived looking identical. The server cannot answer quiet.example on one address and refuse it on the other, and most DNS is UDP. |
| A public secondary from a free DNS host | Workable, and it hides the nameserver address. It leaves the A record to solve separately and adds a third party; the single-purpose host below solves both with one address. |
The design that held
Section titled “The design that held”- Hidden primary. The zone file lives with every other zone, in git, on the main authoritative server. That server is configured to answer nothing for
quiet.exampleon any query path; in knotea this is the per-zonehidden: trueoption, which takes the zone out of authoritative routing entirely (details in the knotea doc). Transfers still work, because they arrive over TCP with a TSIG signature and are relayed to knotd before the query path. - Single-purpose secondary. A small Fly.io app with its own dedicated IPv4 runs knotd as a TSIG secondary for this one zone. Its address is the glue for
ns1.quiet.exampleandns2.quiet.exampleat the registrar, and nothing else is ever served from it. Only IPv4 glue: Fly does not carry UDP over public IPv63. - Same host fronts the service. Caddy on that app terminates HTTPS for
quiet.example(ACME TLS-ALPN-01 on its own address, no account email) and reverse-proxies to the home edge, presenting a client certificate and the SNIquiet.example. The home address lives in an app secret, not in the repo. - The origin admits one client. The home edge’s
quiet.examplesite requires the client certificate from a private CA whose key was destroyed after issuing that one certificate (or a LAN address, for debugging). A direct request to the home address with the right SNI and no certificate gets 401.
Records
Section titled “Records”zone: quiet.examplerecords: - { name: '@', type: NS, content: [ns1.quiet.example., ns2.quiet.example.] } - { name: '@', type: A, content: 192.0.2.53 } - { name: ns1, type: A, content: 192.0.2.53 } - { name: ns2, type: A, content: 192.0.2.53 } - { name: mail, type: MX, content: 10 bounces.mail-provider.example. } # provider's bounce path - { name: mail, type: TXT, content: '"v=spf1 include:spf.mail-provider.example -all"' } - { name: _dmarc, type: TXT, content: '"v=DMARC1; p=none;"' } # no rua: reports would go to a named mailbox# SOA: ns1.quiet.example. hostmaster.quiet.example. ...The DMARC record carries no rua address, because aggregate reports have to go to a mailbox, and any mailbox you read names you.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- A hidden primary breaks the secondary’s refresh, not its first transfer. The secondary bootstrapped fine (AXFR was relayed), then every refresh failed with REFUSED: the refresh starts with a TSIG-signed SOA query, which is a plain QUERY and took the public path. knotea 1.5.4 relays TSIG-signed SOA queries to knotd too. knotd checks the signature before looking up the zone, so a wrong key gets NOTAUTH for every zone alike and does not reveal which zones exist (measured: hidden zone, non-existent zone and a public zone all answered NOTAUTH to a bogus key).
- The secondary can serve a stale copy that names the origin. Before the design settled, the zone briefly held an A record for the home address, and the secondary kept serving that copy until a forced retransfer. Nothing was delegated to it yet, so no ordinary lookup reached it. Delegate only after checking every record on the secondary itself.
- DNS-01 for the origin certificate must check propagation against the secondary. Caddy writes the challenge TXT to the primary over RFC 2136, then waits until it resolves. The hidden primary refuses that query, so point the propagation resolver at the public secondary’s address.
- Apex NS and SOA changes are refused over DNS UPDATE. RFC 2136 only makes the apex SOA and NS sets undeletable4; knotd went further and answered REFUSED to the replacement (measured). Change them with
knotc zone-begin/zone-set/zone-commiton the server, with a SOA serial above the current one. A record-sync tool that reads back through public queries will report REFUSED for the hidden zone even after a correct write; check drift with a dry run instead. - One app per secret address. The Fly UDP behaviour above is why. TCP arrives on the machine’s own interface and UDP on
fly-global-services, so the DNS server must listen on both addresses (knotd binds UDP and TCP per listed address). - Long-lived streams through the front need keepalives. Subscriptions over WebSocket or server-sent events that go quiet are closed by any proxy with an idle timeout. The service sends a ping or comment every 30 seconds; a stream left idle for 150 seconds through the front still delivered the next message (measured).
- Certificate logs keep history. A CT search showed a certificate for the name from before the move, issued for whatever was serving it then. It names nothing else, but CT is a log of everything that ever happened to the name, not only what you issued.
- Your own docs are the biggest leak. A public write-up, repo or config file that names the domain undoes all of the above at once. The repos that hold the zone, the app and the edge config are private; this page uses placeholders, and the docs repo blocks the real names in a pre-commit and pre-push check.
Evidence
Section titled “Evidence”| Claim | How it was checked | Status |
|---|---|---|
| Registrant fields redacted | whois on the real name | measured |
| Delegation and glue live | the TLD servers and three public resolvers returned the single-purpose NS and address | measured |
| Main server refuses the hidden zone | dig @primary SOA over UDP and TCP returned REFUSED | measured |
| Single-purpose server refuses other zones and identity queries | dig @secondary SOA for the other zones: REFUSED; version.bind, hostname.bind, id.server, NSID: empty | measured |
| Bad TSIG gives NOTAUTH for every zone | AXFR with a random key for the hidden zone, a non-existent zone and a public zone | measured |
| Secondary refreshes on NOTIFY | serial moved twice as the origin’s DNS-01 TXT was added and removed | measured |
| Origin refuses requests without the client certificate | from the internet: home address + SNI, no certificate 401, with certificate 200 | measured |
| Mail headers carry only the provider’s hops | raw source of a test message sent to a throwaway inbox; home address and own hostnames searched, 0 matches | measured |
| One Fly app cannot distinguish its IPv4 addresses for UDP | probe app with two dedicated IPv4s logging IP_PKTINFO destinations | measured |
No UDP over IPv6 on Fly; UDP via fly-global-services | Fly docs3 | cited |
| Cloudflare secondary is Enterprise-only; proxying needs override | Cloudflare docs12 | cited |
| A new secondary zone inherited the account’s vanity nameservers; a second Enterprise account refused secondary-zone creation | Cloudflare API responses on the author’s accounts | measured |
Decision guide
Section titled “Decision guide”If the domain only needs a private owner on paper, WHOIS privacy and a shared DNS host are enough. If it must not be traceable from public data, every address it shows has to be an address that shows nothing else: a hidden primary keeps your records where they already live, and a single-purpose host with its own IP carries both the nameservers and the front door. If you cannot run that host, a third-party secondary hides the nameservers but leaves the A record to solve.
Related docs
Section titled “Related docs”- knotea - the authoritative server that holds the hidden primary.
- Caddy on the NixOS edge - the edge that hosts the mTLS origin site.
- NixOS router network design - the home network behind that edge.
References
Section titled “References”-
Cloudflare, “Cloudflare as Secondary,” Cloudflare Docs. https://developers.cloudflare.com/dns/zone-setups/zone-transfers/cloudflare-as-secondary/ ↩ ↩2
-
Cloudflare, “Proxy traffic (Secondary DNS override),” Cloudflare Docs. https://developers.cloudflare.com/dns/zone-setups/zone-transfers/cloudflare-as-secondary/proxy-traffic/ ↩ ↩2
-
Fly.io, “Running fly.io apps on UDP and TCP,” Fly Docs. https://fly.io/docs/networking/udp-and-tcp/ ↩ ↩2 ↩3
-
P. Vixie et al., “Dynamic Updates in the Domain Name System (DNS UPDATE),” RFC 2136, IETF. https://www.rfc-editor.org/rfc/rfc2136 ↩