<?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 2)</title>
      <dc:creator>Alex Miller</dc:creator>
      <pubDate>Thu, 23 Apr 2026 21:34:45 +0000</pubDate>
      <link>https://dev.to/inzheneher/remote-server-monitoring-over-vpn-a-docker-approach-part-2-2dli</link>
      <guid>https://dev.to/inzheneher/remote-server-monitoring-over-vpn-a-docker-approach-part-2-2dli</guid>
      <description>&lt;h2&gt;
  
  
  Recap: Where We Left Off
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/inzheneher/remote-server-monitoring-over-vpn-a-docker-approach-part-1-35il"&gt;Part 1&lt;/a&gt; of this series, we established our secure foundation: an encrypted L3 bridge using &lt;strong&gt;AmneziaWG&lt;/strong&gt; and Docker's &lt;strong&gt;Network Namespace sharing&lt;/strong&gt; (&lt;code&gt;network_mode&lt;/code&gt;). We already have our first agent, &lt;code&gt;node-exporter&lt;/code&gt;, silently listening inside the tunnel on the remote node at &lt;code&gt;10.10.0.2:9100&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The traffic is encrypted, the ports are invisible to the public internet, and the whole thing is defined as code. But metrics sitting on a remote node are useless if nothing collects and visualizes them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Plan for Part 2
&lt;/h2&gt;

&lt;p&gt;We still have three pieces missing from the stack. In this part, we will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Add &lt;strong&gt;cAdvisor&lt;/strong&gt; to remote nodes to monitor individual containers.&lt;/li&gt;
&lt;li&gt; Configure &lt;strong&gt;Prometheus&lt;/strong&gt; on the Hub to pull metrics through the tunnel.&lt;/li&gt;
&lt;li&gt; Deploy &lt;strong&gt;Grafana&lt;/strong&gt; to visualize everything in one place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By the end, you will have a fully functional monitoring stack where the entire scrape path runs inside the encrypted tunnel, with no metrics endpoints reachable from the public internet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Monitoring Containers on Remote Nodes
&lt;/h2&gt;

&lt;p&gt;While &lt;code&gt;node-exporter&lt;/code&gt; gives us the "big picture" of the hardware (CPU, RAM, Disk), we need &lt;strong&gt;cAdvisor&lt;/strong&gt; to see what's happening inside our containers.&lt;/p&gt;

&lt;p&gt;Extend the remote node's &lt;code&gt;docker-compose.yaml&lt;/code&gt; with a new &lt;code&gt;cadvisor&lt;/code&gt; service. Just like the node-exporter, it must share the network stack of the VPN client — so no &lt;code&gt;ports:&lt;/code&gt; block is needed, and the metrics endpoint will only be reachable through the tunnel.&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="c1"&gt;# ... (wg-client and node-exporter services from Part 1) ...&lt;/span&gt;

  &lt;span class="na"&gt;cadvisor&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;gcr.io/cadvisor/cadvisor:latest&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;cadvisor&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;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="c1"&gt;# Shares the VPN tunnel&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;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/kmsg&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;/:/rootfs:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run:/var/run:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/sys:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/:/var/lib/docker:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/disk/:/dev/disk:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--housekeeping_interval=30s'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your remote node is serving hardware metrics on port &lt;code&gt;9100&lt;/code&gt; and container metrics on port &lt;code&gt;8080&lt;/code&gt;, but &lt;strong&gt;only&lt;/strong&gt; via the private VPN IP (e.g., &lt;code&gt;10.10.0.2&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Configuring the Scraper on the Hub
&lt;/h2&gt;

&lt;p&gt;The core of our monitoring is &lt;strong&gt;Prometheus&lt;/strong&gt;. Since we will run it inside the VPN container's network stack on the Hub, it can reach remote targets by their static tunnel IPs as if they were on a local LAN.&lt;/p&gt;

&lt;p&gt;Create a file named &lt;code&gt;./prometheus/prometheus.yml&lt;/code&gt; on your Hub:&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;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remote-nodes-hw'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.10.0.2:9100'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# Remote server: Node Exporter&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remote-nodes-containers'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.10.0.2:8080'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# Remote server: cAdvisor&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;hub-local'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;localhost:9090'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the target addresses: from &lt;strong&gt;Prometheus&lt;/strong&gt;'s point of view, &lt;code&gt;10.10.0.2&lt;/code&gt; is just a regular LAN host. It does not know, and does not care, that packets are being encrypted and shipped across the public internet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Deploying Prometheus and Grafana on the Hub
&lt;/h2&gt;

&lt;p&gt;Now let's update the Hub's &lt;code&gt;docker-compose.yaml&lt;/code&gt;. We need to add &lt;strong&gt;Prometheus&lt;/strong&gt; and &lt;strong&gt;Grafana&lt;/strong&gt;, and extend the &lt;code&gt;wg-monitoring&lt;/code&gt; service from &lt;a href="https://dev.to/inzheneher/remote-server-monitoring-over-vpn-a-docker-approach-part-1-35il"&gt;Part 1&lt;/a&gt; to publish the Prometheus UI port.&lt;/p&gt;

&lt;p&gt;Note the networking logic: &lt;strong&gt;Prometheus&lt;/strong&gt; sits &lt;em&gt;inside&lt;/em&gt; the VPN network to "see" the remote targets. &lt;strong&gt;Grafana&lt;/strong&gt; sits &lt;em&gt;outside&lt;/em&gt; on a standard bridge network and talks to Prometheus through the VPN container's name.&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="c1"&gt;# ... (base config from Part 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;# VPN listener (from Part 1)&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;         &lt;span class="c1"&gt;# Prometheus UI (see note below)&lt;/span&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:latest&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="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;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="c1"&gt;# Hidden inside VPN namespace&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/prometheus.yml:/etc/prometheus/prometheus.yml&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus_data:/prometheus&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;

  &lt;span class="na"&gt;grafana&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;grafana/grafana:latest&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;grafana&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;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;3000:3000"&lt;/span&gt;         &lt;span class="c1"&gt;# Grafana UI (LAN access only)&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&lt;/span&gt;          &lt;span class="c1"&gt;# Talks to wg-monitoring via bridge&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_PROMETHEUS_URL=http://wg-monitoring:9090&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;./grafana_data:/var/lib/grafana&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&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;One important subtlety: because &lt;strong&gt;Prometheus&lt;/strong&gt; uses &lt;code&gt;network_mode: "service:wg-monitoring"&lt;/code&gt;, it inherits the VPN container's network stack and cannot declare its own &lt;code&gt;ports:&lt;/code&gt; block. To reach the &lt;strong&gt;Prometheus UI&lt;/strong&gt; from your LAN, you must publish port &lt;code&gt;9090&lt;/code&gt; on the &lt;code&gt;wg-monitoring&lt;/code&gt; service — that's why the ports list there now has two entries instead of one.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Connection Works
&lt;/h2&gt;

&lt;p&gt;When you log into Grafana at &lt;code&gt;http://hub-ip:3000&lt;/code&gt;, you simply add a &lt;strong&gt;Prometheus&lt;/strong&gt; data source pointing to &lt;code&gt;http://wg-monitoring:9090&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Grafana&lt;/strong&gt; sends a query to the &lt;code&gt;wg-monitoring&lt;/code&gt; container over the &lt;code&gt;monitoring&lt;/code&gt; bridge network.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Prometheus&lt;/strong&gt;, which shares the same network stack, intercepts that request on port &lt;code&gt;9090&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Prometheus&lt;/strong&gt; then looks at its config, sees the target &lt;code&gt;10.10.0.2&lt;/code&gt;, and routes the scrape request through the &lt;code&gt;wg0&lt;/code&gt; interface.&lt;/li&gt;
&lt;li&gt; The encrypted traffic travels across the public internet, hits the remote VPN client, and pulls metrics from the exporters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Zero metrics ports are exposed on the remote servers, and the Hub exposes nothing to the public internet beyond the VPN listener itself.&lt;/strong&gt;&lt;/p&gt;




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

&lt;p&gt;We now have a complete monitoring pipeline: remote agents collect metrics, &lt;strong&gt;Prometheus&lt;/strong&gt; scrapes them through an encrypted tunnel, and Grafana renders dashboards without ever touching the public IPs of the remote nodes.&lt;/p&gt;

&lt;p&gt;A note on access: the &lt;strong&gt;Grafana UI&lt;/strong&gt; (&lt;code&gt;3000&lt;/code&gt;) and &lt;strong&gt;Prometheus UI&lt;/strong&gt; (&lt;code&gt;9090&lt;/code&gt;) are bound to the Hub's host ports, but they are not meant to be reachable from the public internet. In a typical home-lab setup, you access them either from inside your LAN, or by tunneling into the LAN over a separate user-facing VPN. The only port the Hub actually offers to the outside world is the &lt;strong&gt;AmneziaWG&lt;/strong&gt; listener on UDP &lt;code&gt;51820&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, this setup still has a major weakness. If your server reboots, &lt;strong&gt;Docker&lt;/strong&gt; might start &lt;strong&gt;Prometheus&lt;/strong&gt; before the VPN interface is actually up, leading to a "blind" stack that needs manual restarts.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Part 3&lt;/strong&gt;, we will tackle these &lt;strong&gt;Race Conditions&lt;/strong&gt; with &lt;strong&gt;Docker Healthchecks&lt;/strong&gt; and wire up &lt;strong&gt;Alertmanager&lt;/strong&gt; so failures actually reach you — over Telegram, email, or whatever channel you prefer. In &lt;strong&gt;Part 4&lt;/strong&gt;, we will add &lt;strong&gt;Loki&lt;/strong&gt; and &lt;strong&gt;Promtail&lt;/strong&gt; to aggregate logs through the same encrypted tunnel, so metrics and logs finally live in one place.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>monitoring</category>
      <category>security</category>
    </item>
    <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/amneziawg-go:latest&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.ip_forward=1&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;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/net/tun&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=EST&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;# Publish port to the host machine for incoming connections&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;./config/wg0.conf:/etc/amnezia/amneziawg/wg0.conf&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;command&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;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awg-quick&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;wg0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;infinity"&lt;/span&gt;&lt;span class="pi"&gt;]&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&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&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/amneziawg-go:latest&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.ip_forward=1&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;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/net/tun&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TZ=EST&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;./config/wg0.conf:/etc/amnezia/amneziawg/wg0.conf&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;command&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;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awg-quick&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;wg0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;infinity"&lt;/span&gt;&lt;span class="pi"&gt;]&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:latest&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;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.procfs=/host/proc'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.sysfs=/host/sys'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.rootfs=/rootfs'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'&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>
