post://own-your-namespace-private-dns-root-at-home

Own Your Namespace: Building a Private DNS Root at Home

• • read: 29 min • words: 5,636
toc://sections
outline

    The sketch started with one question: what if portfolio.swadhin was a real address on my network, not a folder on a laptop?

    It had a browser asking for an IP, a resolver at dns.openinternet.com, root servers at 10.10.10.11 and 10.10.10.12, a .swadhin TLD, and a service called git.swadhin on 10.10.10.2:8909. There were arrows everywhere and one word at the top: OPENINTERNET. It is the kind of diagram that looks like a joke until you realize every box is a real protocol and every arrow is a real delegation.

    The sketch says dns.openinternet.com; the project's docs use dns.openinternet.org. Same resolver, different spelling.

    The diagram is technically correct. It is also incomplete in the way every alternative-root diagram is incomplete: it shows the delegation, not the trust. A name is not yours because you wrote it in a zone file. It is yours when the resolver that a device actually uses agrees to ask your server. That distinction is the whole article.

    I built the sketch as a lab: two private roots, two .swadhin TLD servers, an Unbound resolver, a primary and a secondary for every zone, and a handful of services. The config below is what I tested. It works. It also breaks in specific, predictable ways, and those ways are more interesting than the happy path.

    The five-hop lookup

    DNS is a delegation system. A resolver starts at the root, gets a referral to a TLD, gets another referral to an authoritative server, and asks that server for the record. The root does not know the answer. It knows who does.

    These diagrams are wide. On a phone, pan them horizontally.

    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 480" width="100%" role="img" aria-label="DNS resolution walk">
    <title>DNS resolution walk</title>
    <desc>A browser asks a recursive resolver for portfolio.swadhin. The resolver gets a referral from the private root, asks the .swadhin TLD, reaches the authoritative server, and caches the answer.</desc>
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#a6adc8"/>
      </marker>
      <marker id="arrow-mint" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#a6e3a1"/>
      </marker>
      <marker id="arrow-peach" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#fab387"/>
      </marker>
    </defs>
    <rect x="0" y="0" width="1000" height="480" rx="16" fill="#181825"/>
    <text x="32" y="40" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="23" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >One lookup, five hops</text>
    <text x="32" y="66" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >The resolver walks from the private root to the authoritative server, then caches the answer.</text>
    <rect x="32" y="196" width="150" height="96" rx="10" fill="#1e1e2e" stroke="#89dceb" stroke-width="1.5"/>
    <text x="48" y="224" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >browser</text>
    <text x="48" y="248" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >stub resolver</text>
    <text x="48" y="268" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#89b4fa" text-anchor="start" opacity="1.0" >portfolio.swadhin</text>
    <text x="48" y="288" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >wants an IP</text>
    <rect x="230" y="172" width="190" height="136" rx="10" fill="#1e1e2e" stroke="#fab387" stroke-width="1.5"/>
    <text x="246" y="200" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >recursive resolver</text>
    <text x="246" y="224" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#a6e3a1" text-anchor="start" opacity="1.0" >10.10.10.53</text>
    <text x="246" y="244" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >Unbound</text>
    <text x="246" y="264" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >cache: empty</text>
    <rect x="470" y="92" width="200" height="104" rx="10" fill="#1e1e2e" stroke="#89b4fa" stroke-width="1.5"/>
    <text x="486" y="120" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >private root</text>
    <text x="486" y="144" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#89b4fa" text-anchor="start" opacity="1.0" >a.root.swadhin</text>
    <text x="486" y="164" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >10.10.10.11</text>
    <text x="486" y="184" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >a/b/c identities</text>
    <rect x="470" y="252" width="200" height="104" rx="10" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.5"/>
    <text x="486" y="280" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >.swadhin TLD</text>
    <text x="486" y="304" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#cba6f7" text-anchor="start" opacity="1.0" >ns1.swadhin</text>
    <text x="486" y="324" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >10.10.10.13</text>
    <text x="486" y="344" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >delegated by root</text>
    <rect x="740" y="252" width="224" height="104" rx="10" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
    <text x="756" y="280" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >authoritative</text>
    <text x="756" y="304" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#a6e3a1" text-anchor="start" opacity="1.0" >portfolio.swadhin</text>
    <text x="756" y="324" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#cdd6f4" text-anchor="start" opacity="1.0" >A 10.10.10.20</text>
    <text x="756" y="344" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >TTL 300</text>
    <rect x="740" y="394" width="224" height="54" rx="10" fill="#1e1e2e" stroke="#89dceb" stroke-width="1.5"/>
    <text x="756" y="422" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >service</text>
    <text x="756" y="446" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#89dceb" text-anchor="start" opacity="1.0" >https://portfolio.swadhin</text>
    <line x1="182" y1="212" x2="228" y2="212" stroke="#89dceb" stroke-width="1.6" marker-end="url(#arrow)"/>
    <path d="M 345 172 L 468 148" fill="none" stroke="#fab387" stroke-width="1.6" marker-end="url(#arrow)"/>
    <line x1="570" y1="196" x2="570" y2="250" stroke="#89b4fa" stroke-width="1.6" marker-end="url(#arrow)"/>
    <line x1="670" y1="304" x2="738" y2="304" stroke="#cba6f7" stroke-width="1.6" marker-end="url(#arrow)"/>
    <path d="M 740 336 C 640 408 500 408 420 306" fill="none" stroke="#a6e3a1" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#arrow)"/>
    <line x1="230" y1="276" x2="184" y2="276" stroke="#a6e3a1" stroke-width="1.6" marker-end="url(#arrow)"/>
    <text x="192" y="202" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#89dceb" text-anchor="start" opacity="1.0" >1 ask</text>
    <text x="440" y="180" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#89b4fa" text-anchor="end" opacity="1.0" >2 refer</text>
    <text x="582" y="226" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#cba6f7" text-anchor="start" opacity="1.0" >3 delegate</text>
    <text x="686" y="294" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6e3a1" text-anchor="start" opacity="1.0" >4 answer</text>
    <text x="548" y="392" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6e3a1" text-anchor="start" opacity="1.0" >5 cache + return</text>
    <circle r="6" fill="#f9e2af" opacity="0.95"><animateMotion path="M 182 212 L 230 212 L 345 172 L 470 148 L 570 148 L 570 252 L 670 304 L 740 304 L 850 336 C 700 400 500 400 420 306 C 320 306 184 276 184 212" dur="10s" begin="0s" repeatCount="indefinite" keyPoints="0;0.10;0.10;0.22;0.22;0.34;0.34;0.48;0.48;0.60;0.60;0.72;0.72;0.90;0.90;1" keyTimes="0;0.08;0.12;0.20;0.24;0.32;0.36;0.44;0.48;0.56;0.60;0.68;0.72;0.86;0.90;1" calcMode="linear"/></circle>
    <rect x="230" y="172" width="190" height="136" rx="10" fill="none" stroke="#fab387" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0;0.9;0.9;0;0" keyTimes="0;0.06;0.10;0.16;0.20;1" dur="10s" repeatCount="indefinite"/></rect>
    <rect x="470" y="92" width="200" height="104" rx="10" fill="none" stroke="#89b4fa" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0;0.9;0.9;0;0" keyTimes="0;0.18;0.22;0.30;0.34;1" dur="10s" repeatCount="indefinite"/></rect>
    <rect x="470" y="252" width="200" height="104" rx="10" fill="none" stroke="#cba6f7" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0;0.9;0.9;0;0" keyTimes="0;0.30;0.34;0.44;0.48;1" dur="10s" repeatCount="indefinite"/></rect>
    <rect x="740" y="252" width="224" height="104" rx="10" fill="none" stroke="#a6e3a1" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0;0.9;0.9;0;0" keyTimes="0;0.44;0.48;0.58;0.62;1" dur="10s" repeatCount="indefinite"/></rect>
    <line x1="852" y1="356" x2="852" y2="392" stroke="#89dceb" stroke-width="1.6" marker-end="url(#arrow)"/>
    <text x="864" y="378" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#89dceb" text-anchor="start" opacity="1.0" >reverse proxy</text>
    <text x="192" y="296" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6e3a1" text-anchor="start" opacity="1.0" >5 return</text>
    <text x="32" y="466" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >Yellow dot: the query. Dashed mint line: the cached answer on its way back.</text>
    </svg>
    

    The root zone is just another zone. It has an SOA, NS records, and glue. The public root has 13 named authorities and hundreds of instances behind them; IANA lists the identities and addresses. A private root is the same shape at a smaller scale: an SOA, NS records for the root servers, and a delegation for every TLD you want to serve.

    Here is the referral from my lab. The query is non-recursive, so the root only tells the resolver where to go next:

    $ dig @10.10.10.11 portfolio.swadhin +norecurse
    
    ;; ->>HEADER<<- opcode: QUERY, status: NOERROR
    ;; flags: qr; QUERY: 1, ANSWER: 0, AUTHORITY: 2, ADDITIONAL: 3
    
    ;; AUTHORITY SECTION:
    swadhin.        300  IN  NS  ns1.swadhin.
    swadhin.        300  IN  NS  ns2.swadhin.
    
    ;; ADDITIONAL SECTION:
    ns1.swadhin.    300  IN  A   10.10.10.13
    ns2.swadhin.    300  IN  A   10.10.10.14
    

    That is the entire trick. The root says: swadhin. is delegated to ns1.swadhin. and ns2.swadhin., and here are their addresses. The resolver then asks one of those servers:

    $ dig @10.10.10.13 portfolio.swadhin
    
    ;; ->>HEADER<<- opcode: QUERY, status: NOERROR
    ;; flags: qr aa; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
    
    ;; ANSWER SECTION:
    portfolio.swadhin.  300  IN  A  10.10.10.20
    

    The aa flag means the TLD server is authoritative for the answer. The root was authoritative only for the referral. The resolver caches both the answer and the referral, which is why the second lookup does not walk the tree again.

    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 390" width="100%" role="img" aria-label="Cold vs cached DNS lookup">
    <title>Cold vs cached DNS lookup</title>
    <desc>The first lookup walks from the resolver to the root, the TLD and the authoritative server. The second lookup is answered from the resolver cache.</desc>
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#a6adc8"/>
      </marker>
      <marker id="arrow-mint" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#a6e3a1"/>
      </marker>
      <marker id="arrow-peach" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#fab387"/>
      </marker>
    </defs>
    <rect x="0" y="0" width="1000" height="390" rx="16" fill="#181825"/>
    <text x="32" y="40" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="23" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >The tree is for the first query</text>
    <text x="32" y="66" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >Caching is why the hierarchy is not on the critical path of every request.</text>
    <text x="32" y="118" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13" font-weight="700" fill="#89b4fa" text-anchor="start" opacity="1.0" >first lookup (cold)</text>
    <rect x="150" y="138" width="110" height="54" rx="9" fill="#1e1e2e" stroke="#89dceb" stroke-width="1.4"/>
    <text x="205" y="170" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#89dceb" text-anchor="middle" opacity="1.0" >browser</text>
    <rect x="320" y="138" width="110" height="54" rx="9" fill="#1e1e2e" stroke="#fab387" stroke-width="1.4"/>
    <text x="375" y="170" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#fab387" text-anchor="middle" opacity="1.0" >resolver</text>
    <rect x="520" y="138" width="110" height="54" rx="9" fill="#1e1e2e" stroke="#89b4fa" stroke-width="1.4"/>
    <text x="575" y="170" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#89b4fa" text-anchor="middle" opacity="1.0" >root</text>
    <rect x="680" y="138" width="110" height="54" rx="9" fill="#1e1e2e" stroke="#cba6f7" stroke-width="1.4"/>
    <text x="735" y="170" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#cba6f7" text-anchor="middle" opacity="1.0" >TLD</text>
    <rect x="840" y="138" width="110" height="54" rx="9" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.4"/>
    <text x="895" y="170" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#a6e3a1" text-anchor="middle" opacity="1.0" >auth</text>
    <line x1="260" y1="165" x2="318" y2="165" stroke="#a6adc8" stroke-width="1.6" marker-end="url(#arrow)"/>
    <line x1="430" y1="165" x2="518" y2="165" stroke="#a6adc8" stroke-width="1.6" marker-end="url(#arrow)"/>
    <line x1="630" y1="165" x2="678" y2="165" stroke="#a6adc8" stroke-width="1.6" marker-end="url(#arrow)"/>
    <line x1="790" y1="165" x2="838" y2="165" stroke="#a6adc8" stroke-width="1.6" marker-end="url(#arrow)"/>
    <text x="289" y="132" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6adc8" text-anchor="middle" opacity="1.0" >1</text>
    <text x="474" y="132" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6adc8" text-anchor="middle" opacity="1.0" >2</text>
    <text x="654" y="132" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6adc8" text-anchor="middle" opacity="1.0" >3</text>
    <text x="814" y="132" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6adc8" text-anchor="middle" opacity="1.0" >4</text>
    <path d="M 900 192 C 900 220 420 220 395 192" fill="none" stroke="#a6e3a1" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#arrow-mint)"/>
    <text x="645" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="600" fill="#a6e3a1" text-anchor="middle" opacity="1.0" >answer + cache</text>
    <circle r="5.5" fill="#f9e2af" opacity="0.95"><animateMotion path="M 260 165 L 318 165 L 430 165 L 518 165 L 630 165 L 678 165 L 790 165 L 838 165" dur="6s" begin="0s" repeatCount="indefinite"/></circle>
    <text x="32" y="276" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13" font-weight="700" fill="#a6e3a1" text-anchor="start" opacity="1.0" >second lookup (warm)</text>
    <rect x="150" y="296" width="110" height="54" rx="9" fill="#1e1e2e" stroke="#89dceb" stroke-width="1.4"/>
    <text x="205" y="328" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#89dceb" text-anchor="middle" opacity="1.0" >browser</text>
    <rect x="320" y="296" width="150" height="54" rx="9" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.4"/>
    <text x="395" y="318" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="700" fill="#a6e3a1" text-anchor="middle" opacity="1.0" >resolver</text>
    <rect x="345" y="324" width="100" height="18" rx="4" fill="#181825" stroke="#a6e3a1" stroke-width="1"/>
    <text x="395" y="337" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="9" font-weight="700" fill="#a6e3a1" text-anchor="middle" opacity="1.0" >CACHE HIT</text>
    <line x1="260" y1="323" x2="318" y2="323" stroke="#a6e3a1" stroke-width="1.6" marker-end="url(#arrow)"/>
    <line x1="470" y1="323" x2="260" y2="323" stroke="#a6e3a1" stroke-width="1.6" marker-end="url(#arrow)"/>
    <circle r="5.5" fill="#a6e3a1" opacity="0.95"><animateMotion path="M 260 323 L 318 323 L 470 323" dur="2.5s" begin="0s" repeatCount="indefinite"/></circle>
    <rect x="338" y="320" width="114" height="26" rx="5" fill="none" stroke="#a6e3a1" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0.2;0.9;0.2;0" keyTimes="0;0.3;0.6;0.8;1" dur="2.5s" repeatCount="indefinite"/></rect>
    <text x="210" y="118" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" font-weight="700" fill="#89b4fa" text-anchor="start" opacity="1.0" >4 round trips</text>
    <text x="230" y="276" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" font-weight="700" fill="#a6e3a1" text-anchor="start" opacity="1.0" >1 round trip</text>
    <text x="32" y="374" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >Cold path: four delegations. Warm path: one cache lookup. The cache is the point.</text>
    </svg>
    

    This is also why the root is not the bottleneck people imagine. A cold query costs up to four round trips; a warm query costs one cache lookup. The hierarchy matters for correctness and delegation, not for every packet.

    Delegation, glue, and the thing that makes private roots work

    The root zone needs to know the addresses of the nameservers it delegates to. If those nameservers are inside the delegated zone, the root has a circular dependency: to resolve ns1.swadhin you need to reach swadhin, and to reach swadhin you need ns1.swadhin. Glue records break the loop. The root includes the A and AAAA records for ns1.swadhin and ns2.swadhin in the additional section, even though the authoritative copies live in the child zone.

    That is why a root server can answer a referral without being authoritative for the child. It is also why a private root is easy to start and hard to make consistent: every delegation needs glue, every glue record needs a matching authoritative record, and every mismatch is a future SERVFAIL.

    The public root is signed; yours is not

    The public root is signed with DNSSEC. A validating resolver has a trust anchor for the root's KSK, and every delegation below it can be validated down the chain. A private root has no such chain unless you build one. That is not a bug in DNS; it is the difference between a globally unique namespace and a local one.

    You have two honest choices:

    1. Mark the private zone insecure in your resolver (domain-insecure: "swadhin") and rely on the fact that only your LAN can reach it.
    2. Sign the private zone with your own key and install the trust anchor in every resolver that needs to validate it.

    Both work. They have different failure modes. Do not disable DNSSEC validation globally just to make a private name resolve; that is how a lab accident becomes a security incident.

    What OpenInternet adds

    OpenInternet, as the project describes itself, is a naming and DNS platform for private TLDs, authoritative DNS, and programmable resolvers. The tagline is "own your namespace." The resolver endpoints are dns.openinternet.org over DoH, DoT, DoQ, and plain DNS. The docs describe a CLI for creating a TLD, creating a zone, adding a record, signing it, and publishing it to every edge region.

    The important part is not the CLI. It is the model:

    • A user owns a namespace, such as .swadhin or .dex.
    • The user runs an authoritative server for that namespace, or delegates it to one.
    • The OpenInternet resolver knows about the private root and asks it first.
    • Everything else is forwarded to the public root.
    • The home server and the main DNS server keep their zones in sync, so an update made at home is visible to anyone using the opt-in resolver.

    The project's status page is explicit that there is no hosted service in this build. The docs describe the intended system, not a live public root. That honesty matters: a landing page that hides the NXDOMAIN is selling something that cannot work on install.

    That last point is the part the sketch is reaching for: git.swadhin does not have to be a local-only name. It can be a name that resolves for anyone who chooses the OpenInternet resolver, while remaining invisible to 8.8.8.8.

    sequenceDiagram
      participant Home as home primary
      participant Main as OpenInternet secondary
      participant Res as opt-in resolver
      Main->>Home: SOA query
      Home-->>Main: serial 2026102301
      Main->>Home: AXFR or IXFR with TSIG
      Home-->>Main: zone and RRSIG
      Main->>Res: updated zone available
      Res-->>Res: cache and serve
    

    The hard problem is stated plainly in the project's own docs: public resolvers are configured from the public root, and the public root has no delegation for .swadhin. So NXDOMAIN is the correct answer from Google DNS, Cloudflare, and every ISP resolver. Nothing short of a root delegation changes that. An alternative root only works for people who deliberately point at your resolver.

    That is not a bug to hide. It is the adoption problem to solve. The project's answer is to make opting in easy: a resolver, a client, a registry, and a sync path. The lab below is the same shape at home, with standard software, so you can see exactly what the resolver is doing.

    $ dig api.swadhin @8.8.8.8
    ;; status: NXDOMAIN
    
    $ dig api.swadhin @dns.openinternet.org
    ;; status: NOERROR
    

    The first answer is not a failure. It is the public internet being consistent.

    Three ways to get your own names at home

    There are three levels of "owning" a name. They solve different problems, and mixing them up is how people end up with a broken browser and a certificate warning they cannot click through.

    Level 1: the hosts file, or a dnsmasq rewrite

    The smallest version is a line in /etc/hosts:

    10.10.10.20  portfolio.swadhin
    10.10.10.2   git.swadhin
    

    Or a dnsmasq rule:

    address=/swadhin/10.10.10.20
    

    This is fine for one machine and one afternoon. It does not work on phones, it does not support wildcards, it does not give you a zone, and it teaches you nothing about delegation. It is a shortcut, not a namespace.

    Level 2: split-horizon DNS with a real domain

    You own example.com. You create home.example.com in the public zone, but you do not publish private A records publicly. Your local resolver is authoritative for home.example.com and returns 10.10.10.x for git.home.example.com. Outside your LAN, the same name returns NXDOMAIN (or a public placeholder). The public certificate is issued with a DNS-01 challenge, so it does not care that the A record is private.

    This is the level I recommend for anything a browser touches. It gives you:

    • Names that work on every device, because they are ordinary DNS names.
    • A publicly trusted certificate, because the name is in a public TLD.
    • No collision with a future gTLD, because you own the domain.
    • A clean split between the public view and the LAN view.
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 430" width="100%" role="img" aria-label="Split-horizon DNS">
    <title>Split-horizon DNS</title>
    <desc>The same name resolves to a private address inside the LAN and to NXDOMAIN or a public placeholder outside it.</desc>
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#a6adc8"/>
      </marker>
      <marker id="arrow-mint" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#a6e3a1"/>
      </marker>
      <marker id="arrow-peach" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
        <path d="M 0 0 L 10 5 L 0 10 z" fill="#fab387"/>
      </marker>
    </defs>
    <rect x="0" y="0" width="1000" height="430" rx="16" fill="#181825"/>
    <text x="32" y="40" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="23" font-weight="700" fill="#cdd6f4" text-anchor="start" opacity="1.0" >Same name, two answers</text>
    <text x="32" y="66" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >Split-horizon DNS keeps the public view and the LAN view apart.</text>
    <rect x="350" y="96" width="300" height="62" rx="10" fill="#1e1e2e" stroke="#313244" stroke-width="1.5"/>
    <text x="500" y="124" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="16" font-weight="700" fill="#cdd6f4" text-anchor="middle" opacity="1.0" >git.home.example.com</text>
    <text x="500" y="145" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" font-weight="400" fill="#a6adc8" text-anchor="middle" opacity="1.0" >one name, one question</text>
    <rect x="40" y="196" width="420" height="180" rx="14" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="1.5"/>
    <rect x="540" y="196" width="420" height="180" rx="14" fill="#1e1e2e" stroke="#fab387" stroke-width="1.5"/>
    <text x="64" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#a6e3a1" text-anchor="start" opacity="1.0" >inside the LAN</text>
    <text x="64" y="254" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >resolver 10.10.10.53</text>
    <text x="64" y="288" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="600" fill="#cdd6f4" text-anchor="start" opacity="1.0" >git.home.example.com</text>
    <text x="64" y="318" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21" font-weight="700" fill="#a6e3a1" text-anchor="start" opacity="1.0" >→ 10.10.10.20</text>
    <text x="64" y="346" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >reverse proxy in the closet</text>
    <text x="564" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700" fill="#fab387" text-anchor="start" opacity="1.0" >outside</text>
    <text x="564" y="254" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >resolver 1.1.1.1</text>
    <text x="564" y="288" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="600" fill="#cdd6f4" text-anchor="start" opacity="1.0" >git.home.example.com</text>
    <text x="564" y="318" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21" font-weight="700" fill="#fab387" text-anchor="start" opacity="1.0" >→ NXDOMAIN</text>
    <text x="564" y="346" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >no route to the closet</text>
    <line x1="500" y1="176" x2="500" y2="392" stroke="#313244" stroke-width="1.5" stroke-dasharray="5 5"/>
    <text x="500" y="186" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" font-weight="400" fill="#a6adc8" text-anchor="middle" opacity="1.0" >boundary</text>
    <path d="M 500 158 C 420 170 250 172 230 196" fill="none" stroke="#a6e3a1" stroke-width="1.6" marker-end="url(#arrow-mint)"/>
    <path d="M 500 158 C 580 170 750 172 770 196" fill="none" stroke="#fab387" stroke-width="1.6" stroke-dasharray="5 4" marker-end="url(#arrow-peach)"/>
    <circle r="6" fill="#a6e3a1" opacity="0.95"><animateMotion path="M 500 158 C 420 170 250 172 230 196" dur="5s" begin="0s" repeatCount="indefinite"/></circle>
    <circle r="6" fill="#fab387" opacity="0.95"><animateMotion path="M 500 158 C 580 170 750 172 770 196" dur="5s" begin="0.8s" repeatCount="indefinite"/></circle>
    <rect x="52" y="300" width="210" height="30" rx="6" fill="none" stroke="#a6e3a1" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0.4;1;0.4;0" keyTimes="0;0.2;0.5;0.8;1" dur="5s" repeatCount="indefinite"/></rect>
    <rect x="552" y="300" width="210" height="30" rx="6" fill="none" stroke="#fab387" stroke-width="2.5" opacity="0"><animate attributeName="opacity" values="0;0.4;1;0.4;0" keyTimes="0;0.2;0.5;0.8;1" dur="5s" repeatCount="indefinite"/></rect>
    <text x="32" y="410" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11.5" font-weight="400" fill="#a6adc8" text-anchor="start" opacity="1.0" >The name is not the answer. The resolver and the view are.</text>
    </svg>
    

    The cost is that you need a real domain and a public DNS provider that supports ACME DNS-01. That is a small cost compared with the certificate and client problems of a private TLD.

    Level 3: a private root and a private TLD

    This is the OpenInternet shape. You run a root zone, delegate .swadhin to your TLD servers, run authoritative servers for swadhin., and point your resolver at the private root. You can use any label you want. You do not need a registrar. You can resolve names with no internet connection at all.

    The cost is everything the previous levels avoid:

    • Every client must use your resolver.
    • Encrypted DNS can silently bypass it.
    • Public CAs will not issue certificates for the name.
    • The name can collide with a future public TLD.
    • You are now the registry, the abuse desk, and the on-call rota.

    That is not a reason not to do it. It is a reason to do it in a lab, with a real domain alongside it for the services that matter.

    Hosts file Split-horizon + real domain Private root + private TLD
    Setup time minutes an afternoon a weekend
    Works on phones no yes only with resolver configuration
    Publicly trusted TLS no yes, with DNS-01 no, unless you run a local CA
    Public reach no optional no
    Collision risk low none depends on the label
    Maintenance one file zone + resolver + ACME root + TLD + resolver + clients
    Best for a quick test home services you actually use learning, labs, opt-in namespaces
    flowchart TD
      A[Does the name need to work outside your LAN?] -->|Yes| B[Own a real domain]
      B --> C[Split-horizon DNS + DNS-01 wildcard]
      A -->|No| D{Do phones and browsers need HTTPS?}
      D -->|Yes| E[.internal + local CA]
      D -->|No| F[Private root + private TLD]
      C --> G[Certificate the client trusts, private A records]
      E --> G
      F --> H[Opt-in resolver only]
    

    Pick a namespace that will not bite you later

    The label matters more than the software.

    • .internal is reserved by ICANN for private use. The Board permanently reserved it from delegation on 29 July 2024 (resolution 2024.07.29.06). It will never be a public gTLD, so it is the safest vanity-free choice for a private DNS namespace.
    • .home.arpa is reserved for home networks by RFC 8375. It is longer, but it is explicitly designed for this and it is delegated to blackhole servers in the public root, so queries that leak do not reach a real authority.
    • .local is reserved for multicast DNS by RFC 6762. Do not use it for unicast DNS. Printers, Apple devices, and service discovery will fight you.
    • .dev, .app, .page, .new, and .day are HSTS-preloaded at the TLD level. Every HTTP request is upgraded to HTTPS before it leaves the browser. If you use one of these for a local name, you need a valid certificate before you can serve a single page. They are great for real sites and terrible for lab names.
    • .swadhin is fine inside your own root. It is not fine as a name you depend on, because a future gTLD round could delegate it, and because no public CA will issue for it today. Treat it as a lab namespace, not a public identity.

    If you want the OpenInternet shape without the collision risk, use .internal for the private TLD and keep a real domain for the browser-facing services. The private root can delegate internal. just as easily as swadhin..

    The lab

    I used 10.10.10.0/24 because that is what the sketch used. If your LAN already uses that subnet, change it everywhere; the protocol does not care. I tested with 172.28.10.0/24 in containers to avoid a Docker gateway collision, then translated the addresses back to the sketch for this article.

    Address Role Software
    10.10.10.11 root a PowerDNS authoritative
    10.10.10.12 root b PowerDNS secondary
    10.10.10.13 .swadhin TLD a PowerDNS authoritative
    10.10.10.14 .swadhin TLD b PowerDNS secondary
    10.10.10.53 recursive resolver Unbound
    10.10.10.20 portfolio / preview Caddy or nginx
    10.10.10.2 git Forgejo or Gitea on :8909
    10.10.10.30 api your app

    You do not need four physical machines. Two small VMs or containers per role are enough for a lab. The important part is that the root and the TLD are separate authorities, so you can see the referral. If you put both zones on one server, PowerDNS will answer from the child zone and the delegation step disappears from the output.

    The root zone

    $ORIGIN .
    $TTL 300
    
    @   IN SOA a.root.swadhin. hostmaster.swadhin. (
            2026102301 ; serial
            3600       ; refresh
            900        ; retry
            604800     ; expire
            300 )      ; negative TTL
    
    @           IN NS  a.root.swadhin.
    @           IN NS  b.root.swadhin.
    
    a.root.swadhin. IN A 10.10.10.11
    b.root.swadhin. IN A 10.10.10.12
    
    ; delegate the user namespace
    swadhin.    IN NS  ns1.swadhin.
    swadhin.    IN NS  ns2.swadhin.
    ns1.swadhin. IN A 10.10.10.13
    ns2.swadhin. IN A 10.10.10.14
    

    The a.root.swadhin and b.root.swadhin records are glue for the root's own nameservers. The ns1.swadhin and ns2.swadhin records are glue for the child. Both sets are necessary because both nameservers live below the root they serve.

    The .swadhin zone

    $ORIGIN swadhin.
    $TTL 300
    
    @   IN SOA ns1.swadhin. hostmaster.swadhin. (
            2026102301 ; serial
            3600       ; refresh
            900        ; retry
            604800     ; expire
            300 )      ; negative TTL
    
    @           IN NS  ns1.swadhin.
    @           IN NS  ns2.swadhin.
    
    ns1         IN A   10.10.10.13
    ns2         IN A   10.10.10.14
    a.root      IN A   10.10.10.11
    b.root      IN A   10.10.10.12
    
    portfolio   IN A   10.10.10.20
    portfolio   IN AAAA fd00:10:10::20
    preview     IN A   10.10.10.20
    git         IN A   10.10.10.2
    git         IN AAAA fd00:10:10::2
    api         IN A   10.10.10.30
    www         IN CNAME portfolio
    
    _https._tcp.git IN SRV 0 5 8909 git.swadhin.
    

    A few details that matter:

    • www is a CNAME, which is fine at a subdomain. Do not put a CNAME at the apex; use an ALIAS/ANAME if your DNS software supports it, or just an A/AAAA pair.
    • The SRV record documents the port for clients that understand SRV. Browsers do not. If you want https://git.swadhin without a port, put a reverse proxy on 443 in front of the service on 8909.
    • The AAAA records use a ULA prefix (fd00:10:10::/64). ULA is the IPv6 equivalent of RFC 1918; it is not globally routable, which is exactly what you want for a private namespace.
    • The negative TTL (the last SOA field) is 300 seconds. Do not set it to 0 just because you are iterating; negative caching is what stops a typo from hammering your root.

    Validate the child zone before you load it:

    $ pdnsutil check-zone swadhin
    0 errors, 0 warnings.
    

    The root zone is small enough that the referral itself is the best test. If dig +norecurse returns the NS and glue you expect, the zone is doing its job.

    The authoritative servers

    I used PowerDNS 4.9 with the BIND backend. The backend reads ordinary zone files, which keeps the whole lab reviewable in git. The primary serves the zone; the secondary transfers it with AXFR.

    /etc/powerdns/named.conf on the root primary:

    options {
      directory "/zones";
      also-notify { 10.10.10.12; };
    };
    
    zone "." {
      type primary;
      file "root.zone";
      also-notify { 10.10.10.12; };
    };
    

    /etc/powerdns/pdns.conf on the same host:

    local-address=0.0.0.0
    launch=bind
    bind-config=/etc/powerdns/named.conf
    primary=yes
    allow-axfr-ips=10.10.10.12,10.10.10.14
    also-notify=10.10.10.12
    loglevel=4
    

    primary=yes is the setting that bit me. In PowerDNS 4.9 it defaults to no. Without it, the zone loads and answers queries, but pdns_control notify refuses to queue a notification and the secondary never learns that the zone changed. The zone is not "primary" just because the file says type primary; the server has to be configured to act as one.

    The secondary:

    options { directory "/zones"; };
    
    zone "." {
      type secondary;
      file "slave/root.zone";
      masters { 10.10.10.11; };
    };
    
    local-address=0.0.0.0
    launch=bind
    bind-config=/etc/powerdns/named.conf
    secondary=yes
    loglevel=4
    

    The first transfer can be forced with pdns_control retrieve . on the secondary. After that, pdns_control notify . on the primary queues a NOTIFY and the secondary checks the SOA serial. The same pattern applies to the .swadhin zone with the TLD addresses.

    $ pdns_control retrieve .
    Added retrieval request for '.' from primary 10.10.10.11
    
    $ dig @10.10.10.12 . NS +short
    b.root.swadhin.
    a.root.swadhin.
    

    In production, restrict allow-axfr-ips to the secondaries, not the whole LAN, and add TSIG if your backend supports it. The BIND backend's AXFR security is IP-based; if you need signed transfers, check the backend's TSIG support or use the gsqlite3/PostgreSQL backend, and put the transfer on a private management network so the IP ACL is one layer of several.

    The resolver

    Unbound does two jobs in this lab: it answers for the private namespace, and it forwards everything else to public resolvers over DNS-over-TLS.

    server:
      interface: 0.0.0.0
      port: 53
      access-control: 10.10.10.0/24 allow
      access-control: 127.0.0.0/8 allow
      do-ip6: yes
      prefer-ip6: no
      qname-minimisation: yes
      harden-dnssec-stripped: yes
      cache-min-ttl: 60
      cache-max-ttl: 86400
      private-domain: "swadhin"
      domain-insecure: "swadhin"
      local-zone: "swadhin." nodefault
      tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
    
    forward-zone:
      name: "."
      forward-tls-upstream: yes
      forward-addr: 1.1.1.1@853#cloudflare-dns.com
      forward-addr: 9.9.9.9@853#dns.quad9.net
    
    stub-zone:
      name: "swadhin."
      stub-addr: 10.10.10.13
      stub-addr: 10.10.10.14
      stub-prime: no
    

    What each block is doing:

    • stub-zone sends queries for swadhin. directly to the TLD servers. This is a shortcut: the private root is still the delegation source, but the resolver does not need to ask it on every cold query. If you want to watch the full walk, query the root directly with dig +norecurse.
    • forward-zone "." sends everything else to Cloudflare and Quad9 over DoT. Unbound still caches and applies DNSSEC validation where the upstream provides it.
    • domain-insecure: "swadhin" tells Unbound not to expect a DNSSEC chain for the private zone. If you sign the zone, replace this with a trust-anchor for swadhin..
    • private-domain: "swadhin" allows private addresses in answers for that domain. Without it, Unbound's rebinding protection can filter the 10.10.10.x answers you just configured.
    • local-zone: "swadhin." nodefault stops Unbound from applying its default local-zone behavior to the name.
    • tls-cert-bundle is a server option, not a forward-zone option. Putting it in the wrong block is a syntax error; I know because I did it.

    Test both halves:

    $ dig +short @10.10.10.53 portfolio.swadhin
    10.10.10.20
    
    $ dig +short @10.10.10.53 example.com A
    104.20.23.154
    172.66.147.243
    

    If you want the resolver to walk the private root for every cold query, run a second Unbound instance with a private root hints file:

    .               3600 IN NS a.root.swadhin.
    .               3600 IN NS b.root.swadhin.
    a.root.swadhin. 3600 IN A  10.10.10.11
    b.root.swadhin. 3600 IN A  10.10.10.12
    
    server:
      root-hints: "/etc/unbound/private-root.hints"
      domain-insecure: "swadhin"
    

    That instance can only resolve the private namespace. It cannot also resolve example.com, because its root only knows about .swadhin. This is the architectural fork that alternative-root projects have to solve: either the private root mirrors the public root (and then DNSSEC gets complicated), or the resolver decides which root to ask based on the name (the "ours first, public fallback" model). At home, the stub-zone is the simpler version of the same decision.

    The cache, measured

    I ran 100 UDP queries per resolver from my laptop on 23 October 2026. The numbers are not a benchmark; they are a sanity check on where the time goes.

    {
      "type": "bar",
      "data": {
        "labels": ["cached answer", "split resolver (stub)", "full recursion (cold)"],
        "datasets": [{
          "label": "DNS round trips",
          "data": [1, 2, 4],
          "backgroundColor": ["#a6e3a1", "#89b4fa", "#cba6f7"],
          "borderRadius": 6
        }]
      },
      "options": {
        "responsive": true,
        "plugins": { "legend": { "display": false } },
        "scales": {
          "y": {
            "beginAtZero": true,
            "ticks": { "stepSize": 1 },
            "title": { "display": true, "text": "round trips" }
          }
        }
      }
    }
    
    {
      "type": "bar",
      "data": {
        "labels": ["8.8.8.8 (30.05 ms)", "1.1.1.1 (1.58 ms)", "9.9.9.9 (1.53 ms)", "local Unbound (0.01 ms)"],
        "datasets": [{
          "label": "median query time (ms)",
          "data": [30.05, 1.58, 1.53, 0.01],
          "backgroundColor": ["#f38ba8", "#89b4fa", "#89dceb", "#a6e3a1"],
          "borderRadius": 6
        }]
      },
      "options": {
        "indexAxis": "y",
        "responsive": true,
        "plugins": { "legend": { "display": false } },
        "scales": {
          "x": {
            "beginAtZero": true,
            "title": { "display": true, "text": "milliseconds" }
          },
          "y": { "ticks": { "autoSkip": false } }
        }
      }
    }
    

    The local bar is small because the resolver was on the same Docker bridge as the client. On a real LAN it will be a fraction of a millisecond to a couple of milliseconds, not 0.01. The point is not the exact number. The point is that the cache is doing the work, and the hierarchy is paid once.

    DHCP and the resolver handoff

    The best DNS server in the world is useless if the client never asks it. Hand out the resolver explicitly.

    With dnsmasq as a DHCP-only server:

    port=0
    dhcp-range=10.10.10.100,10.10.10.200,12h
    dhcp-option=6,10.10.10.53
    dhcp-option=15,swadhin
    dhcp-option=option6:dns-server,[fd00:10:10::53]
    

    port=0 disables dnsmasq's DNS listener; Unbound owns port 53. If you use dnsmasq as the DNS forwarder instead, forward the private zone explicitly and allow rebinding for it:

    server=/swadhin/10.10.10.13
    server=/swadhin/10.10.10.14
    rebind-domain-ok=/swadhin/
    

    If the router is your DHCP server, set the LAN DNS server in the router to 10.10.10.53 and make sure it is not also advertising the ISP resolver as a second option. A second DNS server is a fallback, not a preference: clients may use either one, and the one that does not know .swadhin will return NXDOMAIN.

    Clients are where private DNS goes to die

    Every client has its own resolver logic. The ones that matter are the ones that bypass DHCP without telling you.

    Client What to set What bypasses your resolver
    Linux (systemd-resolved) DNS=10.10.10.53, Domains=~. a per-link DNS setting or a VPN
    Linux (NetworkManager) ipv4.dns, ipv4.ignore-auto-dns yes a connection profile you forgot
    macOS networksetup -setdnsservers Wi-Fi 10.10.10.53 a DNS profile or a VPN
    Windows Set-DnsClientServerAddress Windows 11 DoH, if enabled
    Android Private DNS off or automatic a Private DNS hostname
    iOS Wi-Fi DNS settings or a DNS profile iCloud Private Relay for Safari
    Chrome Secure DNS off, or a custom provider you control Secure DNS automatic mode
    Firefox network.trr.mode = 0 TRR mode 2 or 3
    Edge Windows resolver settings Edge Secure DNS

    Linux

    With systemd-resolved:

    # /etc/systemd/resolved.conf
    [Resolve]
    DNS=10.10.10.53
    FallbackDNS=
    Domains=~.
    DNSOverTLS=no
    
    sudo systemctl restart systemd-resolved
    resolvectl status
    resolvectl query portfolio.swadhin
    

    Domains=~. makes this resolver the default route for all names. If you only want it for the private namespace, use Domains=~swadhin and keep your normal resolver for everything else. That is split DNS at the stub level, and it is often the least disruptive option on a work laptop.

    With NetworkManager:

    sudo nmcli con mod "Wired connection 1" \
      ipv4.dns "10.10.10.53" \
      ipv4.ignore-auto-dns yes \
      ipv6.dns "fd00:10:10::53" \
      ipv6.ignore-auto-dns yes
    sudo nmcli con up "Wired connection 1"
    

    macOS and iOS

    sudo networksetup -setdnsservers Wi-Fi 10.10.10.53
    scutil --dns | grep nameserver
    sudo dscacheutil -flushcache
    sudo killall -HUP mDNSResponder
    

    On iOS, set the DNS server manually for the Wi-Fi network, or install a DNS settings profile if you manage the device. A private TLD is not a good DoT hostname: iOS has to resolve the hostname before it can open the TLS connection, and the private name is exactly what it cannot resolve yet. Use a public hostname for the encrypted resolver, or keep plain DNS on the LAN.

    Windows

    Set-DnsClientServerAddress -InterfaceAlias "Ethernet" -ServerAddresses 10.10.10.53
    Clear-DnsClientCache
    Resolve-DnsName portfolio.swadhin
    

    Windows 11 can use DoH automatically for resolvers it recognizes. A local resolver is not on that list, so it usually stays on plain DNS, but Edge and Chrome can still make their own choice.

    Android

    Android's Private DNS setting has three modes: Off, Automatic, and Private DNS provider hostname. Automatic upgrades to DNS-over-TLS when the network's resolver supports it; if your Unbound only speaks plain DNS, the phone stays on plain DNS. If you enter a hostname, Android sends all queries over DoT to that hostname and marks the network as having no internet access if it cannot connect.

    That means:

    • dns.openinternet.org works as a Private DNS hostname if it is reachable and has a valid certificate.
    • dns.swadhin does not, unless Android can resolve it before it connects, which it cannot because it is the thing you are trying to configure.
    • If you want the phone to use your home resolver over the internet, give the resolver a public hostname and a public certificate, or use a VPN. Do not put a private TLD in the Private DNS field.

    Chrome, Firefox, and Edge

    Chrome's Secure DNS is on by default in automatic mode. If Chrome decides to use a DoH provider, it bypasses the operating system's resolver, including your Unbound server. For the lab, open chrome://settings/security, turn Secure DNS off, or set a custom provider that you control. A custom provider is a DoH URL with a valid certificate; https://dns.swadhin/dns-query will not work unless Chrome already trusts your local CA and can resolve the name.

    Firefox has its own switch. In about:config:

    • network.trr.mode = 0 disables TRR.
    • network.trr.mode = 2 uses TRR as a fallback.
    • network.trr.mode = 3 uses TRR only.

    If you run DoH on your own resolver, set network.trr.uri to your endpoint and make sure Firefox trusts the certificate. Firefox uses its own trust store on many platforms, so a local CA installed in the OS may not be enough.

    Edge follows Windows' resolver settings, but its Secure DNS feature can still make its own choice. Check edge://settings/privacy if a name resolves in dig but not in the browser.

    The one-minute test

    Before you blame the server, check which resolver the client is actually using:

    dig +short @10.10.10.53 portfolio.swadhin
    dig +short portfolio.swadhin
    resolvectl query portfolio.swadhin
    curl -v --resolve portfolio.swadhin:443:10.10.10.20 https://portfolio.swadhin/
    

    If the first command works and the second does not, the server is fine and the client is using a different resolver. That is the single most useful debugging fact in this entire article.

    TLS is the wall

    A private TLD can resolve perfectly and still be unusable in a browser. The browser does not care that your DNS works. It cares that the certificate chains to a trust anchor it recognizes.

    Public CAs are not allowed to issue certificates for internal names. The CA/Browser Forum's baseline requirements have prohibited new certificates for internal names and reserved IP addresses since November 2015 (CA/Browser Forum guidance). Let's Encrypt's own documentation says it cannot issue for localhost because nobody uniquely owns it and it is not rooted in a public TLD (Let's Encrypt). A private TLD has the same problem: there is no public delegation, no domain validation, and no chain.

    You have three options.

    1. A real domain with a DNS-01 wildcard (recommended)

    Use a subdomain of a domain you own, for example home.example.com. Keep the public zone at your registrar or DNS provider. Publish the _acme-challenge TXT record for DNS-01, but do not publish private A records. Your internal resolver answers *.home.example.com with 10.10.10.x.

    sudo apt install certbot python3-certbot-dns-cloudflare
    sudo certbot certonly \
      --dns-cloudflare \
      --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
      -d '*.home.example.com' -d home.example.com
    

    The certificate is publicly trusted because the ACME server only needs the TXT record. The A record can stay private. This is the pattern I use for anything that needs to work in a browser without a warning.

    Caddy with the existing certificate:

    git.home.example.com {
      tls /etc/letsencrypt/live/home.example.com/fullchain.pem /etc/letsencrypt/live/home.example.com/privkey.pem
      reverse_proxy 10.10.10.2:8909
    }
    

    If you want Caddy to manage the certificate itself, use a Caddy build with the DNS provider plugin, or run certbot on a timer and reload Caddy. The stock Caddy binary does not include every DNS provider.

    2. A local CA

    For internal tools that never leave the LAN, a local CA is fine. mkcert is the shortest path:

    mkcert -install
    mkcert git.swadhin portfolio.swadhin
    

    Install the generated root CA on every device that needs to trust it. Caddy can do the same thing without mkcert:

    git.swadhin {
      tls internal
      reverse_proxy 10.10.10.2:8909
    }
    

    caddy trust installs Caddy's local root on the machine where it runs. For other devices, export the root certificate from Caddy's data directory (~/.local/share/caddy/pki/authorities/local/root.crt on Linux) and install it in the system trust store.

    The limits are real:

    • Android apps that target Android 7 or later do not trust user-installed CAs by default. Chrome may, depending on the trust store and the version; do not assume every app will.
    • Firefox uses its own trust store on many platforms. Installing the root in the OS is not always enough.
    • A local CA is a new root of trust. If its private key leaks, every certificate it signed is compromised. Keep it offline or on a machine that is not your web server.
    • Certificate rotation is manual unless you automate it. A local CA without automation becomes a yearly outage.

    3. Plain HTTP, carefully

    Some services do not need a browser: SSH, git over SSH, a metrics endpoint behind a VPN. For those, plain HTTP or a private protocol is fine. Do not put a login form or a session cookie on plain HTTP, and do not enable HSTS on a lab name unless you are prepared to wait out the max-age if you change your mind.

    The cookie and public-suffix problem

    .swadhin is not on the Public Suffix List. A cookie set for .swadhin is sent to every service under that name. If portfolio.swadhin is compromised, it can set a cookie that git.swadhin will receive. Treat the whole private TLD as one trust domain, or use separate real subdomains for services with different trust levels. A real domain gives you the public-suffix boundary and the certificate ecosystem for free.

    Operations, security, and the things that fail at 2 a.m.

    Do not run an open resolver

    An open recursive resolver on the public internet is a amplification reflector. RFC 5358 explains the problem and the mitigations. Bind Unbound to the LAN, allow only your subnets, and firewall port 53 from the WAN.

    access-control: 10.10.10.0/24 allow
    access-control: 127.0.0.0/8 allow
    
    sudo ufw allow from 10.10.10.0/24 to any port 53 proto udp
    sudo ufw allow from 10.10.10.0/24 to any port 53 proto tcp
    

    If you need remote access, use WireGuard and keep DNS inside the tunnel. Do not expose port 53 to the internet so you can resolve git.swadhin from a coffee shop.

    Rate limit and monitor

    Put dnsdist in front of PowerDNS or Unbound if you need response rate limiting, query logging, or a single place to apply ACLs. At home, the LAN ACL is usually enough; the point is to know where the control point is.

    Useful checks:

    dig +short @10.10.10.11 . SOA
    dig +short @10.10.10.13 swadhin. SOA
    pdns_control show '*'
    # unbound-control requires remote-control: control-enable: yes
    unbound-control stats_noreset
    

    Alert on four things:

    • The secondary's SOA serial is behind the primary's.
    • SERVFAIL or REFUSED responses are rising.
    • The resolver's cache hit ratio is falling.
    • The authoritative servers stop answering on TCP as well as UDP.

    DNSSEC, if you want it

    Signing the private zone is possible. With PowerDNS:

    pdnsutil secure-zone swadhin
    pdnsutil show-zone swadhin
    pdnsutil export-zone-ds swadhin
    pdnsutil export-zone-dnskey swadhin <key-id>
    

    The BIND backend needs a DNSSEC database:

    bind-dnssec-db=/var/lib/powerdns/bind-dnssec.db
    
    pdnsutil create-bind-db /var/lib/powerdns/bind-dnssec.db
    

    Then put the trust anchor in Unbound and remove domain-insecure:

    trust-anchor: "swadhin. DNSKEY 257 3 13 <base64-key>"
    

    Test with dig +dnssec and look for the ad flag from the resolver. Remember that a private trust anchor only means something to resolvers you configured. It does not make the zone publicly valid, and it does not give you a public certificate.

    Backups and change control

    Keep the zone files in git. A DNS change is a code change: review it, test it, and be able to roll it back. Bump the SOA serial on every change; YYYYMMDDnn is easy to read and sort. Use pdnsutil check-zone in CI, and keep a copy of the DNSSEC keys somewhere that is not the primary server.

    A simple change flow:

    1. Edit the zone file in a branch.
    2. Run pdnsutil check-zone <zone>.
    3. Merge, bump the serial, reload the primary.
    4. Confirm the secondary transferred: dig @secondary <zone> SOA.
    5. Run a query for a known record from a client that uses the resolver.

    The failure modes I actually hit

    Symptom Cause Fix
    NXDOMAIN from 8.8.8.8 for .swadhin public root has no delegation expected; use your resolver
    dig works, browser searches browser treats the name as a search term, or uses DoH use the FQDN, disable Secure DNS
    SERVFAIL for public names after pointing at the private root the private root only knows the private namespace use a split resolver or forward public names
    Secondary has no zone primary=yes/secondary=yes missing, or AXFR ACL set the role, allow the secondary IP, pdns_control retrieve
    SERVFAIL for private names missing glue, wrong NS, or domain-insecure not set check the referral and the child zone
    Browser certificate warning private TLD has no publicly trusted cert real domain + DNS-01, or install the local CA
    Phone ignores the resolver Android Private DNS or iOS profile set Private DNS off/automatic, or use a public DoT hostname
    LAN client cannot reach a public name hairpin NAT or split-horizon mismatch return the private address internally, or enable NAT loopback
    Resolver returns a public IP for a private name split-horizon view not applied check the stub/forward order and the search domain

    The honest limits of an alternative root

    The lab works. That does not mean the model scales.

    DNS is a delegation system, and delegation is a social contract. The public root works because resolvers agree to start at the same root identities and follow the same chain. A private root works because you configured your resolver to start somewhere else. Both are technically valid. Only one is universally reachable.

    Alternative roots have three structural problems:

    1. The namespace is ambiguous. api.swadhin means one thing on your resolver and nothing on Google DNS. A user cannot tell which root they are on from the name alone. That is a phishing surface, a support burden, and a reason browsers and CAs are conservative.
    2. The trust is local. A private TLD cannot get a public certificate. A private root cannot be validated by a resolver that does not have your trust anchor. The moment you leave your network, the name stops being an identity and becomes a string.
    3. The operations are real. A root is not a zone file. It is a registry, an abuse desk, a trademark policy, a key ceremony, and an anycast network. ICANN is slow because coordination is hard and the alternatives have their own failure modes. A hobby root can skip all of that only because nobody depends on it.

    OpenInternet's honest framing is the right starting point: public resolvers return NXDOMAIN, and that is correct. The product cannot fix that from its side alone. What it can do is make opting in cheap: a resolver that checks the private root first, a client that configures itself, a registry that makes delegation a one-command operation, and a sync path that keeps a home authoritative server and the main service in step. That is a real product. It is not a replacement for the public root, and it does not need to be.

    The pragmatic architecture is the hybrid:

    • Own a real domain. Use it for anything a browser touches.
    • Delegate a subdomain to your home authoritative server. Keep the public view minimal.
    • Run split-horizon DNS so the LAN gets private addresses and the public gets NXDOMAIN or a placeholder.
    • Automate certificates with DNS-01.
    • Use a private TLD such as .internal for the lab, for SSH, for git, for metrics, and for names that never need to leave.
    • Use the OpenInternet-style private root when you want to learn how delegation works, or when you are building a namespace that people deliberately opt into.

    The open internet is not a single root that you replace. It is a set of protocols that let people run their own corner without asking permission. Running your own DNS is a good start. Running your own certificate authority, your own resolver, and your own registry is the rest of the lesson. The hard part is not the software. The hard part is deciding who should be able to resolve the name, and what happens when they cannot.

    A checklist for the first weekend

    1. Pick a namespace you can live with: .internal for private names, a real subdomain for browser-facing services, .swadhin only inside your own root.
    2. Draw the address plan. Root, TLD, resolver, services. Put the root and TLD on separate authorities so you can see the referral.
    3. Write the root zone and the child zone. Add glue for every nameserver. Check the child with pdnsutil check-zone.
    4. Run two authoritative servers per zone. Set primary=yes and secondary=yes, restrict AXFR, and test a transfer.
    5. Run Unbound with a stub for the private zone and a DoT forward for everything else. Mark the private zone insecure until you sign it.
    6. Hand out the resolver with DHCP option 6 and IPv6 RDNSS. Remove the ISP resolver from the list.
    7. Turn off Secure DNS in Chrome and Firefox for the lab. Check Android Private DNS. Test from a phone, not just the laptop.
    8. Put the browser-facing services behind a real domain and a DNS-01 wildcard. Use a local CA only for tools that never leave the LAN.
    9. Firewall port 53 from the WAN. Rate limit if you expose anything. Monitor the secondary serial and the resolver's cache.
    10. Write down what only works on your network. That list is the difference between a lab and a service.

    The sketch on my wall is not a replacement for ICANN. It is a reminder that DNS is a protocol, not a permission slip. You can run the same delegation logic in your closet, teach it to your devices, and learn more about the internet in a weekend than a year of reading. Just do not call it production until you have solved certificates, encrypted DNS, and the second server.

    The name is yours when the resolver agrees to ask. Everything else is configuration.

    react://own-your-namespace-private-dns-root-at-home
    comments://own-your-namespace-private-dns-root-at-home

    No comments yet.