Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Intermediate

Making Services Reachable Only Over VPN: Traefik + WireGuard + Internal DNS

A service that returns 403 from outside and works normally over VPN – same name, same valid certificate. IPAllowList, internal DNS and interface binding.

· 11 min read ·Duration: approx. 50 minutes
Table of contents

Not every service belongs on the open internet. An admin dashboard, a database interface, your private wiki – all of it should be reachable, but only by you. The most convenient route there is not “close the port” but a service that politely answers 403 from outside and works completely normally over the VPN.

What are we building?

We take a service that already runs behind Traefik and lock it down against anything that does not come from the WireGuard network. Afterwards the same name returns 403 Forbidden from outside and 200 over the VPN – with the same valid Let’s Encrypt certificate, no port numbers in the address bar and no certificate warning.

Three building blocks combine to do that, and it pays to keep their roles apart:

  • Traefik’s IPAllowList – the actual protection. Anything not coming from 10.8.0.0/24 is rejected.
  • Internal DNS via AdGuard Home – pure convenience. It makes the name resolve to the VPN address inside the tunnel instead of taking a detour via the public IP.
  • Binding to the VPN interface – the strictest level, for services that should not see a reverse proxy at all.

DNS is not protection

An internal DNS entry prevents nothing. Anyone who knows the public IP can set the Host header by hand and still reach your service. The protection comes exclusively from the allowlist or the interface binding – DNS only makes it convenient.

Requirements

  • A running WireGuard VPN on the 10.8.0.0/24 network, with the server holding 10.8.0.1
  • AdGuard Home on the same server, bound to 10.8.0.1:53
  • Traefik as a reverse proxy with a working Let’s Encrypt resolver
  • A service you want to keep internal. For practice, traefik/whoami is enough – it echoes every header it receives
🍳 Recommendation Ad

VPS 1000 G12.5

4 vCores · 8 GB RAM · 128 GB SSD

from €14.50/month

VPN, DNS and reverse proxy fit together on one VPS 1000.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 1000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

Step by step

Step 1: Put the service behind the allowlist

The core is two extra labels on the container you want to protect. To follow along we use whoami, because it prints every header it receives – that makes it immediately obvious later whether Traefik uses the right source IP:

YAML
# /opt/vpnonly/compose.yaml
services:
  whoami:
    image: traefik/whoami:v1.12.0
    container_name: whoami
    restart: unless-stopped
    networks: [proxy]
    labels:
      - traefik.enable=true
      - traefik.http.routers.whoami.rule=Host(`YOUR_DOMAIN`)
      - traefik.http.routers.whoami.entrypoints=websecure
      - traefik.http.routers.whoami.tls.certresolver=le
      - traefik.http.routers.whoami.middlewares=vpn-only@docker
      - traefik.http.middlewares.vpn-only.ipallowlist.sourcerange=10.8.0.0/24
      - traefik.http.services.whoami.loadbalancer.server.port=80

networks:
  proxy:
    external: true

The two decisive lines are the fifth and sixth. One defines a middleware called vpn-only, the other attaches it to the router. Both are required – more on that shortly.

Terminal
cd /opt/vpnonly && docker compose up -d

Note

In Traefik 2 this middleware was called ipwhitelist; since Traefik 3 it is ipallowlist. Older guides on the internet lead you nowhere here – Traefik simply creates no middleware for an unknown label and only mentions it in the log.

Step 2: The counter-test – the same name twice

Without VPN, from any machine on the internet:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_DOMAIN/
Ausgabe
403

And with the WireGuard tunnel up. Here it matters which tunnel you use: in the split tunnel from the WireGuard tutorial (AllowedIPs = 10.8.0.0/24) the name still points to the public IP. The request then runs past the tunnel and also gets 403 – the internal DNS in step 3 fixes that. Until then, tell curl by hand that the name should point to the server’s VPN address:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" --resolve YOUR_DOMAIN:443:10.8.0.1 https://YOUR_DOMAIN/
Ausgabe
200

With a full tunnel (AllowedIPs = 0.0.0.0/0, ::/0) it works without --resolve too, because then the path to the public IP also runs through the tunnel.

That is the whole trick. Both requests go to the same name, both get the same certificate – only the source IP decides. As a cross-check, look at what whoami reports from inside the VPN:

Terminal
curl -s --resolve YOUR_DOMAIN:443:10.8.0.1 https://YOUR_DOMAIN/ | grep -E "X-Real-Ip|X-Forwarded-For"
Ausgabe
X-Forwarded-For: 10.8.0.2
X-Real-Ip: 10.8.0.2

Traefik sees the client’s VPN address, not the Docker network’s. That is no accident: when publishing a port, Docker performs a pure destination translation (DNAT) and leaves the source address untouched. This is why the allowlist works even though Traefik itself runs in a container.

Step 3: Internal DNS – so the name points the right way inside the VPN

With --resolve it works, but you can’t teach that to a browser. Without internal DNS the client resolves the name to the public IP: in a split tunnel the request then ends up outside the VPN at the 403, in a full tunnel it takes a detour via the public address that breaks the moment you tighten the public ports on the server.

A DNS rewrite in AdGuard Home takes care of it. Open the web interface, go to Filters → DNS rewrites and click “Add DNS rewrite”:

The “Add DNS rewrite” dialog in AdGuard Home with a domain name in the upper field and the VPN address 10.8.0.1 in the lower field.
Enter domain and target IP – here the server's WireGuard address

The upper field holds the service’s name, the lower one the server’s VPN address, that is 10.8.0.1. A leading *. works too if you want to redirect a whole subdomain at once. After saving, the entry appears in the list:

The list of DNS rewrites in AdGuard Home with one enabled entry mapping a domain to 10.8.0.1.
The active rewrite – from now on the name points inward inside the VPN

Check the result against the resolver directly. Before the entry it returns the public address:

Terminal
dig +short @10.8.0.1 YOUR_DOMAIN
Ausgabe
203.0.113.10

And after it:

Terminal
dig +short @10.8.0.1 YOUR_DOMAIN
Ausgabe
10.8.0.1

Conveniently, AdGuard also suppresses the AAAA answer: as long as you only create an IPv4 rewrite, the client gets no IPv6 address for that name at all and therefore cannot accidentally bypass the tunnel over IPv6. The IPv6 counter-check has to come back empty:

Terminal
dig +short AAAA @10.8.0.1 YOUR_DOMAIN

Step 4: Hand the resolver to the clients

For the rewrite to take effect, the VPN clients have to actually use AdGuard. There is exactly one line for that in the WireGuard client configuration:

INI
[Interface]
Address = 10.8.0.2/24
DNS = 10.8.0.1
PrivateKey = YOUR_PRIVATE_CLIENT_KEY

When the connection comes up, wg-quick registers the server as the resolver and removes it again on disconnect:

Terminal
wg-quick up wg0
Ausgabe
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.8.0.2/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] resolvconf -a wg0 -m 0 -x
Terminal
cat /etc/resolv.conf
Ausgabe
# Generated by resolvconf
nameserver 10.8.0.1

The resolvconf -a wg0 line is the important part. If it is missing, wg-quick found no tool to change the resolver configuration. A minimal Debian 13 has neither openresolv nor systemd-resolved installed – without one of them DNS = has no effect and the rewrite never applies.

Step 5: Why the public DNS record has to stay

It is tempting to delete the public A record as well. Do not, as long as Traefik obtains its certificates via HTTP-01: Let’s Encrypt fetches http://YOUR_DOMAIN/.well-known/acme-challenge/… from the outside for that. Without a public DNS record and a reachable port 80, the next renewal attempt after 60 days fails – and you notice only once the certificate has expired.

What matters is that the allowlist only hangs on the application router and not on ACME handling: Traefik answers the challenge itself before any middleware applies. Requests from outside still get 403 for everything else.

Be clear about one thing though: the name of your internal service is public knowledge regardless. It is in DNS and additionally in the Certificate Transparency logs, which anyone can search. You cannot hide the name – only the access. If that bothers you, you need wildcard certificates via the DNS-01 challenge; then a single *.YOUR_DOMAIN entry ends up in the log and the individual hostnames stay private.

Step 6: The stricter level – bind to the VPN interface

For services that do not need HTTPS from outside anyway, there is a stricter route: do not publish the port publicly in the first place. Docker can bind any port to a specific address – and 10.8.0.1 is only reachable through the tunnel:

YAML
    ports:
      - "10.8.0.1:53:53/tcp"
      - "10.8.0.1:53:53/udp"
      - "10.8.0.1:8080:80/tcp"

Check what is actually being listened on:

Terminal
ss -tlnp | grep -E ":8080|:53 "
Ausgabe
LISTEN 0  4096  10.8.0.1:53    0.0.0.0:*  users:(("docker-proxy",pid=852377,fd=8))
LISTEN 0  4096  10.8.0.1:8080  0.0.0.0:*  users:(("docker-proxy",pid=852401,fd=8))

On the left is the concrete VPN address and not 0.0.0.0 – that is exactly the difference. Run the counter-test on the server itself against its public IP. From another machine the netcup firewall would reject port 8080 anyway, and the test would prove nothing:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://YOUR_SERVER_IP:8080/
Ausgabe
000

Not a 403 but no answer at all: there is nothing there to talk to. With "8080:80" instead of "10.8.0.1:8080:80" the same request answers 200 – that’s how you see the binding makes the difference. From the VPN, curl http://10.8.0.1:8080/ still returns 200.

When do you use which? Anything with a web interface that benefits from a reverse proxy (a nice name, a valid certificate, central middlewares) gets the allowlist. Anything only you operate and where raw protocols are involved – DNS, databases, admin ports, metrics endpoints – gets bound to the VPN interface directly. The two are not in conflict; they are two layers.

Tip

The allowlist combines with the other building blocks from hardening Traefik – BasicAuth on top of the IP restriction, for instance. Middlewares are applied in the order they are listed on the router.

When it doesn’t work

Requests from outside still get 200 instead of 403. Almost always the middleware is defined but not attached to the router. The two labels have different jobs: traefik.http.middlewares.vpn-only.ipallowlist.sourcerange=… creates it, traefik.http.routers.YOURROUTER.middlewares=vpn-only uses it. Leave out the second line and everything starts without errors while the service sits wide open – with no error message whatsoever. The @docker suffix is optional as long as middleware and router come from the same provider.

Even from inside the VPN you get 403. Then Traefik sees a different source IP than the VPN client’s. Check with curl -s https://YOUR_DOMAIN/ | grep X-Real-Ip against a whoami container: if it shows a Docker-internal address such as 172.19.0.x or a CDN’s IP, there is another proxy in front. In that case you have to tell Traefik via forwardedHeaders.trustedIPs which predecessor it may believe – without that setting it does not evaluate the header at all.

Inside the VPN the name still resolves to the public IP. The client is not using AdGuard. Check cat /etc/resolv.conf first – if it does not say nameserver 10.8.0.1, then DNS = 10.8.0.1 did not take effect. wg-quick needs a tool that can change the resolver configuration: either resolvconf from the openresolv package or resolvectl from the systemd-resolved package. A minimal Debian 13 ships neither, and then the line does nothing. Whether AdGuard itself answers correctly is a separate check: dig +short @10.8.0.1 YOUR_DOMAIN.

The certificate suddenly stops renewing. The ACME fetch needs the public path. If you deleted the A record in the meantime, closed port 80, or accidentally applied the allowlist globally on the web entrypoint, Let’s Encrypt can no longer reach the challenge. docker compose logs traefik | grep -i acme shows the failure – but only shortly before expiry, not immediately.

The service answers inside the VPN, but only sometimes. That is usually address-family ordering: as long as the name still returns an AAAA answer, the client tries IPv6 first – and lands outside the tunnel on the 403. Create the DNS rewrite for IPv4 only; AdGuard then returns no AAAA for that name at all, and dig +short AAAA @10.8.0.1 YOUR_DOMAIN has to stay empty.

You put a public IPv6 address into the allowlist and still get 403. Traefik compares against the address it actually sees – and over IPv6 that is the bridge gateway’s, as long as the proxy network was created without IPv6. Your real address never enters the comparison. Check it with curl -s -6 https://YOUR_DOMAIN/ | grep X-Forwarded-For from the allowed network: if it says 172.x.x.x, recreate the proxy network with --ipv6 (see Reverse proxy with Traefik). For the VPN case in this tutorial it makes no difference, because WireGuard runs over IPv4 here.

Your problem is not listed? Search all error messages →

Maintenance & backups

The setup is pleasantly low-maintenance: the allowlist is two lines in a Compose file, and the DNS rewrite is one entry in AdGuard. There is nothing here that breaks on its own.

What you do have to keep an eye on:

  • New services inherit nothing. Every additional container needs the middleware line on its own router. Put the two labels into your template, otherwise the next service sits open on the internet – and you will not notice, because everything works fine inside the VPN.
  • The VPN subnet is now security-relevant. Whoever gets a WireGuard peer gets access to every internal service. Remove retired devices from wg0.conf consistently and check once a quarter with wg show wg0 which peers actually had a recent handshake.
  • Certificates still travel the public path. After every change to the firewall, DNS or entrypoints it is worth verifying that Traefik can still renew – easiest with a test request to http://YOUR_DOMAIN/.well-known/acme-challenge/test, which has to return 404 and not 403.

There is little to back up here, but that little matters: the Compose files with the labels, /etc/wireguard/ including the keys, and the AdGuard directory with AdGuardHome.yaml, which holds the rewrites. All three belong in your Restic backup.

Realistic effort: set it up once, then just remember two lines for every new service. The quarterly ritual with the peer list takes five minutes.

You might also like