<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex Miller</title>
    <description>The latest articles on DEV Community by Alex Miller (@inzheneher).</description>
    <link>https://dev.to/inzheneher</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3882389%2F1ace5e68-703e-41c0-abd4-048948c3c32b.png</url>
      <title>DEV Community: Alex Miller</title>
      <link>https://dev.to/inzheneher</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/inzheneher"/>
    <language>en</language>
    <item>
      <title>Remote Server Monitoring over VPN: A Docker Approach (Part 1)</title>
      <dc:creator>Alex Miller</dc:creator>
      <pubDate>Thu, 16 Apr 2026 17:29:11 +0000</pubDate>
      <link>https://dev.to/inzheneher/remote-server-monitoring-over-vpn-a-docker-approach-part-1-35il</link>
      <guid>https://dev.to/inzheneher/remote-server-monitoring-over-vpn-a-docker-approach-part-1-35il</guid>
      <description>&lt;h2&gt;
  
  
  The Dilemma of Remote Monitoring
&lt;/h2&gt;

&lt;p&gt;If you've ever tried to set up a monitoring stack for a scattered infrastructure—say, a local home server, a cheap VPS in Europe, and a ZimaBoard running at your parents' house - you've probably faced the ultimate dilemma: &lt;strong&gt;How do I collect metrics securely?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The classic monitoring stack involves Prometheus pulling data from targets running agents like Node Exporter or cAdvisor. But leaving ports like &lt;code&gt;9100&lt;/code&gt; or &lt;code&gt;8080&lt;/code&gt; wide open to the public internet is a disaster waiting to happen. Scanners will find them within hours, leading to garbage traffic, leaked system information, or worse.&lt;/p&gt;

&lt;p&gt;Many developers solve this by installing a VPN (like WireGuard) directly on the host OS. But this approach has major flaws: it mixes your personal VPN traffic with technical traffic, requires messy &lt;code&gt;iptables&lt;/code&gt; routing, and breaks the clean isolation that Docker is supposed to provide.&lt;/p&gt;

&lt;p&gt;In this series, I'll show you an elegant way to connect isolated monitoring components into a single, secure network using the &lt;strong&gt;VPN Sidecar pattern and Docker's Network Namespace sharing&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture Concept
&lt;/h2&gt;

&lt;p&gt;Instead of configuring a VPN at the OS level, we will isolate WireGuard (or AmneziaWG, if you need to bypass DPI) inside a lightweight Docker container on our Hub server.&lt;/p&gt;

&lt;p&gt;Here is the magic trick: &lt;strong&gt;We won't give our monitoring services (like Prometheus) their own Docker networks. Instead, we will force them to use the VPN container's network stack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;To Prometheus, it will look like it's natively sitting inside the VPN subnet (e.g., &lt;code&gt;10.10.0.x&lt;/code&gt;). It will be able to scrape remote nodes using their internal VPN IPs without routing a single byte through the public internet, and without exposing any host ports.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Setting Up the VPN Hub
&lt;/h2&gt;

&lt;p&gt;Let's start by creating the foundation on our local server (the Hub), which will store the metrics.&lt;/p&gt;

&lt;p&gt;Create a directory for your project and add a &lt;code&gt;docker-compose.yaml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wg-monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amneziavpn/amnezia-wg&lt;/span&gt; &lt;span class="c1"&gt;# or linuxserver/wireguard&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wg-monitoring&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NET_ADMIN&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYS_MODULE&lt;/span&gt;
    &lt;span class="na"&gt;sysctls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;net.ipv4.conf.all.src_valid_mark=1&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;51820:51820/udp"&lt;/span&gt; &lt;span class="c1"&gt;# The only port we expose to the internet&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./wireguard/config:/config&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring_net&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;monitoring_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's happening here?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We grant &lt;code&gt;NET_ADMIN&lt;/code&gt; capabilities so the container can create the &lt;code&gt;wg0&lt;/code&gt; network interface.&lt;/li&gt;
&lt;li&gt;We expose UDP port &lt;code&gt;51820&lt;/code&gt; so our remote servers can connect to this Hub.&lt;/li&gt;
&lt;li&gt;We mount a volume containing our standard &lt;code&gt;wg0.conf&lt;/code&gt; (your WireGuard/AmneziaWG server configuration).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's assume our Hub is assigned the IP &lt;code&gt;10.10.0.1&lt;/code&gt; inside the VPN.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: The Network Namespace Magic
&lt;/h2&gt;

&lt;p&gt;Now, let's add a service to the same &lt;code&gt;docker-compose.yaml&lt;/code&gt; that actually needs to access the remote servers. Let's add Prometheus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
  &lt;span class="c1"&gt;# WARNING: This is where the magic happens!&lt;/span&gt;
  &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service:wg-monitoring"&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wg-monitoring&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus:/etc/prometheus&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Notice the key line&lt;/strong&gt;: &lt;code&gt;network_mode: "service:wg-monitoring"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because of this line, we &lt;em&gt;do not&lt;/em&gt; define a &lt;code&gt;networks&lt;/code&gt; or &lt;code&gt;ports&lt;/code&gt; block for Prometheus. From a networking perspective, the &lt;code&gt;prometheus&lt;/code&gt; container now shares the exact same network stack as &lt;code&gt;wg-monitoring&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you were to &lt;code&gt;exec&lt;/code&gt; into the Prometheus container and run &lt;code&gt;ip a&lt;/code&gt;, you would see the &lt;code&gt;wg0&lt;/code&gt; interface with the IP &lt;code&gt;10.10.0.1&lt;/code&gt;. Prometheus can now directly reach any client connected to this VPN.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Securing the Remote Nodes
&lt;/h2&gt;

&lt;p&gt;On your remote server (let's call it Node 1), we need to deploy a VPN client container alongside our metrics agents.&lt;/p&gt;

&lt;p&gt;Here is the &lt;code&gt;docker-compose.yaml&lt;/code&gt; for the remote server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;wg-client&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amneziavpn/amnezia-wg&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wg-client&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NET_ADMIN&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SYS_MODULE&lt;/span&gt;
    &lt;span class="na"&gt;sysctls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;net.ipv4.conf.all.src_valid_mark=1&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./wireguard/config:/config&lt;/span&gt; &lt;span class="c1"&gt;# Client config with IP 10.10.0.2&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;node-exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/node-exporter&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node-exporter&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service:wg-client"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;wg-client&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/proc:/host/proc:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/host/sys:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/:/rootfs:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We apply the exact same pattern here: &lt;code&gt;node-exporter&lt;/code&gt; shares the network namespace with &lt;code&gt;wg-client&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now, Node Exporter is happily collecting host metrics and serving them on port &lt;code&gt;9100&lt;/code&gt; — but &lt;strong&gt;only inside the VPN tunnel&lt;/strong&gt; at &lt;code&gt;10.10.0.2&lt;/code&gt;. Not a single port scanner on the public internet can reach your metrics, because the port is never published to the host OS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up Part 1
&lt;/h2&gt;

&lt;p&gt;We have successfully created an invisible, encrypted L3 bridge between containers running on servers that might be thousands of miles apart. We achieved this cleanly without littering our host systems with complex &lt;code&gt;iptables&lt;/code&gt; rules, maintaining the true "Infrastructure as Code" spirit.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Part 2&lt;/strong&gt;, we will dive into configuring Prometheus to scrape these remote targets through the tunnel, and we'll set up Grafana so you can safely view your dashboards via the web without exposing your database.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>security</category>
    </item>
  </channel>
</rss>
