<?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>Network – Serverküche</title><link>https://serverkueche.de/en/tags/network/</link><description>Network – 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>Tue, 25 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/network/index.xml" rel="self" type="application/rss+xml"/><item><title>Understanding Docker Networks: bridge, internal DNS &amp; the proxy network</title><link>https://serverkueche.de/en/tutorials/understanding-docker-networks/</link><pubDate>Tue, 25 Aug 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/understanding-docker-networks/</guid><description>Why does every app recipe declare a proxy network? This guide explains Docker networks, internal DNS and container isolation with real commands.</description><content:encoded><![CDATA[<p><code>networks: [proxy]</code> – this half-sentence shows up in almost every Serverküche app recipe, usually without comment. Time to explain it once, centrally. Afterwards you&rsquo;ll understand how containers find each other, why some don&rsquo;t need internet access and how Traefik reaches them at all.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>Not a new service, but the networking model behind every Docker stack. By the end you&rsquo;ll know why you should create your own networks instead of the default <code>bridge</code>, how containers reach each other by <strong>name</strong> instead of IP (the internal DNS), what an <code>internal</code> network without internet access is good for, and why the shared <code>proxy</code> network is the trick that lets <a href="/en/tutorials/traefik-reverse-proxy/">Traefik</a> reach every app. All output comes from a real <strong>Debian 13</strong> server running <strong>Docker 29</strong>.</p>
<p><figure class="my-6"><img src="netzwerk-schema.svg" alt="Diagram: internet via Traefik into the proxy network, the app also on the internal network to the database with no internet access" title="Two networks: proxy connects Traefik and app, intern connects app and database – the DB has no internet access" loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Two networks: proxy connects Traefik and app, intern connects app and database – the DB has no internet access</figcaption></figure></p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server with <a href="/en/tutorials/install-docker/">Docker</a> installed and basic knowledge of <a href="/en/tutorials/docker-compose-basics/">Docker Compose</a></li>
<li>Root or <code>docker</code> group access (see <a href="/en/tutorials/linux-users-and-permissions/">Users &amp; permissions</a>)</li>
<li>No extra package – Docker ships with everything</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/understanding-docker-networks/" data-content-piece="VPS 1000 G12">
  <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</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">A small VPS is enough to work through several container networks.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12 · {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"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 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">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span><code data-track-voucher="36nc17844976032"
        class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-whats-already-there">Step 1: What&rsquo;s already there</h3>
<p>After installation Docker ships with three networks:</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">docker network ls</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">NETWORK ID     NAME      DRIVER    SCOPE
</span></span><span class="line"><span class="cl">9d087537e327   bridge    bridge    local
</span></span><span class="line"><span class="cl">b622109fef73   host      host      local
</span></span><span class="line"><span class="cl">e12e562ad7f6   none      null      local</span></span></code></pre></div>
</div>
<ul>
<li><strong><code>bridge</code></strong> – the default network. Containers with no explicit network land here. It works, but has one crucial catch (step 3).</li>
<li><strong><code>host</code></strong> – the container shares the host&rsquo;s network stack directly (no isolation layer, no port mapping). For special cases only.</li>
<li><strong><code>none</code></strong> – no network at all. For containers that should deliberately be offline.</li>
</ul>
<p>For your own stacks you don&rsquo;t use any of these directly – you create <strong>your own</strong> networks. Why is shown in step 3.</p>
<h3 id="step-2-creating-your-own-network">Step 2: Creating your own network</h3>
<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">docker network create demo-net</span></span></code></pre></div>
</div>
<p>Docker creates a <code>bridge</code> network and assigns a subnet automatically:</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">docker network inspect demo-net --format <span class="s1">&#39;name={{.Name}} driver={{.Driver}} subnet={{range .IPAM.Config}}{{.Subnet}}{{end}}&#39;</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">name=demo-net driver=bridge subnet=172.20.0.0/16</span></span></code></pre></div>
</div>
<p>Every container in this network gets an IP from <code>172.20.0.0/16</code>. But you don&rsquo;t want to work with IPs at all – they change on every restart. The real win is something else.</p>
<h3 id="step-3-the-internal-dns--finding-containers-by-name">Step 3: The internal DNS – finding containers by name</h3>
<p>Start two containers in <code>demo-net</code>: a web server and a client.</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">docker run -d --name web --network demo-net nginx:1.29-alpine
</span></span><span class="line"><span class="cl">docker run -d --name client --network demo-net alpine:3.20 sleep <span class="m">600</span></span></span></code></pre></div>
</div>
<p>Now the crucial test – the client reaches the web server by its <strong>name</strong>:</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">docker <span class="nb">exec</span> client ping -c <span class="m">2</span> web</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">PING web (172.20.0.2): 56 data bytes
</span></span><span class="line"><span class="cl">64 bytes from 172.20.0.2: seq=0 ttl=64 time=0.171 ms
</span></span><span class="line"><span class="cl">64 bytes from 172.20.0.2: seq=1 ttl=64 time=0.095 ms</span></span></code></pre></div>
</div>
<p>Docker automatically resolves the container name <code>web</code> to the right IP. The same works for HTTP:</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">docker <span class="nb">exec</span> client wget -qO- http://web/</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">&lt;title&gt;Welcome to nginx!&lt;/title&gt;</span></span></code></pre></div>
</div>
<p>This is exactly why Compose files say <code>DB_HOST=db</code> instead of an IP: <code>db</code> is the service name, and Docker turns it into the right address. Comparing with the default <code>bridge</code> shows why you avoid it – there&rsquo;s <strong>no</strong> name resolution there:</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">docker run -d --name legacy1 nginx:1.29-alpine
</span></span><span class="line"><span class="cl">docker run --rm alpine:3.20 ping -c <span class="m">1</span> legacy1</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">ping: bad address &#39;legacy1&#39;</span></span></code></pre></div>
</div>
<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>Compose does this automatically
  </p>
  <div class="prose-kitchen text-sm">A <code>docker compose up</code> automatically creates a dedicated named network for the stack (e.g. <code>myapp_default</code>) – with working DNS. That&rsquo;s why the services of a Compose file always reach each other by name without you doing anything. The default <code>bridge</code> only affects containers you start manually with <code>docker run</code> and no <code>--network</code>.</div>
</div>
<h3 id="step-4-segmenting--a-database-with-no-internet">Step 4: Segmenting – a database with no internet</h3>
<p>Not every container needs to see every other one. A database should only be reachable by <em>its</em> app and needs no internet itself. That&rsquo;s what <code>internal</code> networks are for:</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">docker network create --internal demo-intern</span></span></code></pre></div>
</div>
<p><code>internal: true</code> cuts the route to the outside. The proof:</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">docker run --rm --network demo-intern alpine:3.20 <span class="se">\
</span></span></span><span class="line"><span class="cl">  sh -c <span class="s2">&#34;wget -qT4 -O- http://example.com &gt;/dev/null 2&gt;&amp;1 &amp;&amp; echo REACHED || echo BLOCKED&#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">BLOCKED</span></span></code></pre></div>
</div>
<p>This is a strong security pattern: even if an attacker compromises the database, it can&rsquo;t open a connection to the outside (no pulling in malware, no data exfiltration). A typical stack therefore uses <strong>two</strong> networks: an <code>internal</code> network between app and database, and the public <code>proxy</code> network only between app and Traefik.</p>
<h3 id="step-5-the-proxy-network--how-traefik-reaches-every-app">Step 5: The <code>proxy</code> network – how Traefik reaches every app</h3>
<p>Now the most-copied half-sentence of the catalog. <a href="/en/tutorials/traefik-reverse-proxy/">Traefik</a> runs in its own container and has to talk to <em>every</em> app to forward its requests. But containers only talk to each other when they&rsquo;re <strong>on the same network</strong>. The solution: a shared, permanent network called <code>proxy</code>.</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">docker network inspect proxy --format <span class="s1">&#39;name={{.Name}} driver={{.Driver}} subnet={{range .IPAM.Config}}{{.Subnet}}{{end}} internal={{.Internal}}&#39;</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">name=proxy driver=bridge subnet=172.19.0.0/16 internal=false</span></span></code></pre></div>
</div>
<p>Traefik stays permanently on this network. Every new app joins it – in Compose like this:</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="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">myapp</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">example/app:1.0</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></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">proxy       </span><span class="w"> </span><span class="c"># so Traefik can reach the app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">intern      </span><span class="w"> </span><span class="c"># private link to the database</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="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ... the router/service labels</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 class="w">   </span><span class="c"># created by Traefik, do NOT recreate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">intern</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">internal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>Two things are key here: <code>external: true</code> tells Docker &ldquo;this network already exists, just attach to it&rdquo; – if you accidentally recreate it, the app lands in a <em>different</em> <code>proxy</code> network than Traefik and can&rsquo;t be reached via the domain (the classic 502 troubleshooting case). And through the second network <code>intern</code> the app reaches its database without that database ever sitting on the public network.</p>
<h3 id="step-6-publishing-ports--only-where-needed">Step 6: Publishing ports – only where needed</h3>
<p>In all steps so far, containers talk <em>to each other</em> without publishing a single port. A port only needs to go outside when the <strong>host</strong> or the internet should access it directly:</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">docker run -d --name pub -p 8099:80 nginx:1.29-alpine
</span></span><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> http://127.0.0.1:8099/</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><code>-p 8099:80</code> binds host port 8099 to container port 80. In Compose that&rsquo;s <code>ports: [&quot;8099:80&quot;]</code>. The important difference to <code>expose</code>: <code>expose</code> only makes a port visible <em>to other containers</em> (network-internal), <code>ports</code> opens it to the host.</p>
<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>Behind Traefik, don&#39;t publish app ports
  </p>
  <div class="prose-kitchen text-sm">When an app runs behind Traefik it needs (and wants) <strong>no</strong> <code>ports:</code>. Traefik reaches it via the <code>proxy</code> network; an extra published port would bypass Traefik and thus HTTPS, middlewares and firewall logic. Only publish Traefik&rsquo;s own ports (80/443). Everything else stays network-internal.</div>
</div>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>Container can&rsquo;t reach another by name (<code>bad address</code>).</strong> Both aren&rsquo;t on the same <em>named</em> network, or one uses the default <code>bridge</code> (no DNS). Check which networks a container is on with <code>docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' CONTAINER</code>.</p>
<p><strong>Traefik returns 502 Bad Gateway.</strong> Almost always Traefik and the app are on <em>different</em> <code>proxy</code> networks because the network was created twice. Make sure <code>external: true</code> is set in Compose and check with <code>docker network inspect proxy</code> whether both containers are listed.</p>
<p><strong><code>network proxy not found</code> on <code>up</code>.</strong> The external network doesn&rsquo;t exist yet. Create it once: <code>docker network create proxy</code> (or start Traefik first, which creates it).</p>
<p><strong>Database has internet despite <code>internal</code>.</strong> It&rsquo;s also attached to a non-internal network (e.g. <code>proxy</code>). A container has the sum of the access of all its networks – the DB belongs <em>only</em> on the internal network, never on the <code>proxy</code> network.</p>
<p><strong>Two stacks collide on the subnet.</strong> Docker assigns <code>172.x</code> ranges automatically, but with many networks the pool can run short (<code>could not find an available, non-overlapping IPv4 address pool</code>). Clean up unused networks: <code>docker network prune</code>.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Networks need no backup</strong> – they&rsquo;re recreated from the Compose file at any time. What matters is that your <code>compose.yaml</code> and the <code>networks:</code> declarations are in your <a href="/en/tutorials/restic-backups/">backup</a>, not the network state itself.</li>
<li><strong>Clean up orphaned networks.</strong> After lots of testing, unused networks pile up. <code>docker network ls</code> shows them, <code>docker network prune</code> removes all without attached containers. The permanent <code>proxy</code> network survives this as long as Traefik is running.</li>
<li><strong>The <code>proxy</code> network is infrastructure.</strong> Treat it like Traefik itself: create it once, then leave it alone. Deleting it by accident (<code>network prune</code> with Traefik stopped) forces you to reconnect all apps. More on clean-up in <a href="/en/tutorials/keep-docker-stack-updated/">Keeping your Docker stack updated</a>.
</content></li>
</ul>
]]></content:encoded></item><item><title>Setting up a WireGuard VPN: secure access to your own server</title><link>https://serverkueche.de/en/tutorials/wireguard-vpn-setup/</link><pubDate>Thu, 30 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/wireguard-vpn-setup/</guid><description>A lean WireGuard VPN natively on the netcup server: keys, server and client config, firewall, phone QR – plus the Docker pitfall with the full tunnel.</description><content:encoded><![CDATA[<p>So far, everything on your server is publicly reachable behind Traefik. But some things
should run <strong>only for you</strong> – an admin dashboard, a database, an internal service. With
WireGuard you build yourself an encrypted tunnel straight into the server, without
opening any further ports to the internet.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>A <strong>WireGuard VPN</strong> natively on the server (no container – WireGuard is in the Linux
kernel, which is the leanest and most stable variant). Your devices – laptop, phone –
get an encrypted tunnel to the server and, through it, a private IP in the
<code>10.8.0.0/24</code> network. Two operating modes that you switch via a single client line:</p>
<ul>
<li><strong>Split tunnel (default):</strong> Only the path <strong>to the server and its internal services</strong>
runs through the VPN. This lets you reach things that <em>shouldn&rsquo;t</em> be public (a
dashboard, a database, later your own DNS filter), without publishing a port for it.</li>
<li><strong>Full tunnel (optional):</strong> <strong>All</strong> of your device&rsquo;s internet traffic runs through the
server – handy on open café Wi-Fi, because then everything goes out encrypted via your
own IP.</li>
</ul>
<p>Tested with <strong>wireguard-tools 1.0.20210914</strong> on Debian 13 (kernel 6.12, WireGuard is
built in there).</p>
<p>Why WireGuard and not OpenVPN or an open port? WireGuard runs <strong>in the kernel</strong>, is tiny
with only a few thousand lines of code (easy to audit, small attack surface), needs only
a <strong>single UDP port</strong> and gets by with a handful of config lines. The handshake is so
lean that a mobile connection is practically back up immediately after waking. So
instead of putting every internal service on the net with its own authentication, you
lay <strong>one</strong> secure tunnel – and everything behind it stays private.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server with an active <a href="/en/tutorials/firewall-ufw-setup/">UFW firewall</a> and root or
sudo access.</li>
<li>If you also use the <a href="/en/tutorials/netcup-firewall-setup/">netcup firewall</a>: open the
WireGuard port there later (ready-made template in the firewall tutorial).</li>
<li>A client device with the official <strong>WireGuard app</strong> (Windows, macOS, Linux, iOS,
Android).</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/wireguard-vpn-setup/" data-content-piece="VPS 1000 G12">
  <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</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">A WireGuard VPN needs hardly any resources – the smallest VPS is plenty.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12 · {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"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 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">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span><code data-track-voucher="36nc17844976032"
        class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-install-wireguard">Step 1: Install WireGuard</h3>
<p>The kernel already ships WireGuard; you only need the userspace tools (and <code>qrencode</code>
for the phone QR code):</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">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install -y wireguard wireguard-tools qrencode</span></span></code></pre></div>
</div>
<p>Check that the kernel module can be loaded:</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">sudo modprobe wireguard <span class="o">&amp;&amp;</span> lsmod <span class="p">|</span> grep wireguard</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">wireguard             118784  0</span></span></code></pre></div>
</div>
<h3 id="step-2-generate-key-pairs">Step 2: Generate key pairs</h3>
<p>WireGuard authenticates purely via <strong>key pairs</strong> – no password, no certificates. Each
side (server and every client) has a private and a public key. The private one must
<strong>never</strong> leave the machine. First set a strict file mask so the keys aren&rsquo;t readable by
others:</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">umask</span> <span class="m">077</span>
</span></span><span class="line"><span class="cl">wg genkey <span class="p">|</span> sudo tee /etc/wireguard/server.key <span class="p">|</span> wg pubkey <span class="p">|</span> sudo tee /etc/wireguard/server.pub</span></span></code></pre></div>
</div>
<p>The first command generates the private key (<code>server.key</code>), pipes it through <code>wg pubkey</code>
and stores the public one (<code>server.pub</code>). Generate a pair for your first client the same
way:</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 genkey <span class="p">|</span> tee client.key <span class="p">|</span> wg pubkey &gt; client.pub</span></span></code></pre></div>
</div>
<p>You now have four keys. Remember the assignment: the <strong>server</strong> config gets the
<strong>private server key</strong> and the <strong>public client</strong> key; the <strong>client</strong> config the other
way around. This is the most common source of error – never enter the private key of the
wrong side.</p>
<h3 id="step-3-create-the-server-configuration">Step 3: Create the server configuration</h3>
<p>Create <code>/etc/wireguard/wg0.conf</code>. Enter your private server key and the public client
key:</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.1/24</span>
</span></span><span class="line"><span class="cl"><span class="na">ListenPort</span> <span class="o">=</span> <span class="s">51820</span>
</span></span><span class="line"><span class="cl"><span class="na">PrivateKey</span> <span class="o">=</span> <span class="s">YOUR_SERVER_PRIVATE_KEY</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Peer]</span>
</span></span><span class="line"><span class="cl"><span class="na">PublicKey</span> <span class="o">=</span> <span class="s">YOUR_CLIENT_PUBLIC_KEY</span>
</span></span><span class="line"><span class="cl"><span class="na">AllowedIPs</span> <span class="o">=</span> <span class="s">10.8.0.2/32</span></span></span></code></pre></div>
</div>
<p>What the lines mean:</p>
<ul>
<li><strong><code>Address</code></strong> – the VPN-internal IP of the server (<code>10.8.0.1</code>); the <code>/24</code> spans the
VPN subnet <code>10.8.0.0/24</code>.</li>
<li><strong><code>ListenPort</code></strong> – the UDP port WireGuard listens on. <code>51820</code> is the default.</li>
<li><strong><code>[Peer]</code> + <code>AllowedIPs = 10.8.0.2/32</code></strong> – this client may only appear under the VPN
IP <code>10.8.0.2</code>. <strong>Important:</strong> <code>AllowedIPs</code> has a different meaning on each side – on the
<strong>server</strong> it&rsquo;s the client&rsquo;s fixed tunnel IP, on the <strong>client</strong> (step 5) it defines
<em>what</em> is routed through the tunnel.</li>
</ul>
<p>For every further client you later simply add another <code>[Peer]</code> block with the next IP
(<code>10.8.0.3/32</code> …).</p>
<h3 id="step-4-open-the-firewall-and-start-the-tunnel">Step 4: Open the firewall and start the tunnel</h3>
<p>WireGuard speaks over <strong>UDP</strong> – open the port. In UFW:</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">sudo ufw allow 51820/udp</span></span></code></pre></div>
</div>
<p>If the netcup firewall also sits in front, you need an inbound rule there
<code>UDP · destination port 51820</code> – the ready-made template is in the
<a href="/en/tutorials/netcup-firewall-setup/">netcup firewall tutorial</a>. Now start the tunnel
and set up autostart:</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">sudo wg-quick up wg0
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> wg-quick@wg0</span></span></code></pre></div>
</div>
<p>Check that the interface is up:</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">sudo wg show 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">interface: wg0
</span></span><span class="line"><span class="cl">  public key: nZT4kozgZjtHVzGfPRL+niI4llEQCUqGpYG0ni+fAT4=
</span></span><span class="line"><span class="cl">  private key: (hidden)
</span></span><span class="line"><span class="cl">  listening port: 51820
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">peer: j5h2jl87JApAbWrJikrth3H/6fNuf6X2TgBgDSJVwwE=
</span></span><span class="line"><span class="cl">  allowed ips: 10.8.0.2/32</span></span></code></pre></div>
</div>
<p>The peer is registered, but there&rsquo;s no <code>latest handshake</code> yet – logical, the client is
still missing.</p>
<h3 id="step-5-set-up-the-client">Step 5: Set up the client</h3>
<p>The client config is the mirror image. Create a file <code>client.conf</code> (on the server to
generate the QR code; you then transfer the content to the client device):</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">PrivateKey</span> <span class="o">=</span> <span class="s">YOUR_CLIENT_PRIVATE_KEY</span>
</span></span><span class="line"><span class="cl"><span class="c1"># DNS = 10.8.0.1   # only enable if a DNS resolver really runs on 10.8.0.1 – otherwise e.g. 1.1.1.1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Peer]</span>
</span></span><span class="line"><span class="cl"><span class="na">PublicKey</span> <span class="o">=</span> <span class="s">YOUR_SERVER_PUBLIC_KEY</span>
</span></span><span class="line"><span class="cl"><span class="na">Endpoint</span> <span class="o">=</span> <span class="s">YOUR_SERVER_IP:51820</span>
</span></span><span class="line"><span class="cl"><span class="na">AllowedIPs</span> <span class="o">=</span> <span class="s">10.8.0.0/24</span>
</span></span><span class="line"><span class="cl"><span class="na">PersistentKeepalive</span> <span class="o">=</span> <span class="s">25</span></span></span></code></pre></div>
</div>
<p>The decisive lines:</p>
<ul>
<li><strong><code>Endpoint</code></strong> – the <strong>public</strong> IP of your server plus port. The client builds the
tunnel to here.</li>
<li><strong><code>AllowedIPs = 10.8.0.0/24</code></strong> – this is the <strong>split tunnel</strong>: only traffic into the
VPN subnet runs through the tunnel, your normal internet traffic stays direct. For the
<strong>full tunnel</strong> (everything via the server) you set <code>0.0.0.0/0</code> here – see step 6.</li>
<li><strong><code>DNS = 10.8.0.1</code></strong> – deliberately commented out. This line routes the <strong>entire</strong>
system DNS of your device to <code>10.8.0.1</code> – but by default <strong>nothing</strong> listens there on
port 53, so with the tunnel active, name resolution breaks. Only set it if a dedicated
DNS resolver/filter really runs on the server; if you just need working DNS in the
tunnel, enter a real resolver like <code>1.1.1.1</code> instead.</li>
<li><strong><code>PersistentKeepalive = 25</code></strong> – keeps the connection open when the client sits behind
a NAT/CGNAT (phone on the mobile network).</li>
</ul>
<p>For the phone, generate a QR code from the file right in the terminal – in the WireGuard
app &ldquo;Add tunnel → Scan QR code&rdquo;:</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">qrencode -t ansiutf8 &lt; client.conf</span></span></code></pre></div>
</div>
<p>On the desktop you simply import the <code>client.conf</code> in the WireGuard app. Afterwards
<strong>delete</strong> the <code>client.conf</code> and <code>client.key</code> from the server (they belong on the end
device, not on the server).</p>
<h3 id="step-6-check-the-connection-and-enable-the-full-tunnel">Step 6: Check the connection (and enable the full tunnel)</h3>
<p>Activate the tunnel in the client app and check on the server:</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">sudo wg show 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">peer: j5h2jl87JApAbWrJikrth3H/6fNuf6X2TgBgDSJVwwE=
</span></span><span class="line"><span class="cl">  endpoint: 203.0.113.24:34582
</span></span><span class="line"><span class="cl">  allowed ips: 10.8.0.2/32
</span></span><span class="line"><span class="cl">  latest handshake: 45 seconds ago
</span></span><span class="line"><span class="cl">  transfer: 1.58 KiB received, 1.39 KiB sent</span></span></code></pre></div>
</div>
<p>A <code>latest handshake</code> plus a growing <code>transfer</code> means the tunnel is up. From the client
you can now reach the server at its VPN IP:</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">ping 10.8.0.1</span></span></code></pre></div>
</div>
<p>If you want the <strong>full tunnel</strong> (all traffic via the VPN), two changes are needed. On the
<strong>client</strong>, set <code>AllowedIPs = 0.0.0.0/0</code>. Also set a <code>DNS =</code> line on the client now (e.g.
<code>DNS = 1.1.1.1</code> or your own resolver): once <em>all</em> traffic runs through the tunnel, the
DNS leak past the tunnel is in fact the <strong>normal case</strong>, not the exception – without this
line your device keeps querying the DNS servers of your local network (details in the
<a href="/en/tutorials/wireguard-vpn-setup/#when-things-go-wrong">DNS leak entry</a> in the troubleshooting part). On the <strong>server</strong>,
enable routing plus NAT – first turn on IP forwarding permanently:</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">echo</span> <span class="s2">&#34;net.ipv4.ip_forward=1&#34;</span> <span class="p">|</span> sudo tee /etc/sysctl.d/99-wireguard.conf
</span></span><span class="line"><span class="cl">sudo sysctl -p /etc/sysctl.d/99-wireguard.conf</span></span></code></pre></div>
</div>
<p>Then add the forwarding and NAT rules to the <code>[Interface]</code> section of <code>wg0.conf</code> (replace
<code>eth0</code> with your real external interface, see <code>ip route get 1.1.1.1</code>):</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="na">PostUp</span> <span class="o">=</span> <span class="s">iptables -I FORWARD 1 -i wg0 -j ACCEPT; iptables -I FORWARD 1 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE</span>
</span></span><span class="line"><span class="cl"><span class="na">PostDown</span> <span class="o">=</span> <span class="s">iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE</span></span></span></code></pre></div>
</div>
<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>Docker on the same server? Then the rules must go to the top
  </p>
  <div class="prose-kitchen text-sm">If Docker also runs on the server (e.g. your <a href="/en/tutorials/traefik-reverse-proxy/">Traefik</a>),
Docker sets the <code>FORWARD</code> policy to <strong>DROP</strong> and pushes its own chains in front. If you
append the WireGuard rules with <code>-A</code> (append), the <strong>return path</strong> of your reply packets
is dropped by Docker – the handshake works, but no internet arrives. That&rsquo;s why above we
use <code>-I FORWARD 1</code> (<strong>insert</strong> at the very front) and the <code>RELATED,ESTABLISHED</code> rule for
the return path. The same applies with UFW active, since it too sets a <code>FORWARD</code> default
policy of DROP – the <code>-I FORWARD 1</code> overrides it, so the rules take effect in both cases.
Tested exactly like this: after that, the client goes onto the net with the server&rsquo;s IP.</div>
</div>
<p>After <code>sudo wg-quick down wg0 &amp;&amp; sudo wg-quick up wg0</code> you check the full tunnel by
querying your public IP on the client – it should now be the <strong>server IP</strong>:</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 ifconfig.me</span></span></code></pre></div>
</div>
<h3 id="step-7-make-a-service-reachable-only-via-the-vpn">Step 7: Make a service reachable only via the VPN</h3>
<p>Now the real payoff. A service only you need doesn&rsquo;t have to sit publicly behind Traefik
– you bind it to the <strong>VPN IP</strong> of the server (<code>10.8.0.1</code>), then it&rsquo;s reachable
exclusively through the tunnel, without a single port on the internet.</p>
<p>For a Docker service that means: publish the port not to <code>0.0.0.0</code>, but specifically to
the VPN IP. So instead of <code>- &quot;8080:80&quot;</code>:</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:8080:80&#34;</span></span></span></code></pre></div>
</div>
<p>The service thus listens only on the WireGuard interface – it&rsquo;s invisible from the
internet, and via the VPN you reach it at <code>http://10.8.0.1:8080</code>. (Important: <code>wg0</code> must
already be up when the container starts – the <code>systemctl enable</code> from step 4 ensures
that.)</p>
<p>By the same principle you can also allow <strong>SSH only via the VPN</strong>: in UFW, remove the
public SSH access and instead allow it only from the VPN subnet –</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">sudo ufw allow from 10.8.0.0/24 to any port <span class="m">22</span> proto tcp</span></span></code></pre></div>
</div>
<p>– then delete the public SSH rule. <strong>Be sure to</strong> test a second connection via the VPN
beforehand, or you&rsquo;ll lock yourself out (in an emergency the VNC console in the
<a href="/en/tutorials/netcup-snapshots-scp/">netcup SCP</a> helps).</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>A <code>latest handshake</code> never appears in <code>wg show</code>.</strong> The UDP port isn&rsquo;t reachable or the
keys don&rsquo;t match. WireGuard is <strong>silent</strong> with wrong keys – there&rsquo;s no error message,
just no handshake. Check: <code>sudo ufw status</code> (port <code>51820/udp</code> open?), the netcup firewall
(inbound <code>UDP 51820</code>), the <code>Endpoint</code> in the client (correct public IP?) and that the
private and public keys aren&rsquo;t swapped.</p>
<p><strong>Handshake is there, but no internet arrives in the full tunnel.</strong> Almost always the
routing/NAT. Is <code>net.ipv4.ip_forward=1</code> set (<code>sysctl net.ipv4.ip_forward</code>)? If Docker is
running – or UFW is active – the <code>FORWARD</code> DROP trap from the warning box above applies;
both set the forward policy to DROP. The rules must sit <strong>before</strong> the existing chains
with <code>-I FORWARD 1</code>. Is the external interface in the <code>MASQUERADE</code> correct (<code>eth0</code> vs.
something else)?</p>
<p><strong>Full tunnel is up, but DNS queries still run past the tunnel (DNS leak).</strong> Without a
<code>DNS =</code> line, your device keeps querying the DNS servers of your local network – on open
Wi-Fi the operator still sees which domains you visit. On Linux, check with <code>resolvectl status</code> which DNS server is assigned to the <code>wg0</code> interface, or run a leak test (e.g. on
<code>dnsleaktest.com</code>): if foreign resolvers appear there, enter a real resolver in the
client config (<code>DNS = 1.1.1.1</code>) – or the server itself (<code>10.8.0.1</code>) if a dedicated DNS
resolver runs there.</p>
<p><strong>The tunnel is up, but large transfers (SSH, HTTPS, downloads) hang or break off.</strong> An
MTU problem. <code>wg-quick</code> sets the MTU to <code>1420</code>; in some networks (DS-Lite, certain mobile
networks) that&rsquo;s still too high. Try <code>MTU = 1412</code> or <code>1280</code> in the <code>[Interface]</code> section
of the client.</p>
<p><strong><code>wg-quick up</code> reports <code>resolvconf: command not found</code>.</strong> The <code>DNS =</code> line needs
<code>resolvconf</code>. Either install <code>sudo apt install openresolv</code> or remove the <code>DNS</code> line if
you don&rsquo;t need the VPN DNS.</p>
<p><strong>The connection falls asleep as soon as the phone briefly sends nothing.</strong> The client
sits behind NAT/CGNAT. <code>PersistentKeepalive = 25</code> in the client config keeps the
connection open.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li>
<p><strong>Add more devices.</strong> One key pair per device and another <code>[Peer]</code> block in <code>wg0.conf</code>
with the next free IP (<code>10.8.0.3/32</code> …). Never use the same key on two devices. So that
existing tunnels <strong>don&rsquo;t drop</strong> in the process, reload the changed config live instead
of doing <code>down/up</code>:</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">sudo wg syncconf wg0 &lt;<span class="o">(</span>wg-quick strip wg0<span class="o">)</span></span></span></code></pre></div>
</div>
</li>
<li>
<p><strong>Back up the keys – they are the heart of the VPN.</strong> Include <code>/etc/wireguard/</code> in your
<a href="/en/tutorials/restic-backups/">encrypted Restic backup</a>. Whoever has the private keys
gets into the VPN – treat them like passwords.</p>
</li>
<li>
<p><strong>Keep it current.</strong> <code>wireguard-tools</code> gets its updates via
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automatic security updates</a>; the
kernel module comes with the kernel updates.</p>
</li>
<li>
<p><strong>Bind internal services to the VPN.</strong> The real payoff: services no one from the
internet should reach, you let listen only on <code>10.8.0.1</code> (instead of <code>0.0.0.0</code>) – then
they&rsquo;re reachable exclusively through the tunnel, without any public port.</p>
</li>
<li>
<p><strong>On a server migration</strong>, carry the new public IP over into the <code>Endpoint</code> of every
client – otherwise the client no longer finds the server.</p>
</li>
<li>
<p><strong>Honest about the effort:</strong> once set up, WireGuard runs practically maintenance-free.
The only recurring task is creating a peer per new device.</p>
</li>
</ul>
<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>Many devices? Then a mesh manager pays off
  </p>
  <div class="prose-kitchen text-sm">As long as you connect a handful of devices to <strong>one</strong> server, plain WireGuard here is
exactly right – full control, nothing runs via third parties. But as soon as you want to
network many devices with each other (laptop ↔ phone ↔ several servers), the manual peer
maintenance gets tedious. Then it&rsquo;s worth a look at <strong>Tailscale</strong> or the self-hosted
<strong>Headscale</strong>: both build <em>on</em> WireGuard and only take over the key exchange and the
networking automatically. You&rsquo;ll still understand the foundation from this tutorial – it&rsquo;s
the same underneath.</div>
</div>
]]></content:encoded></item></channel></rss>