Full transparency: Links marked with (*) are affiliate links. Yes, we might earn a commission if you buy. No, that doesn’t mean we’re shilling garbage. We recommend what we’d actually use ourselves. Read our full policy.

The Problem Nobody Warns You About

You set up AdGuard Home on a VPS. You configure DNS-over-TLS on port 853. You point your router at it. It works. You feel good about it.

What you probably did not do: restrict who can actually reach port 853.

If your Hetzner (or any cloud) firewall has port 853 open to 0.0.0.0/0, your private DNS resolver is a public DNS resolver. Anyone who finds it — and they will — can use it. The more interesting problem is how they find it.

How Foreign Clients End Up in Your AdGuard Home Logs

Public DoT relay aggregators like mirrordns.xyz crawl the internet for open port 853 endpoints and advertise them as free public DoT servers. Your VPS IP gets listed. Clients from anywhere in the world start resolving DNS through your server.

The tell-tale pattern in AdGuard Home logs looks like this:

  • A client IP from a foreign ISP (e.g. a large Asian mobile carrier)
  • Repeated queries for a hostname like 49-13-86-156.dot.mirrordns.xyz
  • The subdomain encodes your server’s IP in reverse — it is the DoT bootstrapping hostname format, used by DoT clients to authenticate the server certificate before the encrypted session
  • Followed by a query for mirrordns.xyz itself, confirming the client is using the relay service
  • Followed by legitimate queries — baidu.com, google.com, whatever the user actually wants to resolve

This is not a brute-force attack. It is not port-scanning. Someone configured their device to use a public DoT resolver, that resolver happens to be your server, and now they are routing all their DNS through your infrastructure.

The security implications:

  1. You are providing free DNS service to strangers. That is the minor problem.
  2. You can see every domain they query. That is the privacy problem — theirs and yours.
  3. Your server’s reputation is tied to their queries. If they query malicious domains, that traffic originates from your IP.
  4. Your resolver’s blocklists apply to them. Or do not apply — depending on how AdGuard Home is configured.

Why You Cannot Just Restrict the IP

The obvious fix is to lock port 853 to your home IP in the firewall. The non-obvious problem is that most residential ISPs do not give you a static IP. Your home IP changes, and now you are locked out of your own DNS server.

The temptation here is to open port 53 instead — plain UDP DNS, no authentication, no encryption, no protection against amplification attacks. That is worse, not better.

A second temptation is to register a DDNS hostname and trust it. But that puts your home IP in a public DNS record, visible to anyone who looks it up. You have solved one problem by creating another.

The DS-Lite Complication

Modern ISPs in many European markets no longer issue public IPv4 addresses at all. Instead they use DS-Lite — your router gets a real IPv6 prefix, but IPv4 traffic is tunneled through the ISP’s AFTR gateway using carrier-grade NAT (CG-NAT).

Consequences:

  • Your router has no reportable public IPv4 address
  • Any DynDNS mechanism that tries to register an IPv4 record will fail or register the AFTR gateway address, which is shared across thousands of customers
  • Firewall rules based on IPv4 are useless for identifying your home connection
  • Your actual public identity on the internet is your IPv6 prefix

This is increasingly the default for residential connections in Germany and other markets. If you are setting up home-to-VPS connectivity, assume DS-Lite and design around IPv6 from the start.

The Actual Solution: Cloudflare Worker as DynDNS → Hetzner Firewall API

The goal: update a cloud firewall rule automatically whenever your home IP changes, without exposing your home IP in any public record.

Architecture:

FritzBox DynDNS trigger
  → Cloudflare Worker (authenticated endpoint)
    → Hetzner Firewall API (patch source_ips on tagged rule)

No public DNS record. No exposed IP. No polling delay beyond FritzBox’s own trigger interval (which fires on every reconnect).

Cloudflare Worker — the Worker receives a request from FritzBox’s built-in DynDNS client, validates a secret token, extracts the IPv6 address (and optionally IPv4 if available), then calls the Hetzner Cloud API to patch the firewall rule:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.searchParams.get('token') !== env.SECRET_TOKEN) {
      return new Response('Unauthorized', { status: 401 });
    }

    const ip4 = url.searchParams.get('ip');
    const ip6 = url.searchParams.get('ip6');

    if (!ip4 && !ip6) {
      return new Response('No IP provided', { status: 400 });
    }

    const ipv4Regex = /^\d{1,3}(\.\d{1,3}){3}$/;
    const ipv6Regex = /^[0-9a-fA-F:]{2,39}$/;

    if (ip4 && !ipv4Regex.test(ip4)) return new Response('Invalid IPv4', { status: 400 });
    if (ip6 && !ipv6Regex.test(ip6)) return new Response('Invalid IPv6', { status: 400 });

    // Fetch current firewall rules
    const fwRes = await fetch(
      `https://api.hetzner.cloud/v1/firewalls/${env.HETZNER_FIREWALL_ID}`,
      { headers: { Authorization: `Bearer ${env.HETZNER_API_TOKEN}` } }
    );
    const fwData = await fwRes.json();
    const rules = fwData.firewall.rules;

    const updatedRules = rules.map(rule => {
      if (rule.description !== 'home-dynamic') return rule;

      const existing = rule.source_ips || [];

      const newSources = [
        // Preserve whichever IP type we are NOT updating this request
        ...existing.filter(ip => {
          if (ip4 && !ip.includes(':')) return false; // drop old IPv4
          if (ip6 && ip.includes(':')) return false;  // drop old IPv6
          return true;
        }),
        ...(ip4 ? [`${ip4}/32`] : []),
        ...(ip6 ? [`${ip6}/128`] : []),
      ];

      return { ...rule, source_ips: newSources };
    });

    const patchRes = await fetch(
      `https://api.hetzner.cloud/v1/firewalls/${env.HETZNER_FIREWALL_ID}/actions/set_rules`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${env.HETZNER_API_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ rules: updatedRules }),
      }
    );

    const patchData = await patchRes.json();
    const ok = patchData.actions?.every(a => a.status !== 'error');
    return new Response(ok ? 'OK' : 'Error', { status: ok ? 200 : 500 });
  }
};

Key implementation detail: tag your port 853 Hetzner firewall rule with description: "home-dynamic". The Worker only touches that rule. All other rules — static allowlists, Tailscale CGNAT, Cloudflare IP ranges — are left untouched.

Why the IP merging logic matters: FritzBox on DS-Lite fires separate update requests for IPv4 and IPv6 changes. Without merging, the second request would overwrite the first, leaving only one address family in the rule. The filter preserves whichever type is not being updated in the current request.

FritzBox DynDNS URL (under Internet → Permit Access → DynDNS → User-defined):

https://your-worker.workers.dev/update?ip=<ipaddr>&ip6=<ip6addr>&token=YOUR_SECRET

On DS-Lite, <ipaddr> will be empty or the AFTR gateway — the Worker validates and skips it. Only the IPv6 update will succeed, which is all you need.

Worker secrets to configure:

wrangler secret put SECRET_TOKEN
wrangler secret put HETZNER_API_TOKEN
wrangler secret put HETZNER_FIREWALL_ID

Why Not Just Open Port 53?

Because DoT (port 853) over TCP gives you meaningful firewall control. Port 53 UDP is stateless — source IPs can be spoofed, the protocol is trivially abused for amplification attacks, and there is no encryption. You would be solving a configuration problem by downgrading your security posture.

The right answer after locking down port 853 is to configure your router to use DoT explicitly — not to fall back to plain DNS on port 53. FritzBox has supported DoT natively since FRITZ!OS 7.50 under Home Network → Network → DNS-over-TLS.

Summary: What to Actually Do

  1. Audit AdGuard Home logs — look for unknown client IPs, especially queries for *.mirrordns.xyz or similar relay hostnames
  2. Check your cloud firewall — is port 853 open to 0.0.0.0/0? Close it immediately
  3. Determine your ISP’s IPv4 model — are you on DS-Lite/CG-NAT? If yes, stop chasing IPv4 solutions
  4. Deploy the Cloudflare Worker — authenticated endpoint, no public IP record, Hetzner API integration
  5. Tag your firewall ruledescription: "home-dynamic" so the Worker can find it without touching anything else
  6. Configure DoT on your router — not plain DNS, not port 53 fallback

FAQ

What is DNS-over-TLS and why does it use port 853?
DNS-over-TLS (DoT) is an encrypted DNS protocol defined in RFC 7858. It wraps standard DNS queries in a TLS connection, preventing interception and manipulation. Port 853 is its designated port, as opposed to the unencrypted port 53.

What is DS-Lite and how does it affect my home network?
DS-Lite (Dual-Stack Lite) is a technology that lets ISPs share a single public IPv4 address across many customers using carrier-grade NAT, while giving each customer a real public IPv6 prefix. The practical effect is that your router has no publicly routable IPv4 address, making traditional IPv4-based DynDNS and firewall allowlisting ineffective.

How do DoT relay aggregators find open resolvers?
Services like mirrordns.xyz scan the internet for hosts responding to DoT on port 853 and index them as public resolvers. This is similar to how Shodan indexes open services — your open port is publicly crawlable.

Is AdGuard Home safe to self-host?
Yes, but only with proper network-level access controls. AdGuard Home itself is solid software. The risk is entirely in how you expose it — specifically whether the DNS ports are restricted to trusted clients or open to the world.

Why use a Cloudflare Worker instead of a cron job on the VPS?
A cron job polling a DDNS hostname adds latency (polling interval), requires the hostname to be publicly resolvable (exposing your home IP), and introduces a dependency on an external DDNS service. The Worker is event-driven — FritzBox triggers it on every IP change — and bypasses public DNS entirely.

Strategic Opposition Principle: The problem is rarely the open port. It is the assumption that opening a port for yourself means it is only open to you.