Dienste nur im VPN erreichbar machen: Traefik + WireGuard + interner DNS
Ein Dienst, der von außen 403 liefert und im VPN normal läuft – mit demselben Namen und gültigem Zertifikat. IPAllowList, interner DNS und Interface-Bindung.
Inhaltsverzeichnis
Nicht jeder Dienst gehört ins offene Netz. Ein Admin-Dashboard, eine Datenbankoberfläche, das
private Wiki – all das soll erreichbar sein, aber nur für dich. Der bequemste Weg dorthin ist nicht
„Port zu", sondern ein Dienst, der von außen höflich 403 sagt und über das VPN ganz normal
funktioniert.
Was bauen wir?
Wir nehmen einen Dienst, der schon hinter Traefik läuft, und sperren ihn für alles aus, was nicht aus dem WireGuard-Netz kommt. Danach liefert derselbe Name von außen 403 Forbidden und im VPN 200 – mit demselben gültigen Let’s-Encrypt-Zertifikat, ohne Portnummern in der Adresszeile und ohne Zertifikatswarnung.
Dafür kombinieren wir drei Bausteine, und es lohnt sich, ihre Rollen sauber zu trennen:
- Traefik-
IPAllowList– der eigentliche Schutz. Alles, was nicht aus10.8.0.0/24kommt, wird abgewiesen. - Interner DNS über AdGuard Home – reine Bequemlichkeit. Er sorgt dafür, dass der Name im VPN auf die VPN-Adresse zeigt, statt den Umweg über die öffentliche IP zu nehmen.
- Bindung an das VPN-Interface – die härteste Stufe für Dienste, die gar keinen Reverse Proxy sehen sollen.
DNS ist kein Schutz
Host-Header
von Hand setzen und landet trotzdem bei deinem Dienst. Der Schutz kommt ausschließlich aus der
Allowlist bzw. der Interface-Bindung – der DNS macht es nur bequem.Voraussetzungen
- Ein laufendes WireGuard-VPN mit dem Netz
10.8.0.0/24; der Server hat darin die10.8.0.1 - AdGuard Home auf demselben Server, an
10.8.0.1:53gebunden - Traefik als Reverse Proxy mit funktionierendem Let’s-Encrypt-Resolver
- Ein Dienst, den du intern stellen willst. Zum Üben reicht
traefik/whoami– der zeigt dir alle Header, die bei ihm ankommen
VPS 1000 G12.5
4 vCore · 8 GB RAM · 128 GB SSD
ab 14,50 €/Monat
VPN, DNS und Reverse Proxy passen zusammen auf einen VPS 1000.
💶 1 Monat gratis:
Einmal einlösbar, gilt für VPS 1000 G12.5. Im Warenkorb einlösen →
💶 5,95 € Gutschein für netcup-Neukunden: immer gültig · nicht für Domains und VPS Lite
Schritt für Schritt
Schritt 1: Den Dienst hinter die Allowlist stellen
Der Kern sind zwei zusätzliche Labels an dem Container, den du schützen willst. Zum Mitmachen
nehmen wir whoami, weil er alle empfangenen Header ausgibt – damit sieht man später sofort, ob
Traefik die richtige Quell-IP verwendet:
# /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(`DEINE_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: trueDie beiden entscheidenden Zeilen sind Nummer fünf und sechs. Die eine definiert eine Middleware
namens vpn-only, die andere hängt sie an den Router. Beides ist nötig – dazu gleich mehr.
cd /opt/vpnonly && docker compose up -dHinweis
ipwhitelist, seit Traefik 3 heißt sie ipallowlist. Ältere
Anleitungen im Netz führen dich hier ins Leere – Traefik legt bei einem unbekannten Label einfach
keine Middleware an und meldet das nur im Log.Schritt 2: Die Gegenprobe – zweimal derselbe Name
Ohne VPN, von irgendeinem Rechner im Internet:
curl -s -o /dev/null -w "%{http_code}\n" https://DEINE_DOMAIN/403Und mit aktivem WireGuard-Tunnel. Hier kommt es darauf an, welchen Tunnel du nutzt: Im
Split-Tunnel aus dem WireGuard-Tutorial (AllowedIPs = 10.8.0.0/24) zeigt der Name noch auf
die öffentliche IP. Die Anfrage läuft dann am Tunnel vorbei und bekommt ebenfalls 403 – das
richtet erst der interne DNS in Schritt 3. Bis dahin sagst du curl von Hand, dass der Name auf
die VPN-Adresse des Servers zeigen soll:
curl -s -o /dev/null -w "%{http_code}\n" --resolve DEINE_DOMAIN:443:10.8.0.1 https://DEINE_DOMAIN/200Mit einem Full-Tunnel (AllowedIPs = 0.0.0.0/0, ::/0) klappt es auch ohne --resolve, weil
dann auch der Weg zur öffentlichen IP durch den Tunnel läuft.
Das ist der ganze Trick. Beide Anfragen gehen an denselben Namen, beide bekommen dasselbe
Zertifikat – nur die Quell-IP entscheidet. Schau dir zur Kontrolle an, was whoami aus dem VPN
heraus meldet:
curl -s --resolve DEINE_DOMAIN:443:10.8.0.1 https://DEINE_DOMAIN/ | grep -E "X-Real-Ip|X-Forwarded-For"X-Forwarded-For: 10.8.0.2
X-Real-Ip: 10.8.0.2Traefik sieht die VPN-Adresse des Clients, nicht die des Docker-Netzes. Das ist kein Zufall: Beim Veröffentlichen eines Ports macht Docker eine reine Ziel-Übersetzung (DNAT) und lässt die Absenderadresse unangetastet. Deshalb funktioniert die Allowlist auch dann, wenn Traefik selbst in einem Container läuft.
Schritt 3: Interner DNS – damit der Name im VPN richtig zeigt
Mit --resolve funktioniert es, aber das kannst du keinem Browser beibringen. Ohne internen DNS
löst der Client den Namen zur öffentlichen IP auf: Im Split-Tunnel landet die Anfrage dann außerhalb
des VPN beim 403, im Full-Tunnel nimmt sie einen Umweg über die öffentliche Adresse, der schiefgeht,
sobald du am Server die öffentlichen Ports enger ziehst.
Erledigt wird das mit einer DNS-Umschreibung in AdGuard Home. Öffne die Weboberfläche und geh auf Filter → DNS-Umschreibungen, dann auf „DNS-Umschreibung hinzufügen":

Im oberen Feld steht der Name des Dienstes, im unteren die VPN-Adresse des Servers, also
10.8.0.1. Ein führendes *. funktioniert auch, wenn du gleich eine ganze Subdomain umbiegen
willst. Nach dem Speichern taucht der Eintrag in der Liste auf:

Prüf das Ergebnis direkt gegen den Resolver. Vorher liefert er die öffentliche Adresse:
dig +short @10.8.0.1 DEINE_DOMAIN203.0.113.10Und nach dem Eintrag:
dig +short @10.8.0.1 DEINE_DOMAIN10.8.0.1Praktischerweise unterdrückt AdGuard dabei auch die AAAA-Antwort: Solange du nur eine IPv4-Umschreibung anlegst, bekommt der Client für diesen Namen gar keine IPv6-Adresse mehr und kann damit nicht versehentlich über IPv6 am Tunnel vorbeilaufen.
Die Gegenprobe auf IPv6 muss ohne Ausgabe zurückkommen:
dig +short AAAA @10.8.0.1 DEINE_DOMAINSchritt 4: Den Resolver an die Clients verteilen
Damit die Umschreibung greift, müssen die VPN-Clients AdGuard auch benutzen. Dafür gibt es in der WireGuard-Client-Konfiguration genau eine Zeile:
[Interface]
Address = 10.8.0.2/24
DNS = 10.8.0.1
PrivateKey = DEIN_PRIVATER_CLIENT_SCHLUESSELBeim Verbindungsaufbau trägt wg-quick den Server als Resolver ein und nimmt ihn beim Trennen
wieder heraus:
wg-quick up wg0[#] 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 -xcat /etc/resolv.conf# Generated by resolvconf
nameserver 10.8.0.1Die Zeile resolvconf -a wg0 ist der wichtige Teil. Fehlt sie, hat wg-quick kein Werkzeug
gefunden, um die Resolver-Konfiguration zu ändern. Auf einem minimalen Debian 13 ist weder
openresolv noch systemd-resolved installiert – ohne eines davon bleibt DNS = wirkungslos, und
die Umschreibung greift nicht.
Schritt 5: Warum der öffentliche DNS-Eintrag bleiben muss
Es liegt nahe, den öffentlichen A-Record gleich mit zu löschen. Tu das nicht, solange Traefik seine
Zertifikate per HTTP-01 holt: Let’s Encrypt ruft dafür http://DEINE_DOMAIN/.well-known/acme-challenge/…
von außen ab. Ohne öffentlichen DNS-Eintrag und ohne erreichbaren Port 80 bekommst du beim nächsten
Erneuerungsversuch nach 60 Tagen kein Zertifikat mehr – und merkst es erst, wenn es abgelaufen ist.
Wichtig ist, dass die Allowlist nur am Anwendungs-Router hängt und nicht an der
ACME-Verarbeitung: Traefik beantwortet die Challenge selbst, bevor irgendeine Middleware greift.
Der Abruf von außen liefert für alles andere weiterhin 403.
Sei dir dabei über eines im Klaren: Der Name deines internen Dienstes ist trotzdem öffentlich
bekannt. Er steht im DNS und zusätzlich in den Certificate-Transparency-Logs, die jeder
durchsuchen kann. Verstecken kannst du den Namen nicht – nur den Zugriff. Wenn dich das stört,
brauchst du Wildcard-Zertifikate per DNS-01-Challenge; dann reicht ein einziger
*.DEINE_DOMAIN-Eintrag im Log, und die einzelnen Hostnamen bleiben unter sich.
Schritt 6: Die härtere Stufe – an das VPN-Interface binden
Für Dienste, die ohnehin kein HTTPS von außen brauchen, gibt es einen strengeren Weg: den Port gar
nicht erst öffentlich veröffentlichen. Docker kann jeden Port an eine bestimmte Adresse binden –
und 10.8.0.1 ist nur über den Tunnel erreichbar:
ports:
- "10.8.0.1:53:53/tcp"
- "10.8.0.1:53:53/udp"
- "10.8.0.1:8080:80/tcp"Kontrolliere, worauf tatsächlich gelauscht wird:
ss -tlnp | grep -E ":8080|:53 "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))Links steht die konkrete VPN-Adresse und nicht 0.0.0.0 – genau das ist der Unterschied. Mach die
Gegenprobe auf dem Server selbst gegen seine öffentliche IP. Von einem anderen Rechner aus würde
die netcup-Firewall Port 8080 ohnehin abweisen, und der Test bewiese nichts:
curl -s -o /dev/null -w "%{http_code}\n" --max-time 5 http://DEINE_SERVER_IP:8080/000Kein 403, sondern gar keine Antwort: Da ist nichts, mit dem man reden könnte. Mit "8080:80"
statt "10.8.0.1:8080:80" antwortet derselbe Aufruf mit 200 – so siehst du, dass die Bindung den
Unterschied macht. Aus dem VPN liefert curl http://10.8.0.1:8080/ weiterhin 200.
Wann nimmst du was? Alles, was eine Weboberfläche hat und von einem Reverse Proxy profitiert (schöner Name, gültiges Zertifikat, zentrale Middlewares), bekommt die Allowlist. Alles, was sowieso nur du bedienst und wo Rohprotokolle im Spiel sind – DNS, Datenbanken, Admin-Ports, Metrik-Endpunkte – wird direkt ans VPN-Interface gebunden. Beides zusammen ist kein Widerspruch, sondern zwei Schichten.
Tipp
Wenn es nicht funktioniert
Von außen kommt weiterhin 200 statt 403. Fast immer ist die Middleware zwar definiert, aber nicht am Router angehängt. Die beiden Labels haben unterschiedliche Aufgaben: traefik.http.middlewares.vpn-only.ipallowlist.sourcerange=… legt sie an, traefik.http.routers.DEINROUTER.middlewares=vpn-only benutzt sie. Fehlt die zweite Zeile, startet alles fehlerfrei und der Dienst ist offen – ohne jede Fehlermeldung. Das Suffix @docker ist dabei optional, solange Middleware und Router aus demselben Provider kommen.
Auch aus dem VPN kommt 403. Dann sieht Traefik eine andere Quell-IP als die des VPN-Clients. Prüf das mit curl -s https://DEINE_DOMAIN/ | grep X-Real-Ip gegen einen whoami-Container: Steht dort eine Docker-interne Adresse wie 172.19.0.x oder die IP eines CDN, liegt noch ein Proxy davor. In dem Fall musst du Traefik über forwardedHeaders.trustedIPs beibringen, welchen Vorgänger er glauben darf – ohne diese Einstellung wertet er den Header nicht aus.
Der Name löst im VPN weiter auf die öffentliche IP auf. Der Client benutzt AdGuard nicht. Kontrollier zuerst cat /etc/resolv.conf – steht dort nicht nameserver 10.8.0.1, hat DNS = 10.8.0.1 nicht gegriffen. wg-quick braucht dafür ein Werkzeug, das die Resolver-Konfiguration ändern kann: entweder resolvconf aus dem Paket openresolv oder resolvectl aus dem Paket systemd-resolved. Auf einem minimalen Debian 13 ist keins von beiden vorinstalliert, und dann bleibt die Zeile wirkungslos. Ob AdGuard selbst richtig antwortet, prüfst du unabhängig davon mit dig +short @10.8.0.1 DEINE_DOMAIN.
Das Zertifikat wird plötzlich nicht mehr erneuert. Der ACME-Abruf braucht den öffentlichen Weg. Wenn du zwischendurch den A-Record gelöscht, Port 80 geschlossen oder die Allowlist versehentlich am web-Entrypoint global gesetzt hast, kommt Let’s Encrypt nicht mehr an die Challenge. docker compose logs traefik | grep -i acme zeigt den Fehlschlag – aber eben erst kurz vor Ablauf, nicht sofort.
Der Dienst antwortet im VPN, aber nur manchmal. Das ist meist die Reihenfolge der Adressfamilien: Solange der Name noch eine AAAA-Antwort liefert, probiert der Client zuerst IPv6 – und landet damit außerhalb des Tunnels bei der 403. Leg die DNS-Umschreibung nur für IPv4 an, dann liefert AdGuard für diesen Namen gar keine AAAA mehr; dig +short AAAA @10.8.0.1 DEINE_DOMAIN muss leer bleiben.
Du trägst eine öffentliche IPv6-Adresse in die Allowlist ein, und es bleibt bei 403. Traefik vergleicht mit der Adresse, die es tatsächlich sieht – und das ist bei IPv6 die des Bridge-Gateways, solange das proxy-Netz ohne IPv6 angelegt wurde. Deine echte Adresse kommt in dem Vergleich gar nicht vor. Prüf das mit der Ausgabe von curl -s -6 https://DEINE_DOMAIN/ | grep X-Forwarded-For aus dem erlaubten Netz heraus: Steht dort 172.x.x.x, leg das proxy-Netz mit --ipv6 neu an (siehe Reverse Proxy mit Traefik). Für den VPN-Fall in diesem Tutorial spielt das keine Rolle, weil WireGuard hier über IPv4 läuft.
Dein Problem ist nicht dabei? Alle Fehlermeldungen durchsuchen →
Wartung & Backups
Der Aufbau ist erfreulich pflegeleicht: Die Allowlist besteht aus zwei Zeilen in einer Compose-Datei, und die DNS-Umschreibung ist ein Eintrag in AdGuard. Es gibt nichts, was von selbst kaputtgeht.
Was du trotzdem im Blick behalten musst:
- Neue Dienste erben nichts. Jeder weitere Container braucht die Middleware-Zeile am eigenen Router. Nimm die zwei Labels in deine Vorlage auf, sonst steht der nächste Dienst offen im Netz – und du merkst es nicht, weil im VPN alles funktioniert.
- Das VPN-Subnetz ist jetzt sicherheitsrelevant. Wer einen WireGuard-Peer bekommt, bekommt
Zugriff auf alle internen Dienste. Wirf gelöschte Geräte konsequent aus
wg0.confund prüf einmal im Quartal mitwg show wg0, welche Peers zuletzt einen Handshake hatten. - Zertifikate laufen weiter über den öffentlichen Weg. Nach jeder Änderung an Firewall, DNS
oder Entrypoints lohnt ein Blick, ob Traefik noch erneuern kann – am einfachsten mit einem
Testabruf von
http://DEINE_DOMAIN/.well-known/acme-challenge/test, der404liefern muss und nicht403.
Sichern musst du hier wenig, aber das Wenige ist wichtig: die Compose-Dateien mit den Labels,
/etc/wireguard/ samt Schlüsseln und den AdGuard-Ordner mit AdGuardHome.yaml, in dem die
Umschreibungen liegen. Alles drei gehört in dein Restic-Backup.
Realistischer Aufwand: Einmal einrichten, danach nur noch bei jedem neuen Dienst zwei Zeilen mitdenken. Das Quartals-Ritual mit der Peer-Liste dauert fünf Minuten.
Feedback per E-Mail: feedback@serverkueche.de
Das könnte dir auch schmecken


