Back to guides
How-to · Management Plane

Zero Open Admin Ports.

This server runs a public DNS service, an AdGuard Home admin panel, a Grafana dashboard and an SSH daemon. Scan it from the outside and you will find exactly one thing: the DNS service. The panel answers two specific devices over Tailscale and resets everyone else before a single byte of content; port 22 is open in the kernel and unreachable from the internet. None of it needs custom software - stock AdGuard Home, nginx, nftables and Tailscale. This guide walks through the three gates, verifies each one with a command you can copy, and ends with the one-command version for a Raspberry Pi at home.

By Ozy-666, creator and operator of dnsdoh.art · Published · 16 min read

Scan your own server first

Before changing anything, look at your server the way everyone else on the internet does. Run a plain nmap your-server-ip from your own machine - any machine that is not the server - or use an online scanner such as pentest-tools.com's port scanner if you have nothing installed. That is all the scanning this guide needs; the scan is a mirror, not the topic.

A default AdGuard Home install on a VPS shows two things it should not: 22/tcp open ssh and 3000/tcp open - the admin panel, listening on 0.0.0.0, serving its login page to any address that asks. Add Grafana or any other dashboard and the list grows. Every one of those is a login form facing the whole internet, which means every one of them is a target for credential stuffing and whatever the next authentication bug happens to be. We took a different position, and it is the position this whole guide builds: a listener nobody can reach beats a login page with a strong password.

We scanned ourselves for the same reason we did in the amplification post-mortem: the view from outside is the only view that counts. The rest of this article is how our own scan got to the state you will see at the end - four service ports and nothing else.

The goal state

One sentence: the management plane is reachable from the operator's devices and does not exist for anyone else. Not firewalled-with-a-403, not rate-limited, not hidden on a weird port number - a stranger's connection is reset before any response, and a stranger's SSH packet is dropped without a reply. Meanwhile you open https://agh.your-domain.example on your laptop and the panel is just there, with a valid certificate, no VPN client ritual beyond Tailscale running in the background.

Three gates stack up to produce that, and each one is independently useful: your own resolver answers the panel's hostname with a private tailnet address (gate 3), nginx only proxies requests whose source address is on a two-entry device allowlist (gate 2), and the panel itself is bound to localhost so no route reaches it directly (gate 1). SSH skips the web stack entirely and hides behind one nftables rule. The diagram shows both journeys.

EVERYONE ELSE YOUR DEVICES · TAILSCALE any client or scanner somewhere on the internet dig agh.dnsdoh.art → 194.180.189.33 public record exists only for the certificate nginx :443 geo $tailscale_allowed = 0 source IP is not on the device allowlist return 444 connection reset, no content, no banner nc 194.180.189.33 22 nftables input policy drop no reply · timeout laptop · phone 100.64.0.1 / 100.64.0.2 dig agh.dnsdoh.art → 100.64.0.10 your own resolver answers the tailnet IP 3 nginx :443 · via tailscale0 geo $tailscale_allowed = 1 device IP is on the allowlist → proxy 2 AGH panel · 127.0.0.1:3000 bound to localhost, only nginx reaches it 1 ssh 100.64.0.10 iifname "tailscale0" accept sshd answers

Gate 0: Tailscale, in two commands

Everything below rides on a Tailscale tailnet - a WireGuard mesh where each of your devices gets a stable private address in 100.64.0.0/10. This is not a Tailscale tutorial; on the server it is genuinely two commands, and the official install docs cover every platform:

on the server
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up

# then note the addresses of every device on your tailnet:
tailscale status

Write down two kinds of address from that output: the server's own tailnet IP (ours is 100.64.0.10 throughout this article), and the IPs of the devices you actually manage from - for us a laptop, 100.64.0.1, and a phone, 100.64.0.2. Those two device addresses become the entire admin allowlist.

The flip side of the design, stated plainly: every device you manage from needs the Tailscale app installed and connected - Windows, macOS, Linux, Android or iPhone, all of them have one. From a machine that is not on your tailnet, or has Tailscale toggled off, the panel and SSH are simply unreachable. That is not a limitation to work around; it is the whole point working as intended.

Gate 1: bind the panel to localhost

Stock AdGuard Home listens on 0.0.0.0:3000 - every interface, including the public one. The first gate is one line in AdGuardHome.yaml: make the panel listen on loopback only, so no packet from any network can reach it directly, whatever the firewall does.

AdGuardHome.yaml
http:
  address: 127.0.0.1:3000

Restart AdGuard Home (systemctl restart AdGuardHome) and verify the gate on its own, before any nginx exists: curl http://your-public-ip:3000 from anywhere now gets connection refused. The panel still works - it is just only visible from inside the box, which is exactly where nginx will pick it up.

(We run a private fork that serves the panel on a unix socket instead of a TCP port; same effect, and stock AdGuard Home does not need it - 127.0.0.1 does the job.)

Gate 2: an nginx vhost that resets strangers

Now the panel needs a way back out - for exactly two devices. nginx's geo module maps a client's source address to a variable at connection time, cheaper and earlier than any allow/deny list inside a location. This block lives in the http {} context of nginx.conf:

nginx.conf - http {} context
# TAILSCALE ALLOWLIST
geo $tailscale_allowed {
    default 0;
    100.64.0.1  1;   # laptop
    100.64.0.2  1;   # phone
}

Note what is not in that map: the tailnet's whole 100.64.0.0/10 range. Allowlisting the range would grant the panel to every device that ever joins your tailnet - the TV, a test VM, a friend's laptop you once shared a node with. Tailnet growth should not silently grow the admin allowlist; two explicit device IPs mean adding an admin device is a deliberate one-line edit.

The vhost itself is short enough to publish whole. This is the production file, with only the certificate paths genericized:

sites-available/agh.dnsdoh.art.conf - complete
# ============================================================
# AGH Admin Panel - agh.dnsdoh.art
# ============================================================

server {
    listen 80;
    server_name agh.dnsdoh.art;

    # ACME http-01; the 301 must live in location /, a server-level
    # return would swallow the challenge path.
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/acme;
        default_type text/plain;
        try_files $uri =404;
    }

    location / {
        return 301 https://agh.dnsdoh.art$request_uri;
    }
}

server {
    listen 443 ssl;
    listen 443 quic;
    http2 on;

    server_name agh.dnsdoh.art;

    ssl_certificate     /path/to/ssl/fullchain.pem;
    ssl_certificate_key /path/to/ssl/privkey.pem;
    ssl_trusted_certificate /path/to/ssl/chain.pem;

    # ... your ssl_protocols / ciphers / session settings ...

    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # -- Proxy to the AGH panel on loopback --------------------
    location / {
        if ($tailscale_allowed = 0) { return 444; }
        proxy_pass http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_buffering off;
        proxy_read_timeout 60s;
        proxy_send_timeout 30s;
        client_max_body_size 10M;
    }
}

Four lines carry the design; the rest is ordinary proxying.

  • if ($tailscale_allowed = 0) { return 444; } - the gate itself. nginx's status 444 is special: it closes the connection without sending anything. A stranger gets a TCP reset, not an error page - no server banner, no TLS-level clue about what lives behind the name. (This is one of the rare cases where if inside a location is safe: a plain return, nothing else.)
  • listen 80 block - exists for one reason only: the ACME http-01 challenge that keeps the certificate renewing. The challenge location must come before the redirect, and the redirect must live in location / rather than a server-level return, or it would swallow the challenge path.
  • proxy_pass http://127.0.0.1:3000/ - hands the request to gate 1's loopback listener. nginx is the only thing on the box that can.
  • X-Real-IP $remote_addr - so the AGH access log shows which of your devices did what, instead of 127.0.0.1 for everything.

Reload nginx and verify all three views. From an allowlisted device, the panel answers:

from the laptop (100.64.0.1) - expect 200
curl -s -o /dev/null -w "%{http_code}\n" https://agh.dnsdoh.art/
200

From anywhere else - another tailnet device that is not on the list, or any machine on the internet - the connection dies before content. This is a real capture, taken from a non-allowlisted address:

from a non-allowlisted address - the 444 in the wild
$ curl https://agh.dnsdoh.art/
curl: (56) OpenSSL SSL_read: Connection reset by peer, errno 104

# over HTTP/2 the same gate shows up as a stream reset:
* HTTP/2 stream 1 reset by server (error 0x1 PROTOCOL_ERROR)

Gate 3: the DNS split view

Here is the part that surprises people. Ask public DNS where the panel lives and you get the server's ordinary public address. Ask our own resolver - the same one this whole site is about - and you get the tailnet address instead. Both captures are real, taken minutes apart:

the same name, two truths
# the public view - what the rest of the internet resolves:
$ dig +noall +answer agh.dnsdoh.art @8.8.8.8
agh.dnsdoh.art.   300   IN   A   194.180.189.33

# the view from our own resolver - what our devices resolve:
$ dig +noall +answer agh.dnsdoh.art @dnsdoh.art
agh.dnsdoh.art.   300   IN   A   100.64.0.10

The mechanism is an AdGuard Home DNS rewrite - three lines of yaml, or Filters → DNS rewrites in the panel:

AdGuardHome.yaml - filtering section
rewrites:
  - domain: agh.dnsdoh.art
    answer: 100.64.0.10
    enabled: true

Since our devices already use this resolver for everything, they resolve the panel's name to the tailnet IP automatically and the connection travels inside the WireGuard mesh, arriving at nginx from a 100.64.0.x source address - which is what lets gate 2's allowlist match at all. This is classic split-horizon DNS, done at the resolver you already run rather than with Tailscale's MagicDNS or a second DNS server. The public record still exists, and that is deliberate: it points at the public IP, where gates 1 and 2 answer with a reset. It is there for exactly one consumer - the certificate authority.

The zone side of this record (DNSSEC, CAA and friends) is covered in the hardened zone anatomy guide.

A note on the certificate

The panel serves a real, publicly trusted certificate - no browser warnings, no self-signed exceptions on every device. That works because the public A record plus the :80 ACME location are enough for a certificate authority's http-01 validation to succeed, even though every other visitor gets reset at gate 2. The CA validates, issues, renews; nobody else gets a byte. On this server the name simply rides the same 160-hour unified certificate as everything else.

If you would rather not have a public record for the name at all, that option exists too - Tailscale can issue a certificate for the device's ts.net name, which is how the Raspberry Pi variant at the end of this guide gets HTTPS with zero public DNS.

SSH: an open port nobody can see

SSH needs no vhost and no DNS trick - it gets the bluntest gate of all. The daemon itself stays completely stock, listening on every interface:

real capture - sshd listens on everything
$ ss -tlnp | grep :22
LISTEN 0  128  0.0.0.0:22  0.0.0.0:*  users:(("sshd",...))

What makes it invisible is the nftables input chain: the default policy drops everything, and the Tailscale interface is accepted wholesale before any public-facing rule runs. The relevant excerpt from our ruleset:

nftables - the two lines that hide port 22
chain input {
    type filter hook input priority filter; policy drop;
    iif "lo" accept
    iifname "tailscale0" accept
    # ... accepts for your public service ports (53, 443, 853) follow ...
}

Read it as two statements. policy drop: a packet that no rule accepts is discarded silently - no TCP reset, no ICMP rejection, nothing a scanner can distinguish from a dead host. iifname "tailscale0" accept: anything arriving through the WireGuard tunnel is let in, which includes SSH from your tailnet devices. The result, from outside:

port 22, two views
# from the internet - the SYN is dropped, nothing ever comes back:
$ nc -vz -w3 194.180.189.33 22
nc: connect to 194.180.189.33 port 22 (tcp) timed out

# from a tailnet device - the same port, instantly:
$ ssh 100.64.0.10
Welcome ...

Note the difference in failure modes between the two planes: the web gate resets (nginx must first read the request to know who is asking), the packet gate drops (nftables knows the source address before any handshake). Both end the same way - the service does not exist for strangers.

Before you enable a drop policy: test your break-glass path. Every VPS provider has an out-of-band console (VNC, serial, "emergency shell") that works even when the network locks you out. Log into it once, confirm it works, and only then reload the ruleset. If Tailscale ever breaks while you are away from the datacenter, that console is the way back in.

The scan, after

Back to the mirror. What actually answers on this server now - confirmed both by an outside scan and by the firewall ruleset itself, which is the source of truth a scanner can only approximate:

PortStateWhat it is
53 tcp+udpopenDNS
80 tcpopenACME challenge + redirect to 443, nothing else
443 tcp+udpopenHTTPS, DoH; HTTP/3 and DoH3 over QUIC
853 tcp+udpopenDoT; DoQ over UDP
22no replysshd running, dropped by policy
3000refusedpanel bound to 127.0.0.1
anything elseno replypolicy drop

Those four service ports are open because this is a public DNS service - they are the product. The point of the exercise was never to close ports; it was to make the list of open ports equal the list of things we intentionally serve the public, with the management plane not on it. Port 80 deserves its one honest line: it answers only the ACME challenge path and a redirect, because the certificate lineage renews over http-01.

One small aside if your scanner reports service names: those are fingerprint guesses, not facts - ours labels the DNS ports as a different resolver than what actually runs there. Trust your own ruleset over a scanner's guess column.

Same gate, next service

The reason to like this pattern is what the second service costs: almost nothing. Our Grafana dashboard sits behind the exact same three gates - a gr.dnsdoh.art vhost that reuses the same geo $tailscale_allowed map, a second three-line DNS rewrite, and a proxy_pass to Grafana's loopback port. No new firewall rules, no new allowlist, no new certificate lineage. Every admin surface you add from now on is a vhost and a rewrite away from being invisible.

The same idea on a Raspberry Pi at home

If your AdGuard Home runs on a Pi behind your home router, you need none of the nginx machinery - there is no public IP pointing at the box, so gates 2 and 3 solve a problem you do not have. What you probably do want is reaching the panel when you are not home, with real HTTPS. Tailscale collapses this guide into one command:

on the Pi - the whole setup
tailscale serve --bg --https=443 localhost:3000

tailscale serve terminates HTTPS on the Pi with an automatically provisioned certificate for the device's ts.net name, proxies to the panel on loopback, and serves it to your tailnet only - the allowlist is tailnet membership itself. --bg keeps it running across reboots. Keep gate 1 anyway (address: 127.0.0.1:3000), so the panel is not simultaneously answering every device on your home LAN.

That is the honest trade-off between the two setups: the VPS version needs the nginx gate because the machine has a public address by definition; the home version gets the same outcome from the fact that it never had one. Both end where this guide started - a management plane your devices can reach and nobody else can find.

Related reading: the DNS amplification post-mortem, anatomy of a hardened DNS zone, and running a 160-hour certificate.