Zum Inhalt springen
Serverküche
Suche

Die Suche wird geladen … (nur in der veröffentlichten Seite verfügbar).

Sicherheit Schwierigkeit: Fortgeschritten

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.

· 11 Min. Lesezeit ·Dauer: ca. 50 Minuten
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 aus 10.8.0.0/24 kommt, 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

Ein interner DNS-Eintrag verhindert nichts. Wer die öffentliche IP kennt, kann den 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 die 10.8.0.1
  • AdGuard Home auf demselben Server, an 10.8.0.1:53 gebunden
  • 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
🍳 Empfehlung Anzeige

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.

Zu netcup →

💶 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

Alle netcup-Gutscheine →

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:

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(`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: true

Die 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.

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

Hinweis

In Traefik 2 hieß diese Middleware 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:

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

Und 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:

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

Mit 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:

Terminal
curl -s --resolve DEINE_DOMAIN:443:10.8.0.1 https://DEINE_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 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":

Der Dialog „DNS-Umschreibung hinzufügen" in AdGuard Home mit einem Domainnamen im oberen Feld und der VPN-Adresse 10.8.0.1 im unteren Feld.
Domain und Ziel-IP eintragen – hier die WireGuard-Adresse des Servers

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:

Die Liste der DNS-Umschreibungen in AdGuard Home mit einem aktivierten Eintrag, der eine Domain auf 10.8.0.1 abbildet.
Die aktive Umschreibung – ab jetzt zeigt der Name im VPN nach innen

Prüf das Ergebnis direkt gegen den Resolver. Vorher liefert er die öffentliche Adresse:

Terminal
dig +short @10.8.0.1 DEINE_DOMAIN
Ausgabe
203.0.113.10

Und nach dem Eintrag:

Terminal
dig +short @10.8.0.1 DEINE_DOMAIN
Ausgabe
10.8.0.1

Praktischerweise 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:

Terminal
dig +short AAAA @10.8.0.1 DEINE_DOMAIN

Schritt 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:

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

Beim Verbindungsaufbau trägt wg-quick den Server als Resolver ein und nimmt ihn beim Trennen wieder heraus:

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

Die 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:

YAML
    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:

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))

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:

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

Kein 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

Die Allowlist lässt sich mit den übrigen Bausteinen aus Traefik härten kombinieren – etwa BasicAuth zusätzlich zur IP-Beschränkung. Middlewares werden in der Reihenfolge angewendet, in der sie am Router stehen.

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.conf und prüf einmal im Quartal mit wg 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, der 404 liefern muss und nicht 403.

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