<?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/en/tags/adguard/</link><description>Adguard – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</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/en/tags/adguard/index.xml" rel="self" type="application/rss+xml"/><item><title>Making Services Reachable Only Over VPN: Traefik + WireGuard + Internal DNS</title><link>https://serverkueche.de/en/tutorials/internal-services-vpn-only/</link><pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/internal-services-vpn-only/</guid><description>A service that returns 403 from outside and works normally over VPN – same name, same valid certificate. IPAllowList, internal DNS and interface binding.</description><content:encoded><![CDATA[<p>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 &ldquo;close the port&rdquo; but a service that politely answers <code>403</code> from outside and works completely
normally over the VPN.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>We take a service that already runs behind <a href="/en/tutorials/traefik-reverse-proxy/">Traefik</a> and lock
it down against anything that does not come from the WireGuard network. Afterwards the same name
returns <strong>403 Forbidden</strong> from outside and <strong>200</strong> over the VPN – with the same valid Let&rsquo;s Encrypt
certificate, no port numbers in the address bar and no certificate warning.</p>
<p>Three building blocks combine to do that, and it pays to keep their roles apart:</p>
<ul>
<li><strong>Traefik&rsquo;s <code>IPAllowList</code></strong> – the actual protection. Anything not coming from <code>10.8.0.0/24</code> is
rejected.</li>
<li><strong>Internal DNS via AdGuard Home</strong> – pure convenience. It makes the name resolve to the VPN
address inside the tunnel instead of taking a detour via the public IP.</li>
<li><strong>Binding to the VPN interface</strong> – the strictest level, for services that should not see a
reverse proxy at all.</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 is not protection
  </p>
  <div class="prose-kitchen text-sm">An internal DNS entry prevents nothing. Anyone who knows the public IP can set the <code>Host</code> header by
hand and still reach your service. The protection comes exclusively from the allowlist or the
interface binding – DNS only makes it convenient.</div>
</div>
<h2 id="requirements">Requirements</h2>
<ul>
<li>A running <a href="/en/tutorials/wireguard-vpn-setup/">WireGuard VPN</a> on the <code>10.8.0.0/24</code> network, with
the server holding <code>10.8.0.1</code></li>
<li><a href="/en/tutorials/adguard-home-ad-blocker/">AdGuard Home</a> on the same server, bound to <code>10.8.0.1:53</code></li>
<li><a href="/en/tutorials/traefik-reverse-proxy/">Traefik as a reverse proxy</a> with a working Let&rsquo;s Encrypt
resolver</li>
<li>A service you want to keep internal. For practice, <code>traefik/whoami</code> is enough – it echoes every
header it receives</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 · /en/tutorials/internal-services-vpn-only/" 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>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</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 vCores · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €14.50/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">VPN, DNS and reverse proxy fit together on one VPS 1000.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12.5-iv-24m-eu?ref=44083#vps-1000-g12.5-iv-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">
  Go to 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="No code is available for this plan right now – use the €5.95 code." data-voucher-msg-limit="No more codes today – use the €5.95 code or try again tomorrow." data-voucher-msg-error="The code couldn&#39;t be fetched right now – use the €5.95 code."
     data-voucher-hint="Empty the cart first – the code adds the server itself.">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1"><span>💶 <span class="font-semibold">1 month free</span>:</span>
    <button type="button" data-voucher-reveal data-copy-title="Click to copy"
            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">Show code</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">another code</button>
  </p><p class="mt-1 text-xs text-slate-500 dark:text-slate-400" data-voucher-hint-target>
    Single use, valid for VPS 1000 G12.5.
    <a href="https://www.netcup.com/en/checkout/cart?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">Redeem in the cart →</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 voucher</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" 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">always valid · not for domains or VPS Lite</span>
  </p><p class="mt-1 text-xs"><a href="/en/netcup-voucher/" class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">All netcup vouchers →</a></p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-put-the-service-behind-the-allowlist">Step 1: Put the service behind the allowlist</h3>
<p>The core is two extra labels on the container you want to protect. To follow along we use <code>whoami</code>,
because it prints every header it receives – that makes it immediately obvious later whether Traefik
uses the right source IP:</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(`YOUR_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>The two decisive lines are the fifth and sixth. One <strong>defines</strong> a middleware called <code>vpn-only</code>, the
other <strong>attaches it to the router</strong>. Both are required – more on that shortly.</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>Note
  </p>
  <div class="prose-kitchen text-sm">In Traefik 2 this middleware was called <code>ipwhitelist</code>; since Traefik 3 it is <code>ipallowlist</code>. 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.</div>
</div>
<h3 id="step-2-the-counter-test--the-same-name-twice">Step 2: The counter-test – the same name twice</h3>
<p>Without VPN, from any machine on the 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://YOUR_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>And with the WireGuard tunnel up. Here it matters which tunnel you use: in the <strong>split tunnel</strong> from
the WireGuard tutorial (<code>AllowedIPs = 10.8.0.0/24</code>) the name still points to the public IP. The
request then runs past the tunnel and also gets <code>403</code> – the internal DNS in step 3 fixes that. Until
then, tell <code>curl</code> by hand that the name should point to the server&rsquo;s VPN address:</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 YOUR_DOMAIN:443:10.8.0.1 https://YOUR_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>With a <strong>full tunnel</strong> (<code>AllowedIPs = 0.0.0.0/0, ::/0</code>) it works without <code>--resolve</code> too, because
then the path to the public IP also runs through the tunnel.</p>
<p>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 <code>whoami</code> reports from inside the VPN:</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 YOUR_DOMAIN:443:10.8.0.1 https://YOUR_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 sees the <strong>client&rsquo;s VPN address</strong>, not the Docker network&rsquo;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.</p>
<h3 id="step-3-internal-dns--so-the-name-points-the-right-way-inside-the-vpn">Step 3: Internal DNS – so the name points the right way inside the VPN</h3>
<p>With <code>--resolve</code> it works, but you can&rsquo;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
<code>403</code>, in a full tunnel it takes a detour via the public address that breaks the moment you tighten
the public ports on the server.</p>
<p>A <strong>DNS rewrite</strong> in AdGuard Home takes care of it. Open the web interface, go to
<strong>Filters → DNS rewrites</strong> and click &ldquo;Add DNS rewrite&rdquo;:</p>
<p><figure class="my-6"><img src="/en/tutorials/internal-services-vpn-only/adguard-rewrite-dialog_hu_fafb944f42aebbb0.webp" srcset="/en/tutorials/internal-services-vpn-only/adguard-rewrite-dialog_hu_afbac2097c04973c.webp 480w, /en/tutorials/internal-services-vpn-only/adguard-rewrite-dialog_hu_fafb944f42aebbb0.webp 768w, /en/tutorials/internal-services-vpn-only/adguard-rewrite-dialog_hu_9cc48c3eacbd93eb.webp 1200w, /en/tutorials/internal-services-vpn-only/adguard-rewrite-dialog_hu_d2ce2cab9328f8b6.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/internal-services-vpn-only/adguard-rewrite-dialog_hu_fd256c9f60fbb0ca.webp"
    alt="The &ldquo;Add DNS rewrite&rdquo; dialog in AdGuard Home with a domain name in the upper field and the VPN address 10.8.0.1 in the lower field." title="Enter domain and target IP – here the server&#39;s WireGuard address"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Enter domain and target IP – here the server&#39;s WireGuard address</figcaption></figure></p>
<p>The upper field holds the service&rsquo;s name, the lower one the <strong>server&rsquo;s VPN address</strong>, that is
<code>10.8.0.1</code>. A leading <code>*.</code> works too if you want to redirect a whole subdomain at once. After
saving, the entry appears in the list:</p>
<p><figure class="my-6"><img src="/en/tutorials/internal-services-vpn-only/adguard-rewrite-liste_hu_4c02d79c913eb694.webp" srcset="/en/tutorials/internal-services-vpn-only/adguard-rewrite-liste_hu_c8d50127ed5fb51f.webp 480w, /en/tutorials/internal-services-vpn-only/adguard-rewrite-liste_hu_4c02d79c913eb694.webp 768w, /en/tutorials/internal-services-vpn-only/adguard-rewrite-liste_hu_db5885848252eb8b.webp 1200w, /en/tutorials/internal-services-vpn-only/adguard-rewrite-liste_hu_e688178a536b3855.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/internal-services-vpn-only/adguard-rewrite-liste_hu_f33f08213622dd35.webp"
    alt="The list of DNS rewrites in AdGuard Home with one enabled entry mapping a domain to 10.8.0.1." title="The active rewrite – from now on the name points inward inside the VPN"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The active rewrite – from now on the name points inward inside the VPN</figcaption></figure></p>
<p>Check the result against the resolver directly. Before the entry it returns the public address:</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 YOUR_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>And after it:</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 YOUR_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>Conveniently, AdGuard also suppresses the <strong>AAAA answer</strong>: 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:</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 YOUR_DOMAIN</span></span></code></pre></div>
</div>
<h3 id="step-4-hand-the-resolver-to-the-clients">Step 4: Hand the resolver to the clients</h3>
<p>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:</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">YOUR_PRIVATE_CLIENT_KEY</span></span></span></code></pre></div>
</div>
<p>When the connection comes up, <code>wg-quick</code> registers the server as the resolver and removes it again
on disconnect:</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>The <code>resolvconf -a wg0</code> line is the important part. If it is missing, <code>wg-quick</code> found no tool to
change the resolver configuration. A minimal Debian 13 has neither <code>openresolv</code> nor
<code>systemd-resolved</code> installed – without one of them <code>DNS =</code> has no effect and the rewrite never
applies.</p>
<h3 id="step-5-why-the-public-dns-record-has-to-stay">Step 5: Why the public DNS record has to stay</h3>
<p>It is tempting to delete the public A record as well. Do not, as long as Traefik obtains its
certificates via <strong>HTTP-01</strong>: Let&rsquo;s Encrypt fetches
<code>http://YOUR_DOMAIN/.well-known/acme-challenge/…</code> 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.</p>
<p>What matters is that the allowlist only hangs on the <strong>application router</strong> and not on ACME
handling: Traefik answers the challenge itself before any middleware applies. Requests from outside
still get <code>403</code> for everything else.</p>
<p>Be clear about one thing though: the name of your internal service is public knowledge regardless.
It is in DNS and additionally in the <strong>Certificate Transparency logs</strong>, 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 <code>*.YOUR_DOMAIN</code> entry ends up in the log and the individual
hostnames stay private.</p>
<h3 id="step-6-the-stricter-level--bind-to-the-vpn-interface">Step 6: The stricter level – bind to the VPN interface</h3>
<p>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
<code>10.8.0.1</code> is only reachable through the tunnel:</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>Check what is actually being listened on:</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>On the left is the concrete VPN address and not <code>0.0.0.0</code> – that is exactly the difference. Run the
counter-test <strong>on the server itself</strong> against its public IP. From another machine the netcup
firewall would reject port 8080 anyway, and the test would prove nothing:</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://YOUR_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>Not a <code>403</code> but no answer at all: there is nothing there to talk to. With <code>&quot;8080:80&quot;</code> instead of
<code>&quot;10.8.0.1:8080:80&quot;</code> the same request answers <code>200</code> – that&rsquo;s how you see the binding makes the
difference. From the VPN, <code>curl http://10.8.0.1:8080/</code> still returns <code>200</code>.</p>
<p><strong>When do you use which?</strong> 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.</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>Tip
  </p>
  <div class="prose-kitchen text-sm">The allowlist combines with the other building blocks from
<a href="/en/tutorials/traefik-middlewares-hardening/">hardening Traefik</a> – BasicAuth on top of the IP
restriction, for instance. Middlewares are applied in the order they are listed on the router.</div>
</div>
<h2 id="when-it-doesnt-work">When it doesn&rsquo;t work</h2>
<div class="troubleshoot not-prose">
<p><strong>Requests from outside still get <code>200</code> instead of <code>403</code>.</strong> Almost always the middleware is defined but not attached to the router. The two labels have different jobs: <code>traefik.http.middlewares.vpn-only.ipallowlist.sourcerange=…</code> creates it, <code>traefik.http.routers.YOURROUTER.middlewares=vpn-only</code> uses it. Leave out the second line and everything starts without errors while the service sits wide open – with no error message whatsoever. The <code>@docker</code> suffix is optional as long as middleware and router come from the same provider.</p>
<p><strong>Even from inside the VPN you get <code>403</code>.</strong> Then Traefik sees a different source IP than the VPN client&rsquo;s. Check with <code>curl -s https://YOUR_DOMAIN/ | grep X-Real-Ip</code> against a <code>whoami</code> container: if it shows a Docker-internal address such as <code>172.19.0.x</code> or a CDN&rsquo;s IP, there is another proxy in front. In that case you have to tell Traefik via <code>forwardedHeaders.trustedIPs</code> which predecessor it may believe – without that setting it does not evaluate the header at all.</p>
<p><strong>Inside the VPN the name still resolves to the public IP.</strong> The client is not using AdGuard. Check <code>cat /etc/resolv.conf</code> first – if it does not say <code>nameserver 10.8.0.1</code>, then <code>DNS = 10.8.0.1</code> did not take effect. <code>wg-quick</code> needs a tool that can change the resolver configuration: either <code>resolvconf</code> from the <code>openresolv</code> package or <code>resolvectl</code> from the <code>systemd-resolved</code> package. A minimal Debian 13 ships neither, and then the line does nothing. Whether AdGuard itself answers correctly is a separate check: <code>dig +short @10.8.0.1 YOUR_DOMAIN</code>.</p>
<p><strong>The certificate suddenly stops renewing.</strong> 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 <code>web</code> entrypoint, Let&rsquo;s Encrypt can no longer reach the challenge. <code>docker compose logs traefik | grep -i acme</code> shows the failure – but only shortly before expiry, not immediately.</p>
<p><strong>The service answers inside the VPN, but only sometimes.</strong> 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 <code>403</code>. Create the DNS rewrite for IPv4 only; AdGuard then returns no AAAA for that name at all, and <code>dig +short AAAA @10.8.0.1 YOUR_DOMAIN</code> has to stay empty.</p>
<p><strong>You put a public IPv6 address into the allowlist and still get <code>403</code>.</strong> Traefik compares against the address it actually sees – and over IPv6 that is the bridge gateway&rsquo;s, as long as the <code>proxy</code> network was created without IPv6. Your real address never enters the comparison. Check it with <code>curl -s -6 https://YOUR_DOMAIN/ | grep X-Forwarded-For</code> from the allowed network: if it says <code>172.x.x.x</code>, recreate the <code>proxy</code> network with <code>--ipv6</code> (see <a href="/en/tutorials/traefik-reverse-proxy/">Reverse proxy with Traefik</a>). For the VPN case in this tutorial it makes no difference, because WireGuard runs over IPv4 here.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p>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.</p>
<p><strong>What you do have to keep an eye on:</strong></p>
<ul>
<li><strong>New services inherit nothing.</strong> 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.</li>
<li><strong>The VPN subnet is now security-relevant.</strong> Whoever gets a WireGuard peer gets access to every
internal service. Remove retired devices from <code>wg0.conf</code> consistently and check once a quarter
with <code>wg show wg0</code> which peers actually had a recent handshake.</li>
<li><strong>Certificates still travel the public path.</strong> After every change to the firewall, DNS or
entrypoints it is worth verifying that Traefik can still renew – easiest with a test request to
<code>http://YOUR_DOMAIN/.well-known/acme-challenge/test</code>, which has to return <code>404</code> and not <code>403</code>.</li>
</ul>
<p><strong>There is little to back up here, but that little matters:</strong> the Compose files with the labels,
<code>/etc/wireguard/</code> including the keys, and the AdGuard directory with <code>AdGuardHome.yaml</code>, which holds
the rewrites. All three belong in your <a href="/en/tutorials/restic-backups/">Restic backup</a>.</p>
<p><strong>Realistic effort:</strong> set it up once, then just remember two lines for every new service. The
quarterly ritual with the peer list takes five minutes.</p>
]]></content:encoded></item></channel></rss>