DNS is not a firewall: splitting Traefik into LAN and public
Blog · networking

DNS is not a firewall: splitting Traefik into LAN and public

89 internal hostnames were one Host header away from the internet. Two entry points fixed it.

For months, my “internal only” services were one HTTP header away from the internet. 89 internal hostnames had no public DNS records, and I treated that as a wall. It was not. Anyone who sent a request to my public IP with the right Host header and TLS server name reached them, because the reverse proxy had no idea which requests came from the LAN and which came from outside.

I found it during an audit on 17 October 2026, proved it from the outside over Tor, and fixed it the same day by splitting Traefik into two entry points. This post is about why DNS is not an access boundary, how the split works, and the two testing mistakes that nearly sent me down the wrong path.

Contents · 11 sections

TL;DR #

  • 1 entry point served both the LAN and the internet. The router forwarded public HTTPS straight to it, so every route was reachable from outside if you knew its name.
  • 89 internal routes were exposed this way: 7 had an IP allowlist, 52 relied on single sign-on alone, and 30 had no protection at all.
  • 60 public routes now list a dedicated public entry point. The router forwards internet traffic only to that one. Internal routes never list it.
  • Test external access with curl --connect-to, not --resolve, and confirm with the entry point name in the access log.
  • A side effect worth knowing: a WireGuard phone that uses the router’s split-horizon DNS reaches public sites through the tunnel and the LAN entry point, at tunnel speed.

The setup, and the false assumption #

My lab runs on one machine with k3s and Traefik as the ingress controller. Some services are public: my website, a blog, a mail web client, a file share. Many more are for the house only: dashboards, admin panels, internal APIs, an MCP server for my local AI agent, a vector database.

The public ones have public DNS records. The internal ones live under a private domain that only the router’s DNS answers. The router forwarded the public HTTPS port to Traefik’s normal HTTPS entry point, the same one LAN clients used.

In my head, that was a split. In reality, the only thing separating “public” from “internal” was whether someone knew the name.

How the exposure works #

A reverse proxy picks a route from what the client sends: the TLS Server Name Indication (SNI) during the handshake and the HTTP Host header afterwards. Both are chosen by the client. DNS only helps a client find an IP address; once it has one, it can claim any name it likes.

So a request from anywhere on the internet to my public IP with Host: <internal-name> matched the internal route, exactly as if it had come from the kitchen. Nothing in Traefik’s configuration said “this route is only for LAN clients”.

And the internal names were not secret. One internal hostname had been printed 1,634 times on my public threat-intelligence pages, inside generated reports. Names leak; that is what they do.

Proving it from outside #

I tested from the internet side by routing curl through Tor and pinning the hostname to my public IP:

curl --socks5-hostname <tor-proxy> \
  --resolve <internal-name>:443:<public-ip> \
  https://<internal-name>/

Among the things that answered: the MCP server with 44 tools, 22 of them able to write, including one that publishes content; vector database collections with no API key; and status pages for backups and security tools. Some internal routes were behind single sign-on, which still held. Thirty were not.

Four days of access logs later showed no outside use. I was lucky, not safe.

The fix: two entry points #

The clean fix is to make the edge itself know where a request came from. Traefik entry points are listeners on separate ports. I added a second HTTPS entry point used only for internet traffic, changed the router to forward the public HTTPS port to it, and left the existing entry point for LAN and VPN clients.

In the Helm values, the shape is:

ports:
  websecure:            # LAN and VPN clients
    port: 443
    asDefault: true
  websecure-public:     # internet only; the router forwards here
    port: <public-port>
    expose:
      default: true
    exposedPort: <public-port>
    tls:
      enabled: true
      certResolver: <acme-resolver>

The public entry point also does not trust X-Forwarded-For from anyone, so an internet client cannot claim to be on the LAN through a header, and it has longer timeouts for large downloads.

asDefault: true matters: a route that does not name its entry points attaches only to the default ones, so a new route is internal unless someone deliberately makes it public.

Each public route then lists both entry points, so LAN users still reach it:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: <app>
  namespace: <namespace>
spec:
  entryPoints:
    - websecure
    - websecure-public
  routes:
    - match: Host(`www.example.com`)
      kind: Rule
      services:
        - name: <app>
          port: 80
  tls:
    certResolver: <acme-resolver>

Internal routes list only websecure. The rule I wrote down: a new public service must list the public entry point, and an internal route must never list it.

The Helm upgrade restarted Traefik (it uses the Recreate strategy), so every site had about 15 seconds of downtime. I kept a backup of the previous values and routes, and a one-line rollback for the router and Helm.

After the cut-over, the same Tor tests returned 403 for internal names and 200 for public ones. LAN behaviour did not change.

Mistake 1: a test that never tested what I thought #

During LAN testing I wanted to simulate an external client hitting the public port. I used --resolve with the new port:

# Wrong: the URL has no port, so curl connects to 443
curl -sk --resolve <internal-name>:<public-port>:<lan-ip> https://<internal-name>/

It returned 200 for internal hosts, which looked like the split was broken. I spent a long time chasing firewall rules and entry point names. The real problem: --resolve only applies to the host and port in the URL. My URL had no port, so curl connected to 443, the LAN entry point, and of course it answered.

The right tool is --connect-to, which redirects the connection but keeps the URL, and therefore the Host header and SNI, realistic:

curl -sk --connect-to <internal-name>:443:<lan-ip>:<public-port> https://<internal-name>/

Then confirm in Traefik’s access log that the request really arrived on the public entry point (entryPointName). A test is only a test if you verify which path it took.

Mistake 2: a regex I matched as a string #

To choose which routes to move, I classified hosts by substring: does the match rule contain my public domain? One route did not match: a catch-all that answers unknown subdomains with a friendly 404 page. Its rule is a HostRegexp with escaped dots, [a-z0-9-]+\.example\.com, so a plain substring search for example.com missed it.

Result: for a while, unknown public subdomains got the internal “blocked host” page from the internet instead of the public 404, until a guardrail check in my SSO admin tool flagged it.

Two lessons. Classify routes after unescaping regex patterns. And after any bulk change to routes, run an invariant check right away: every public host on the public entry point, no internal host on it, catch-alls accounted for.

A side effect: the slow phone #

The same day I noticed my phone downloading a 7.7 MB PDF from my own public site at about 200 KB/s. It took around three minutes.

The phone was on WireGuard, using the router for DNS. The router answers my public names with the server’s LAN address (split-horizon DNS), so the phone’s requests went through the tunnel and arrived on the LAN entry point. Every byte travelled over the VPN instead of straight over mobile data. Traefik’s log showed the request on the internal entry point, from a VPN-range client address.

Nothing was broken. It is the expected result of split-horizon DNS plus a full-tunnel VPN. Visitors without my WireGuard profile resolve public DNS and use the public entry point. If it bothers you, give the VPN profile a public DNS server for public names, or route only the LAN subnets through the tunnel.

Checklist #

  • Treat DNS as a convenience, never as access control: any client can send any Host header.
  • Give internet traffic its own entry point and make the internal one the default.
  • Make public exposure an explicit opt-in per route.
  • Keep SSO or allowlists on internal routes anyway; the split is one layer, not the only one.
  • Test from outside for real (a VPS, Tor, mobile data), and use curl --connect-to to simulate it.
  • Check the access log’s entry point to prove which path a request took.
  • After bulk route edits, run an invariant check and match regex rules after unescaping them.

Frequently asked questions #

Are services without public DNS records hidden from the internet? #

No. If your reverse proxy listens on a public IP, any client can send a request with any Host header and SNI, and the proxy will route it. A missing DNS record only means fewer people guess the name. Names leak through logs, certificates, reports and screenshots.

How do I separate internal and public routes in Traefik? #

Create two entry points on different ports, forward internet traffic only to one of them, and list that entry point only on routes meant to be public. Set the internal entry point as the default so new routes are private unless you opt in.

Why does curl --resolve not work with a different port? #

--resolve host:port:address only applies when the URL’s host and port match. If the URL has no port, curl uses 443 and ignores your entry. Use --connect-to host:443:address:port to send the connection elsewhere while keeping the original URL and Host header.

Why is my site slow over WireGuard but fast on mobile data? #

If your VPN clients use a DNS server that answers public names with internal addresses, their traffic to your public site goes through the tunnel. Throughput is then limited by the VPN path. Check the proxy’s access log for a VPN-range client address to confirm.

The lesson #

I had built the split between public and private in the one layer that cannot enforce it. DNS tells clients where to go; it does not decide who may enter. The fix was boring: a second listener, an explicit opt-in per route, and a router that only forwards to the public one. The testing mistakes were the more useful lesson. Both times the check looked fine and measured the wrong thing. Verify which path your test took before you believe its result.