<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Adguard – Serverküche</title><link>https://serverkueche.de/tags/adguard/</link><description>Adguard – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>de-DE</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Thu, 22 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/tags/adguard/index.xml" rel="self" type="application/rss+xml"/><item><title>Dienste nur im VPN erreichbar machen: Traefik + WireGuard + interner DNS</title><link>https://serverkueche.de/tutorials/interne-dienste-nur-im-vpn/</link><pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/tutorials/interne-dienste-nur-im-vpn/</guid><description>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.</description><content:encoded><![CDATA[<p>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&quot;, sondern ein Dienst, der von außen höflich <code>403</code> sagt und über das VPN ganz normal
funktioniert.</p>
<h2 id="was-bauen-wir">Was bauen wir?</h2>
<p>Wir nehmen einen Dienst, der schon hinter <a href="/tutorials/reverse-proxy-traefik/">Traefik</a> läuft, und
sperren ihn für alles aus, was nicht aus dem WireGuard-Netz kommt. Danach liefert derselbe Name von
außen <strong>403 Forbidden</strong> und im VPN <strong>200</strong> – mit demselben gültigen Let&rsquo;s-Encrypt-Zertifikat, ohne
Portnummern in der Adresszeile und ohne Zertifikatswarnung.</p>
<p>Dafür kombinieren wir drei Bausteine, und es lohnt sich, ihre Rollen sauber zu trennen:</p>
<ul>
<li><strong>Traefik-<code>IPAllowList</code></strong> – der eigentliche Schutz. Alles, was nicht aus <code>10.8.0.0/24</code> kommt,
wird abgewiesen.</li>
<li><strong>Interner DNS über AdGuard Home</strong> – 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.</li>
<li><strong>Bindung an das VPN-Interface</strong> – die härteste Stufe für Dienste, die gar keinen Reverse Proxy
sehen sollen.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>DNS ist kein Schutz
  </p>
  <div class="prose-kitchen text-sm">Ein interner DNS-Eintrag verhindert nichts. Wer die öffentliche IP kennt, kann den <code>Host</code>-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.</div>
</div>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<ul>
<li>Ein laufendes <a href="/tutorials/wireguard-vpn-einrichten/">WireGuard-VPN</a> mit dem Netz <code>10.8.0.0/24</code>;
der Server hat darin die <code>10.8.0.1</code></li>
<li><a href="/tutorials/adguard-home-werbeblocker/">AdGuard Home</a> auf demselben Server, an <code>10.8.0.1:53</code>
gebunden</li>
<li><a href="/tutorials/reverse-proxy-traefik/">Traefik als Reverse Proxy</a> mit funktionierendem
Let&rsquo;s-Encrypt-Resolver</li>
<li>Ein Dienst, den du intern stellen willst. Zum Üben reicht <code>traefik/whoami</code> – der zeigt dir alle
Header, die bei ihm ankommen</li>
</ul>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /tutorials/interne-dienste-nur-im-vpn/" data-content-piece="VPS 1000 G12.5">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Empfehlung</span>
    <span title="Mit * markierte Links sind Affiliate-Links.">Anzeige</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 1000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCore · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">ab 14,50 €/Monat</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">VPN, DNS und Reverse Proxy passen zusammen auf einen VPS 1000.</p>
    </div>
    <a href="https://www.netcup.com/de/server/vps/vps-1000-g12.5-24m-eu?ref=44083#vps-1000-g12.5-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12.5 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Zu netcup →
</a>

  </div><div class="px-4 pb-4"><div class="not-prose my-3 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200"
     data-voucher-api="vps-1000" data-voucher-msg-empty="Gerade ist für diesen Tarif kein Code frei – nimm den 5,95-€-Code." data-voucher-msg-limit="Für heute sind keine weiteren Codes drin – nimm den 5,95-€-Code oder versuch es morgen." data-voucher-msg-error="Der Code ließ sich gerade nicht abrufen – nimm den 5,95-€-Code."
     data-voucher-hint="Warenkorb vorher leeren – der Code legt den Server selbst hinein.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 Monat gratis</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Zum Kopieren klicken"
            class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">Code anzeigen</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">anderer Code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Einmal einlösbar, gilt für VPS 1000 G12.5.
    <a href="https://www.netcup.com/de/checkout/warenkorb?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 1000 G12.5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Im Warenkorb einlösen →</a>
  </p><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">5,95 € Gutschein</span> für netcup-Neukunden:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Zum Kopieren klicken" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">immer gültig · nicht für Domains und VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/netcup-gutschein/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Alle netcup-Gutscheine →</a></p>
</div></div>
</div>

<h2 id="schritt-für-schritt">Schritt für Schritt</h2>
<h3 id="schritt-1-den-dienst-hinter-die-allowlist-stellen">Schritt 1: Den Dienst hinter die Allowlist stellen</h3>
<p>Der Kern sind zwei zusätzliche Labels an dem Container, den du schützen willst. Zum Mitmachen
nehmen wir <code>whoami</code>, weil er alle empfangenen Header ausgibt – damit sieht man später sofort, ob
Traefik die richtige Quell-IP verwendet:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /opt/vpnonly/compose.yaml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">whoami</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">traefik/whoami:v1.12.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">whoami</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">proxy]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.whoami.rule=Host(`DEINE_DOMAIN`)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.whoami.entrypoints=websecure</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.whoami.tls.certresolver=le</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.routers.whoami.middlewares=vpn-only@docker</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.middlewares.vpn-only.ipallowlist.sourcerange=10.8.0.0/24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">traefik.http.services.whoami.loadbalancer.server.port=80</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>Die beiden entscheidenden Zeilen sind Nummer fünf und sechs. Die eine <strong>definiert</strong> eine Middleware
namens <code>vpn-only</code>, die andere <strong>hängt sie an den Router</strong>. Beides ist nötig – dazu gleich mehr.</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /opt/vpnonly <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>Hinweis
  </p>
  <div class="prose-kitchen text-sm">In Traefik 2 hieß diese Middleware <code>ipwhitelist</code>, seit Traefik 3 heißt sie <code>ipallowlist</code>. Ä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.</div>
</div>
<h3 id="schritt-2-die-gegenprobe--zweimal-derselbe-name">Schritt 2: Die Gegenprobe – zweimal derselbe Name</h3>
<p>Ohne VPN, von irgendeinem Rechner im Internet:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://DEINE_DOMAIN/</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">403</span></span></code></pre></div>
</div>
<p>Und mit aktivem WireGuard-Tunnel. Hier kommt es darauf an, welchen Tunnel du nutzt: Im
<strong>Split-Tunnel</strong> aus dem WireGuard-Tutorial (<code>AllowedIPs = 10.8.0.0/24</code>) zeigt der Name noch auf
die öffentliche IP. Die Anfrage läuft dann am Tunnel vorbei und bekommt ebenfalls <code>403</code> – das
richtet erst der interne DNS in Schritt 3. Bis dahin sagst du <code>curl</code> von Hand, dass der Name auf
die VPN-Adresse des Servers zeigen soll:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> --resolve DEINE_DOMAIN:443:10.8.0.1 https://DEINE_DOMAIN/</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">200</span></span></code></pre></div>
</div>
<p>Mit einem <strong>Full-Tunnel</strong> (<code>AllowedIPs = 0.0.0.0/0, ::/0</code>) klappt es auch ohne <code>--resolve</code>, weil
dann auch der Weg zur öffentlichen IP durch den Tunnel läuft.</p>
<p>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 <code>whoami</code> aus dem VPN
heraus meldet:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s --resolve DEINE_DOMAIN:443:10.8.0.1 https://DEINE_DOMAIN/ <span class="p">|</span> grep -E <span class="s2">&#34;X-Real-Ip|X-Forwarded-For&#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">X-Forwarded-For: 10.8.0.2
</span></span><span class="line"><span class="cl">X-Real-Ip: 10.8.0.2</span></span></code></pre></div>
</div>
<p>Traefik sieht die <strong>VPN-Adresse des Clients</strong>, 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.</p>
<h3 id="schritt-3-interner-dns--damit-der-name-im-vpn-richtig-zeigt">Schritt 3: Interner DNS – damit der Name im VPN richtig zeigt</h3>
<p>Mit <code>--resolve</code> 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 <code>403</code>, im Full-Tunnel nimmt sie einen Umweg über die öffentliche Adresse, der schiefgeht,
sobald du am Server die öffentlichen Ports enger ziehst.</p>
<p>Erledigt wird das mit einer <strong>DNS-Umschreibung</strong> in AdGuard Home. Öffne die Weboberfläche und geh
auf <strong>Filter → DNS-Umschreibungen</strong>, dann auf „DNS-Umschreibung hinzufügen&quot;:</p>
<p><figure class="my-6"><img src="/tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-dialog_hu_e5f8f769a9458ad0.webp" srcset="/tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-dialog_hu_2ed18fe7a994818b.webp 480w, /tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-dialog_hu_e5f8f769a9458ad0.webp 768w, /tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-dialog_hu_2f31858d7e98e4f3.webp 1200w, /tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-dialog_hu_1491e3cf71afa27e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-dialog_hu_9cdcfe1e2f18a964.webp"
    alt="Der Dialog „DNS-Umschreibung hinzufügen&quot; in AdGuard Home mit einem Domainnamen im oberen Feld und der VPN-Adresse 10.8.0.1 im unteren Feld." title="Domain und Ziel-IP eintragen – hier die WireGuard-Adresse des Servers"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Domain und Ziel-IP eintragen – hier die WireGuard-Adresse des Servers</figcaption></figure></p>
<p>Im oberen Feld steht der Name des Dienstes, im unteren die <strong>VPN-Adresse des Servers</strong>, also
<code>10.8.0.1</code>. Ein führendes <code>*.</code> funktioniert auch, wenn du gleich eine ganze Subdomain umbiegen
willst. Nach dem Speichern taucht der Eintrag in der Liste auf:</p>
<p><figure class="my-6"><img src="/tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-liste_hu_cc185ef4904967f2.webp" srcset="/tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-liste_hu_542cda106e0d04ae.webp 480w, /tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-liste_hu_cc185ef4904967f2.webp 768w, /tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-liste_hu_19b1e88557ba8740.webp 1200w, /tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-liste_hu_cb734ccf5489a5f8.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/tutorials/interne-dienste-nur-im-vpn/adguard-rewrite-liste_hu_a4d54e0f85a37f25.webp"
    alt="Die Liste der DNS-Umschreibungen in AdGuard Home mit einem aktivierten Eintrag, der eine Domain auf 10.8.0.1 abbildet." title="Die aktive Umschreibung – ab jetzt zeigt der Name im VPN nach innen"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Die aktive Umschreibung – ab jetzt zeigt der Name im VPN nach innen</figcaption></figure></p>
<p>Prüf das Ergebnis direkt gegen den Resolver. Vorher liefert er die öffentliche Adresse:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short @10.8.0.1 DEINE_DOMAIN</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">203.0.113.10</span></span></code></pre></div>
</div>
<p>Und nach dem Eintrag:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short @10.8.0.1 DEINE_DOMAIN</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">10.8.0.1</span></span></code></pre></div>
</div>
<p>Praktischerweise unterdrückt AdGuard dabei auch die <strong>AAAA-Antwort</strong>: 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.</p>
<p>Die Gegenprobe auf IPv6 muss ohne Ausgabe zurückkommen:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">dig +short AAAA @10.8.0.1 DEINE_DOMAIN</span></span></code></pre></div>
</div>
<h3 id="schritt-4-den-resolver-an-die-clients-verteilen">Schritt 4: Den Resolver an die Clients verteilen</h3>
<p>Damit die Umschreibung greift, müssen die VPN-Clients AdGuard auch benutzen. Dafür gibt es in der
WireGuard-Client-Konfiguration genau eine Zeile:</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Interface]</span>
</span></span><span class="line"><span class="cl"><span class="na">Address</span> <span class="o">=</span> <span class="s">10.8.0.2/24</span>
</span></span><span class="line"><span class="cl"><span class="na">DNS</span> <span class="o">=</span> <span class="s">10.8.0.1</span>
</span></span><span class="line"><span class="cl"><span class="na">PrivateKey</span> <span class="o">=</span> <span class="s">DEIN_PRIVATER_CLIENT_SCHLUESSEL</span></span></span></code></pre></div>
</div>
<p>Beim Verbindungsaufbau trägt <code>wg-quick</code> den Server als Resolver ein und nimmt ihn beim Trennen
wieder heraus:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">wg-quick up wg0</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">[#] wg setconf wg0 /dev/fd/63
</span></span><span class="line"><span class="cl">[#] ip -4 address add 10.8.0.2/24 dev wg0
</span></span><span class="line"><span class="cl">[#] ip link set mtu 1420 up dev wg0
</span></span><span class="line"><span class="cl">[#] resolvconf -a wg0 -m 0 -x</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat /etc/resolv.conf</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Generated by resolvconf
</span></span><span class="line"><span class="cl">nameserver 10.8.0.1</span></span></code></pre></div>
</div>
<p>Die Zeile <code>resolvconf -a wg0</code> ist der wichtige Teil. Fehlt sie, hat <code>wg-quick</code> kein Werkzeug
gefunden, um die Resolver-Konfiguration zu ändern. Auf einem minimalen Debian 13 ist weder
<code>openresolv</code> noch <code>systemd-resolved</code> installiert – ohne eines davon bleibt <code>DNS =</code> wirkungslos, und
die Umschreibung greift nicht.</p>
<h3 id="schritt-5-warum-der-öffentliche-dns-eintrag-bleiben-muss">Schritt 5: Warum der öffentliche DNS-Eintrag bleiben muss</h3>
<p>Es liegt nahe, den öffentlichen A-Record gleich mit zu löschen. Tu das nicht, solange Traefik seine
Zertifikate per <strong>HTTP-01</strong> holt: Let&rsquo;s Encrypt ruft dafür <code>http://DEINE_DOMAIN/.well-known/acme-challenge/…</code>
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.</p>
<p>Wichtig ist, dass die Allowlist nur am <strong>Anwendungs-Router</strong> 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 <code>403</code>.</p>
<p>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 <strong>Certificate-Transparency-Logs</strong>, 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
<code>*.DEINE_DOMAIN</code>-Eintrag im Log, und die einzelnen Hostnamen bleiben unter sich.</p>
<h3 id="schritt-6-die-härtere-stufe--an-das-vpn-interface-binden">Schritt 6: Die härtere Stufe – an das VPN-Interface binden</h3>
<p>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 <code>10.8.0.1</code> ist nur über den Tunnel erreichbar:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;10.8.0.1:53:53/tcp&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;10.8.0.1:53:53/udp&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;10.8.0.1:8080:80/tcp&#34;</span></span></span></code></pre></div>
</div>
<p>Kontrolliere, worauf tatsächlich gelauscht wird:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ss -tlnp <span class="p">|</span> grep -E <span class="s2">&#34;:8080|:53 &#34;</span></span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">LISTEN 0  4096  10.8.0.1:53    0.0.0.0:*  users:((&#34;docker-proxy&#34;,pid=852377,fd=8))
</span></span><span class="line"><span class="cl">LISTEN 0  4096  10.8.0.1:8080  0.0.0.0:*  users:((&#34;docker-proxy&#34;,pid=852401,fd=8))</span></span></code></pre></div>
</div>
<p>Links steht die konkrete VPN-Adresse und nicht <code>0.0.0.0</code> – genau das ist der Unterschied. Mach die
Gegenprobe <strong>auf dem Server selbst</strong> gegen seine öffentliche IP. Von einem anderen Rechner aus würde
die netcup-Firewall Port 8080 ohnehin abweisen, und der Test bewiese nichts:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> --max-time <span class="m">5</span> http://DEINE_SERVER_IP:8080/</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">000</span></span></code></pre></div>
</div>
<p>Kein <code>403</code>, sondern gar keine Antwort: Da ist nichts, mit dem man reden könnte. Mit <code>&quot;8080:80&quot;</code>
statt <code>&quot;10.8.0.1:8080:80&quot;</code> antwortet derselbe Aufruf mit <code>200</code> – so siehst du, dass die Bindung den
Unterschied macht. Aus dem VPN liefert <code>curl http://10.8.0.1:8080/</code> weiterhin <code>200</code>.</p>
<p><strong>Wann nimmst du was?</strong> 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.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tipp
  </p>
  <div class="prose-kitchen text-sm">Die Allowlist lässt sich mit den übrigen Bausteinen aus
<a href="/tutorials/traefik-middlewares-absichern/">Traefik härten</a> kombinieren – etwa BasicAuth zusätzlich
zur IP-Beschränkung. Middlewares werden in der Reihenfolge angewendet, in der sie am Router stehen.</div>
</div>
<h2 id="wenn-es-nicht-funktioniert">Wenn es nicht funktioniert</h2>
<div class="troubleshoot not-prose">
<p><strong>Von außen kommt weiterhin <code>200</code> statt <code>403</code>.</strong> Fast immer ist die Middleware zwar definiert, aber nicht am Router angehängt. Die beiden Labels haben unterschiedliche Aufgaben: <code>traefik.http.middlewares.vpn-only.ipallowlist.sourcerange=…</code> legt sie an, <code>traefik.http.routers.DEINROUTER.middlewares=vpn-only</code> benutzt sie. Fehlt die zweite Zeile, startet alles fehlerfrei und der Dienst ist offen – ohne jede Fehlermeldung. Das Suffix <code>@docker</code> ist dabei optional, solange Middleware und Router aus demselben Provider kommen.</p>
<p><strong>Auch aus dem VPN kommt <code>403</code>.</strong> Dann sieht Traefik eine andere Quell-IP als die des VPN-Clients. Prüf das mit <code>curl -s https://DEINE_DOMAIN/ | grep X-Real-Ip</code> gegen einen <code>whoami</code>-Container: Steht dort eine Docker-interne Adresse wie <code>172.19.0.x</code> oder die IP eines CDN, liegt noch ein Proxy davor. In dem Fall musst du Traefik über <code>forwardedHeaders.trustedIPs</code> beibringen, welchen Vorgänger er glauben darf – ohne diese Einstellung wertet er den Header nicht aus.</p>
<p><strong>Der Name löst im VPN weiter auf die öffentliche IP auf.</strong> Der Client benutzt AdGuard nicht. Kontrollier zuerst <code>cat /etc/resolv.conf</code> – steht dort nicht <code>nameserver 10.8.0.1</code>, hat <code>DNS = 10.8.0.1</code> nicht gegriffen. <code>wg-quick</code> braucht dafür ein Werkzeug, das die Resolver-Konfiguration ändern kann: entweder <code>resolvconf</code> aus dem Paket <code>openresolv</code> oder <code>resolvectl</code> aus dem Paket <code>systemd-resolved</code>. 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 <code>dig +short @10.8.0.1 DEINE_DOMAIN</code>.</p>
<p><strong>Das Zertifikat wird plötzlich nicht mehr erneuert.</strong> Der ACME-Abruf braucht den öffentlichen Weg. Wenn du zwischendurch den A-Record gelöscht, Port 80 geschlossen oder die Allowlist versehentlich am <code>web</code>-Entrypoint global gesetzt hast, kommt Let&rsquo;s Encrypt nicht mehr an die Challenge. <code>docker compose logs traefik | grep -i acme</code> zeigt den Fehlschlag – aber eben erst kurz vor Ablauf, nicht sofort.</p>
<p><strong>Der Dienst antwortet im VPN, aber nur manchmal.</strong> 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 <code>403</code>. Leg die DNS-Umschreibung nur für IPv4 an, dann liefert AdGuard für diesen Namen gar keine AAAA mehr; <code>dig +short AAAA @10.8.0.1 DEINE_DOMAIN</code> muss leer bleiben.</p>
<p><strong>Du trägst eine öffentliche IPv6-Adresse in die Allowlist ein, und es bleibt bei <code>403</code>.</strong> Traefik vergleicht mit der Adresse, die es tatsächlich sieht – und das ist bei IPv6 die des Bridge-Gateways, solange das <code>proxy</code>-Netz ohne IPv6 angelegt wurde. Deine echte Adresse kommt in dem Vergleich gar nicht vor. Prüf das mit der Ausgabe von <code>curl -s -6 https://DEINE_DOMAIN/ | grep X-Forwarded-For</code> aus dem erlaubten Netz heraus: Steht dort <code>172.x.x.x</code>, leg das <code>proxy</code>-Netz mit <code>--ipv6</code> neu an (siehe <a href="/tutorials/reverse-proxy-traefik/">Reverse Proxy mit Traefik</a>). Für den VPN-Fall in diesem Tutorial spielt das keine Rolle, weil WireGuard hier über IPv4 läuft.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Dein Problem ist nicht dabei?
  <a href="/fehler/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Alle Fehlermeldungen durchsuchen →</a>
</p>

<h2 id="wartung--backups">Wartung &amp; Backups</h2>
<p>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.</p>
<p><strong>Was du trotzdem im Blick behalten musst:</strong></p>
<ul>
<li><strong>Neue Dienste erben nichts.</strong> 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.</li>
<li><strong>Das VPN-Subnetz ist jetzt sicherheitsrelevant.</strong> Wer einen WireGuard-Peer bekommt, bekommt
Zugriff auf alle internen Dienste. Wirf gelöschte Geräte konsequent aus <code>wg0.conf</code> und prüf
einmal im Quartal mit <code>wg show wg0</code>, welche Peers zuletzt einen Handshake hatten.</li>
<li><strong>Zertifikate laufen weiter über den öffentlichen Weg.</strong> Nach jeder Änderung an Firewall, DNS
oder Entrypoints lohnt ein Blick, ob Traefik noch erneuern kann – am einfachsten mit einem
Testabruf von <code>http://DEINE_DOMAIN/.well-known/acme-challenge/test</code>, der <code>404</code> liefern muss und
nicht <code>403</code>.</li>
</ul>
<p><strong>Sichern</strong> musst du hier wenig, aber das Wenige ist wichtig: die Compose-Dateien mit den Labels,
<code>/etc/wireguard/</code> samt Schlüsseln und den AdGuard-Ordner mit <code>AdGuardHome.yaml</code>, in dem die
Umschreibungen liegen. Alles drei gehört in dein <a href="/tutorials/backups-mit-restic/">Restic-Backup</a>.</p>
<p><strong>Realistischer Aufwand:</strong> Einmal einrichten, danach nur noch bei jedem neuen Dienst zwei Zeilen
mitdenken. Das Quartals-Ritual mit der Peer-Liste dauert fünf Minuten.</p>
]]></content:encoded></item></channel></rss>