<?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: Josh Hall</title>
    <description>The latest articles on DEV Community by Josh Hall (@josh_hall_b54941047f33661).</description>
    <link>https://dev.to/josh_hall_b54941047f33661</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3955665%2F97363ae4-2025-4c73-9ae5-a8906dc62535.png</url>
      <title>DEV Community: Josh Hall</title>
      <link>https://dev.to/josh_hall_b54941047f33661</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/josh_hall_b54941047f33661"/>
    <language>en</language>
    <item>
      <title>Deploy the Arr Stack with Docker Compose (Prowlarr, Radarr, Sonarr)</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:47:19 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/deploy-the-arr-stack-with-docker-compose-prowlarr-radarr-sonarr-30g6</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/deploy-the-arr-stack-with-docker-compose-prowlarr-radarr-sonarr-30g6</guid>
      <description>&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; put your downloads and your media library under &lt;strong&gt;one shared root&lt;/strong&gt; (&lt;code&gt;/data&lt;/code&gt;), mount that same root into every container, and run only the torrent client behind a VPN container. Get the folder layout right and every import is an instant hardlink; get it wrong and every import is a slow copy that doubles your disk usage. Everything below is one Compose file plus three connections in the web UIs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cross-post from &lt;strong&gt;Peira Labs&lt;/strong&gt;. Full version with screenshots at every step: &lt;strong&gt;&lt;a href="https://peira.dev/articles/arr-stack-docker-deploy/" rel="noopener noreferrer"&gt;peira.dev/articles/arr-stack-docker-deploy&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;New to the stack? Start with the concepts: &lt;a href="https://dev.to/josh_hall_b54941047f33661/what-is-the-arr-stack-sonarr-radarr-and-prowlarr-explained-4ocf"&gt;What Is the Arr Stack? Sonarr, Radarr, and Prowlarr Explained&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Lawful use: this automates media you have the rights to, such as your own rips, DRM-free purchases, home video, Linux ISOs, and public-domain or Creative Commons releases. Downloading copyrighted material you haven't paid for is illegal in most places, and it isn't what this guide teaches.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Get the folder layout right first
&lt;/h2&gt;

&lt;p&gt;This is the single most important decision in the build, and the one you can't easily fix later. When downloads and media sit on the same filesystem, Radarr and Sonarr import a finished download as a &lt;strong&gt;hardlink&lt;/strong&gt;: a second directory entry pointing at the same data on disk. It appears in your library instantly, costs no extra space, and the original keeps seeding. Split them into separate mounts and the containers see two filesystems, so every import falls back to copy-and-delete.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://trash-guides.info/File-and-Folder-Structure/Hardlinks-and-Instant-Moves/" rel="noopener noreferrer"&gt;TRaSH Guides&lt;/a&gt; layout uses a single &lt;code&gt;/data&lt;/code&gt; root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/data
├── torrents          # download client writes here
│   ├── movies
│   └── tv
└── media             # media server reads here
    ├── movies
    └── tv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Don't mount &lt;code&gt;/downloads&lt;/code&gt; and &lt;code&gt;/movies&lt;/code&gt; as separate volumes.&lt;/strong&gt; Mount the parent &lt;code&gt;/data&lt;/code&gt; into every app. Create it with the UID/GID you'll give the containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /data/torrents/&lt;span class="o"&gt;{&lt;/span&gt;movies,tv&lt;span class="o"&gt;}&lt;/span&gt; /data/media/&lt;span class="o"&gt;{&lt;/span&gt;movies,tv&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The Compose file
&lt;/h2&gt;

&lt;p&gt;Prowlarr, Radarr, and Sonarr run normally on your LAN. qBittorrent lives inside a &lt;a href="https://github.com/qdm12/gluetun" rel="noopener noreferrer"&gt;gluetun&lt;/a&gt; VPN container, so the torrent traffic, and only the torrent traffic, goes through the VPN. The images are from &lt;a href="https://www.linuxserver.io/" rel="noopener noreferrer"&gt;LinuxServer.io&lt;/a&gt;, which share one &lt;code&gt;PUID&lt;/code&gt;/&lt;code&gt;PGID&lt;/code&gt; permissions model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make these values your own first:&lt;/strong&gt; &lt;code&gt;YOUR_WIREGUARD_PRIVATE_KEY&lt;/code&gt; (from your VPN provider's WireGuard config; keep it in a secret store), &lt;code&gt;protonvpn&lt;/code&gt; and &lt;code&gt;Netherlands&lt;/code&gt; (your provider and server country), &lt;code&gt;192.168.1.0/24&lt;/code&gt; (your real LAN subnet), &lt;code&gt;1000&lt;/code&gt;/&lt;code&gt;1000&lt;/code&gt; (the UID/GID that owns &lt;code&gt;/data&lt;/code&gt;; check with &lt;code&gt;id&lt;/code&gt;), and the &lt;code&gt;/opt/...&lt;/code&gt; config paths.&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;gluetun&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;qmcgaw/gluetun: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;gluetun&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="nv"&gt;NET_ADMIN&lt;/span&gt;&lt;span class="pi"&gt;]&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="nv"&gt;/dev/net/tun&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/dev/net/tun&lt;/span&gt;&lt;span class="pi"&gt;]&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;8080:8080"&lt;/span&gt;        &lt;span class="c1"&gt;# qBittorrent WebUI, published by gluetun&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;VPN_SERVICE_PROVIDER=protonvpn&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VPN_TYPE=wireguard&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WIREGUARD_PRIVATE_KEY=YOUR_WIREGUARD_PRIVATE_KEY&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SERVER_COUNTRIES=Netherlands&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VPN_PORT_FORWARDING=on&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24&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;qbittorrent&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;lscr.io/linuxserver/qbittorrent: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;qbittorrent&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:gluetun"&lt;/span&gt;      &lt;span class="c1"&gt;# all traffic through the VPN&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="nv"&gt;PUID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;PGID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;TZ=Etc/UTC&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;WEBUI_PORT=8080&lt;/span&gt;&lt;span class="pi"&gt;]&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;/opt/qbittorrent:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/data/torrents:/data/torrents&lt;/span&gt;     &lt;span class="c1"&gt;# same path as the Arr apps&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="nv"&gt;gluetun&lt;/span&gt;&lt;span class="pi"&gt;]&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;prowlarr&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;lscr.io/linuxserver/prowlarr: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;prowlarr&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;9696:9696"&lt;/span&gt;&lt;span class="pi"&gt;]&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="nv"&gt;PUID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;PGID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;TZ=Etc/UTC&lt;/span&gt;&lt;span class="pi"&gt;]&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="nv"&gt;/opt/prowlarr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/config&lt;/span&gt;&lt;span class="pi"&gt;]&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;radarr&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;lscr.io/linuxserver/radarr: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;radarr&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;7878:7878"&lt;/span&gt;&lt;span class="pi"&gt;]&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="nv"&gt;PUID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;PGID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;TZ=Etc/UTC&lt;/span&gt;&lt;span class="pi"&gt;]&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;/opt/radarr:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/data:/data&lt;/span&gt;                        &lt;span class="c1"&gt;# ONE shared root -&amp;gt; hardlinks&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;sonarr&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;lscr.io/linuxserver/sonarr: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;sonarr&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;8989:8989"&lt;/span&gt;&lt;span class="pi"&gt;]&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="nv"&gt;PUID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;PGID=1000&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;TZ=Etc/UTC&lt;/span&gt;&lt;span class="pi"&gt;]&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;/opt/sonarr:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/data:/data&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;gluetun blocks everything except the VPN by default, so &lt;code&gt;FIREWALL_OUTBOUND_SUBNETS&lt;/code&gt; must match your real LAN or the Arr apps can't reach qBittorrent. Bring it up and prove the tunnel works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# This MUST print a VPN IP, not your home IP:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;gluetun wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; https://api.ipify.org &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Wire the apps together, in this order
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Register Sonarr and Radarr in Prowlarr.&lt;/strong&gt; Copy each app's API key from its Settings → General page, then add both in Prowlarr → Settings → Apps using the container URLs (&lt;code&gt;http://radarr:7878&lt;/code&gt;, &lt;code&gt;http://sonarr:8989&lt;/code&gt;). Containers reach each other by service name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqw9pt8cz941h3fgyfsq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiqw9pt8cz941h3fgyfsq.webp" alt="Prowlarr Settings, Apps: Radarr and Sonarr registered, each with a green Full Sync badge" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add indexers in Prowlarr only.&lt;/strong&gt; They sync to Radarr and Sonarr automatically, so you never maintain two lists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add qBittorrent as the download client&lt;/strong&gt; in Radarr and in Sonarr: Settings → Download Clients → qBittorrent at &lt;code&gt;http://&amp;lt;host-ip&amp;gt;:8080&lt;/code&gt; (gluetun publishes the UI on the host), with a category of &lt;code&gt;radarr&lt;/code&gt; or &lt;code&gt;sonarr&lt;/code&gt; so downloads land in the right subfolder.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F334dwsia6ndh3s32062p.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F334dwsia6ndh3s32062p.webp" alt="Radarr Settings, Download Clients: qBittorrent enabled" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set each root folder to the media subpath:&lt;/strong&gt; &lt;code&gt;/data/media/movies&lt;/code&gt; in Radarr, &lt;code&gt;/data/media/tv&lt;/code&gt; in Sonarr. Because qBittorrent writes to &lt;code&gt;/data/torrents/...&lt;/code&gt; under the same mount, imports become hardlinks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8ow0o622dimbb865nwq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh8ow0o622dimbb865nwq.webp" alt="Radarr Media Management: a single root folder on the media path" width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Prove it end to end
&lt;/h2&gt;

&lt;p&gt;Add one movie in Radarr, mark it monitored, and hit &lt;strong&gt;Search&lt;/strong&gt;. Radarr queries Prowlarr's indexers, picks a release by its quality settings, hands it to qBittorrent, and imports the finished file into &lt;code&gt;/data/media/movies&lt;/code&gt;. Success looks like this: &lt;strong&gt;the import completes instantly and your disk usage doesn't jump by the file's size.&lt;/strong&gt; If either is off, look at the folder layout from step 1 first.&lt;/p&gt;

&lt;p&gt;If a download finishes but won't import, it's almost always ownership. Keep the same &lt;code&gt;PUID&lt;/code&gt;/&lt;code&gt;PGID&lt;/code&gt; across qBittorrent and the Arr apps, and make sure &lt;code&gt;/data&lt;/code&gt; belongs to that user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go further
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Copy-paste version: the &lt;a href="https://peira.dev/playbooks/arr-stack-docker-compose/" rel="noopener noreferrer"&gt;Arr stack Compose playbook&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The download client in depth, with kill-switch and port-forward sync: the &lt;a href="https://peira.dev/playbooks/vpn-torrent-stack-docker/" rel="noopener noreferrer"&gt;VPN torrent stack playbook&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Take it hands-off with import lists, quality profiles, and a media-server rescan: &lt;a href="https://peira.dev/articles/arr-stack-automation/" rel="noopener noreferrer"&gt;Automating the Arr Stack&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: &lt;a href="https://trash-guides.info/File-and-Folder-Structure/Hardlinks-and-Instant-Moves/" rel="noopener noreferrer"&gt;TRaSH Guides: Hardlinks and Instant Moves&lt;/a&gt; · &lt;a href="https://wiki.servarr.com/docker-guide" rel="noopener noreferrer"&gt;Servarr Docker Guide&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Peira Labs. Full version, with the screenshots and FAQ, at &lt;a href="https://peira.dev/articles/arr-stack-docker-deploy/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Homelab Capacity Planning: What If a Node Dies Tonight?</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Tue, 22 Sep 2026 15:02:58 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/homelab-capacity-planning-what-if-a-node-dies-tonight-3djh</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/homelab-capacity-planning-what-if-a-node-dies-tonight-3djh</guid>
      <description>&lt;p&gt;Every dashboard in my rack could tell me what was happening &lt;em&gt;right now&lt;/em&gt; — and not one could answer the question that actually keeps me up: &lt;strong&gt;if one of my four nodes dies tonight, do the containers it was running fit on the three that survive, and how long until they're back?&lt;/strong&gt; I'd been answering that with a shrug. So I built a read-only simulator that reads the real cluster, does the arithmetic, and tells me plainly when the answer is no. On day one, it told me no.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cross-post from &lt;strong&gt;Peira Labs&lt;/strong&gt;. The full version — with the diagrams and the fifteen-line packer — lives at &lt;strong&gt;&lt;a href="https://peira.dev/articles/homelab-capacity-planning-node-failure/" rel="noopener noreferrer"&gt;https://peira.dev/articles/homelab-capacity-planning-node-failure/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Plan against RAM you actually use, not RAM you configured
&lt;/h2&gt;

&lt;p&gt;The configured memory &lt;em&gt;maximum&lt;/em&gt; is a ceiling you picked once, usually generously. Plan against it and your cluster looks far more full than it is. Plan against a &lt;strong&gt;multi-day observed peak&lt;/strong&gt; from your metrics and you learn what the workload really needs — while the configured limit still protects the host from a runaway process. Actual usage for planning, configured limits for safety.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Just migrate it" isn't the recovery plan
&lt;/h2&gt;

&lt;p&gt;Without shared storage, Proxmox HA can't restart a guest elsewhere — it can't reach the disk. On a local-disk cluster the real recovery path is a &lt;strong&gt;restore from backup onto a surviving node&lt;/strong&gt;, so the only two questions that matter are: do the survivors have room, and how long does the restore take?&lt;/p&gt;

&lt;h2&gt;
  
  
  Packing the survivors: first-fit decreasing
&lt;/h2&gt;

&lt;p&gt;Sort the homeless containers largest-first, then drop each into the first survivor it fits in. Sorting big-first places the awkward items while there's still room — provably close to optimal, which is far more accuracy than a homelab estimate needs. Fifteen lines of Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore time: measure once, then divide
&lt;/h2&gt;

&lt;p&gt;Don't borrow someone else's number. Restore one real guest, time it, divide backup size by elapsed time to get &lt;em&gt;your&lt;/em&gt; throughput. From then on, any restore estimate is just backup-size ÷ that rate. Your disks, network, and compression make a borrowed figure confidently wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scenario with no ETA
&lt;/h2&gt;

&lt;p&gt;The nastiest case: the node that dies is the one running your &lt;strong&gt;backup server&lt;/strong&gt;. You lose the fast recovery path along with the node — any backup stored only there is gone with it, and recovery falls back to whatever offsite copy you keep (hours, not minutes). It's the strongest argument for a second backup tier somewhere else entirely.&lt;/p&gt;

&lt;p&gt;The simulator told me "no" on day one — one container that wouldn't fit anywhere, and a scenario with no ETA. Both were fixable once I could &lt;em&gt;see&lt;/em&gt; them. If you want to run the same math on your own lab, I turned it into two free tools: the &lt;a href="https://peira.dev/tools/node-failure-simulator/" rel="noopener noreferrer"&gt;node-failure simulator&lt;/a&gt; and a &lt;a href="https://peira.dev/tools/blast-radius/" rel="noopener noreferrer"&gt;blast-radius map&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Peira Labs — full walkthrough with diagrams at &lt;a href="https://peira.dev/articles/homelab-capacity-planning-node-failure/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>proxmox</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>Proxmox Datacenter Manager: One Pane Over Every Cluster</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:59:33 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/proxmox-datacenter-manager-one-pane-over-every-cluster-1lbl</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/proxmox-datacenter-manager-one-pane-over-every-cluster-1lbl</guid>
      <description>&lt;p&gt;Run more than one Proxmox cluster and you end up living in browser tabs — one for the office cluster, one for the box at home, one for the backup server. &lt;strong&gt;Proxmox Datacenter Manager (PDM) gives you one read-only pane over all of it — but only bother if you actually run more than one cluster.&lt;/strong&gt; For a single cluster the native Proxmox VE interface already does this, and PDM is just one more thing to host and secure.&lt;/p&gt;

&lt;p&gt;Here's how I deployed PDM 1.1 in a container and connected my clusters with tokens that can only &lt;em&gt;look&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a cross-post from &lt;strong&gt;Peira Labs&lt;/strong&gt;. The full illustrated version — with the add-remote wizard, the architecture diagram, and every gotcha — lives at &lt;strong&gt;&lt;a href="https://peira.dev/articles/proxmox-datacenter-manager/" rel="noopener noreferrer"&gt;https://peira.dev/articles/proxmox-datacenter-manager/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What PDM actually is
&lt;/h2&gt;

&lt;p&gt;A Proxmox VE &lt;em&gt;cluster&lt;/em&gt; fuses several nodes into one system. PDM sits a layer above: it doesn't join anything, it connects to your clusters and standalone nodes as &lt;strong&gt;remotes&lt;/strong&gt; and aggregates them into one screen. Each remote stays independent and hands PDM nothing but an API token — which is the whole security story, because you decide exactly how much that token can do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpdebve66ekmc0tw5nel5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpdebve66ekmc0tw5nel5.webp" alt="PDM login screen" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy it in a container
&lt;/h2&gt;

&lt;p&gt;PDM ships as Debian packages, so a plain Debian 13 ("trixie") LXC does the job (2 vCPU / 2 GB RAM is plenty). Add the no-subscription repo and install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wget https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-O&lt;/span&gt; /usr/share/keyrings/proxmox-archive-keyring.gpg

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/apt/sources.list.d/proxmox.sources &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
Types: deb
URIs: http://download.proxmox.com/debian/pdm
Suites: trixie
Components: pdm-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;apt update
apt &lt;span class="nb"&gt;install &lt;/span&gt;proxmox-datacenter-manager-container-meta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The web UI comes up on &lt;strong&gt;port 8443&lt;/strong&gt;. Log in as &lt;code&gt;root@pam&lt;/code&gt; with the container's root password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect a cluster so it can only look
&lt;/h2&gt;

&lt;p&gt;On each cluster, create a &lt;strong&gt;dedicated user and a read-only token&lt;/strong&gt; rather than handing PDM an admin login. Proxmox VE has a built-in &lt;strong&gt;PVEAuditor&lt;/strong&gt; role (read access, nothing else); Proxmox Backup Server has an &lt;strong&gt;Audit&lt;/strong&gt; role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Proxmox VE&lt;/span&gt;
pveum user add pdm@pve
pveum acl modify / &lt;span class="nt"&gt;-user&lt;/span&gt; pdm@pve &lt;span class="nt"&gt;-role&lt;/span&gt; PVEAuditor
pveum user token add pdm@pve pdm &lt;span class="nt"&gt;--privsep&lt;/span&gt; 0   &lt;span class="c"&gt;# prints the secret ONCE&lt;/span&gt;

&lt;span class="c"&gt;# Proxmox Backup Server&lt;/span&gt;
proxmox-backup-manager user create pdm@pbs
proxmox-backup-manager user generate-token pdm@pbs pdm
proxmox-backup-manager acl update / Audit &lt;span class="nt"&gt;--auth-id&lt;/span&gt; &lt;span class="s1"&gt;'pdm@pbs!pdm'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In PDM, &lt;strong&gt;Remotes → Add&lt;/strong&gt;, give it the node address, let it read back the certificate fingerprint (it &lt;em&gt;pins&lt;/em&gt; it, so a swapped cert breaks the link on purpose), then paste the token. Both remotes then show up with their audit tokens:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk07m2wc53i2bhqy98t5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwk07m2wc53i2bhqy98t5.webp" alt="PDM remotes table with two audit-scoped remotes" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The single pane
&lt;/h2&gt;

&lt;p&gt;With both remotes connected, the dashboard finally aggregates every node, guest, and datastore across separate clusters, with combined CPU/memory/storage gauges:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs04694ikwk6kfk98qybw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs04694ikwk6kfk98qybw.webp" alt="PDM dashboard aggregating resources across remotes" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rough edges
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 403 in the log every cycle — ignore it.&lt;/strong&gt; PDM tries to fetch each node's available updates, which needs &lt;code&gt;Sys.Modify&lt;/code&gt;. Your audit token doesn't have it, so you get a harmless 403 per cycle. That's least-privilege working as intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pure-Audit token can't populate every tile.&lt;/strong&gt; On my PBS remote, PDM's resource-collection call returns &lt;code&gt;403 permission check failed&lt;/code&gt;, so the backup node reads as offline even though the connection is fine. Decide per remote whether you want the extra read scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 1.1.7 client CLI won't do password-file auth.&lt;/strong&gt; The separate &lt;code&gt;proxmox-datacenter-manager-client&lt;/code&gt; rejects &lt;code&gt;--user root@pam --password-file&lt;/code&gt; even when the same credentials pass at the raw API. The UI, admin CLI, and API are all fine — use one of those.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you later want PDM 1.1's cross-cluster VM migration, remember these tokens can't do it (migration writes to both sides) — re-scope deliberately for that job.&lt;/p&gt;

&lt;p&gt;For a single cluster, skip it. But the day you're logging into three Proxmox tabs to answer "is everything up?", PDM is the fix — and you can stand it up in an afternoon with credentials that can't hurt you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written by Peira Labs — full version with the add-remote wizard walkthrough and architecture diagram at &lt;a href="https://peira.dev/articles/proxmox-datacenter-manager/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Track AI Token Spend in Grafana: Claude, Codex, and Ollama</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Tue, 08 Sep 2026 20:35:30 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/track-ai-token-spend-in-grafana-claude-codex-and-ollama-5alm</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/track-ai-token-spend-in-grafana-claude-codex-and-ollama-5alm</guid>
      <description>&lt;p&gt;I let three different AIs work in my homelab every day — a coding assistant, a second CLI for pair-work, and a small agent that triages alerts overnight. One evening I realized I couldn't answer a basic question: &lt;strong&gt;what is all of this actually costing me?&lt;/strong&gt; Two burn subscription quota I've already paid for, one spends real API dollars, and none showed up on the Grafana dashboards I'd built for everything else in the rack.&lt;/p&gt;

&lt;p&gt;So I fixed it. Every call from every AI in the lab — tokens, latency, cache hits, and dollars — now lands in Prometheus and one Grafana dashboard. This walks through the four measurement legs, the PromQL traps that made my first dashboard lie, and the privacy scrub that makes the screenshots publishable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Make the addresses your own.&lt;/strong&gt; Every machine-specific value here is a placeholder: the monitoring host &lt;code&gt;10.0.0.5&lt;/code&gt;, agent host &lt;code&gt;10.0.0.7&lt;/code&gt;, Ollama nodes &lt;code&gt;10.0.0.1&lt;/code&gt;–&lt;code&gt;10.0.0.3&lt;/code&gt;, exporter ports, and any &lt;code&gt;/home/youradmin&lt;/code&gt; paths.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why measure AI at all?
&lt;/h2&gt;

&lt;p&gt;The AI layer has a genuinely weird cost structure. Two interactive CLIs run on flat subscriptions, so their "cost" is quota — a percentage of a weekly allowance. The automated agent calls a hosted API and pays per token. Same lab, three billing models. So the dashboard has two columns: &lt;strong&gt;quota burn&lt;/strong&gt; (a percentage that resets) and &lt;strong&gt;real dollars&lt;/strong&gt; (the metered agent).&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg 1: Claude Code already speaks OpenTelemetry
&lt;/h2&gt;

&lt;p&gt;The coding CLI needs no wrapper — it has &lt;a href="https://code.claude.com/docs/en/monitoring-usage" rel="noopener noreferrer"&gt;native OpenTelemetry support&lt;/a&gt;. Switch it on with env vars:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_ENABLE_TELEMETRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_METRICS_EXPORTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;otlp
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;grpc
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://10.0.0.5:4317
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every session exports &lt;code&gt;claude_code.token.usage&lt;/code&gt; (by type — input, output, cache read), &lt;code&gt;claude_code.cost.usage&lt;/code&gt; in USD, and &lt;code&gt;claude_code.session.count&lt;/code&gt;. Those arrive over OTLP, which Prometheus doesn't scrape directly — so an &lt;a href="https://opentelemetry.io/docs/collector/" rel="noopener noreferrer"&gt;OpenTelemetry Collector&lt;/a&gt; on the monitoring host listens on &lt;code&gt;:4317&lt;/code&gt;, applies processors, and re-exposes everything in Prometheus format on &lt;code&gt;:8889&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg 2: tail the agent's log
&lt;/h2&gt;

&lt;p&gt;The automated agent doesn't speak OTel, but it writes an honest log line per call: provider, model, token counts, latency, cache hit, and the occasional "fallback activated". A ~200-line stdlib-only Python exporter tails the log with a regex, keeps counters in memory, and serves them on &lt;code&gt;:9109&lt;/code&gt;. It also reads the agent's task database (read-only) for queue-depth gauges — so the dashboard shows quality alongside spend: is the agent finishing work, or just burning tokens?&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg 3: Ollama, measured from the outside
&lt;/h2&gt;

&lt;p&gt;My three-node Ollama cluster was stubborn: Ollama ships no Prometheus endpoint (as of the 0.30 releases). But it leaks what you need in two places — its journald request log (status, latency, caller IP) and the documented &lt;code&gt;/api/ps&lt;/code&gt; endpoint (which models are loaded right now). One exporter per node on &lt;code&gt;:9110&lt;/code&gt;. The caller-IP label turned out useful: it shows &lt;em&gt;who&lt;/em&gt; is using the local models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leg 4: the laptop, via Pushgateway
&lt;/h2&gt;

&lt;p&gt;The second CLI (&lt;a href="https://github.com/openai/codex" rel="noopener noreferrer"&gt;Codex&lt;/a&gt;) records tokens and quota in session files, but the laptop sleeps and moves, so Prometheus can't reliably scrape it. That's exactly the &lt;a href="https://prometheus.io/docs/practices/pushing/" rel="noopener noreferrer"&gt;Pushgateway&lt;/a&gt; case: a systemd &lt;em&gt;user&lt;/em&gt; timer parses the session files every 5 minutes and pushes lifetime totals plus quota percentage to &lt;code&gt;:9091&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Wiring it together is four scrape jobs:&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;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="s"&gt;ai_claude_code&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="nv"&gt;targets&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;10.0.0.5:8889"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;   &lt;span class="c1"&gt;# OTel Collector&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="s"&gt;ai_codex&lt;/span&gt;
    &lt;span class="na"&gt;honor_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="nv"&gt;targets&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;10.0.0.5:9091"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;   &lt;span class="c1"&gt;# Pushgateway&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="s"&gt;ai_agent&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="nv"&gt;targets&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;10.0.0.7:9109"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;   &lt;span class="c1"&gt;# log-tail 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="s"&gt;ai_ollama&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="nv"&gt;targets&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;10.0.0.1:9110"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.2:9110"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.3:9110"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three PromQL traps that made v1 lie
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trap 1: per-session counters break &lt;code&gt;increase()&lt;/code&gt;.&lt;/strong&gt; Claude Code's counters are per-session and ephemeral; a short session leaves one sample, and range functions need two, so &lt;code&gt;increase()&lt;/code&gt; returns nothing while a real 30k-token session sits invisible. Read the last value each session reported and sum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(max_over_time(claude_code_token_usage_tokens_total[1d]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trap 2: composite cost math collapses on absent series.&lt;/strong&gt; In PromQL, arithmetic with an empty operand makes the &lt;em&gt;whole expression&lt;/em&gt; empty — so before any cache reads existed, real spend rendered as $0.00. Guard every component with &lt;code&gt;or vector(0)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(sum(rate(input_tokens[1h]))  or vector(0)) * 1.00 / 1e6
+ (sum(rate(cache_tokens[1h])) or vector(0)) * 0.10 / 1e6
+ (sum(rate(output_tokens[1h])) or vector(0)) * 5.00 / 1e6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trap 3: &lt;code&gt;histogram_quantile&lt;/code&gt; returns literal &lt;code&gt;NaN&lt;/code&gt; over idle windows.&lt;/strong&gt; Documented behavior with zero observations — my latency panel drew garbage across every quiet hour. Consumers need to drop non-finite samples; Grafana panels just go sparse when the lab is idle, which is the honest picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy scrub: make dashboards publishable
&lt;/h2&gt;

&lt;p&gt;Claude Code's telemetry attaches identity by default — your &lt;strong&gt;email&lt;/strong&gt;, account ids, org id — as labels on every metric. Useful in a company; radioactive on a public screenshot. Going forward, the OTel Collector deletes those before they reach 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;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;attributes/scrub&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;user.email&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;        &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;delete&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;user.account_uuid&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;delete&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;user.account_id&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;delete&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;user.id&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;           &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;delete&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;organization.id&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;   &lt;span class="nv"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;delete&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete by key, not value, so anyone who ever exported from that laptop gets scrubbed. For history already on disk, open a temporary Prometheus admin window (&lt;code&gt;--web.enable-admin-api&lt;/code&gt;), delete the identity-labeled series, then close it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'http://10.0.0.5:9090/api/v1/admin/tsdb/delete_series?match[]={user_email!=""}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s1"&gt;'http://10.0.0.5:9090/api/v1/admin/tsdb/clean_tombstones'&lt;/span&gt;
&lt;span class="c"&gt;# now remove --web.enable-admin-api from the unit and restart again&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One deliberate non-deletion: I kept &lt;code&gt;session_id&lt;/code&gt;. Dropping it merges per-session cumulative counters into one series (last write wins, totals undercount). Scrub identity; keep cardinality that's structurally load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't page yourself over a sleeping laptop
&lt;/h2&gt;

&lt;p&gt;My Node Down alert was &lt;code&gt;up == 0&lt;/code&gt;. A sleeping laptop is not an outage, so it's now scoped to exclude the AI jobs — a dead AI exporter shows as a dashboard gap, real infra still pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;up{job!~"ai_.*"} == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I actually watch
&lt;/h2&gt;

&lt;p&gt;Not spend — the &lt;strong&gt;cache-hit rate&lt;/strong&gt;. At ~78% cached input, the agent's metered bill stays in coffee money. The day that rate drops is the day something changed in how it builds prompts, and now I'll see it the same morning. Total cost of the measurement layer: two tiny Python exporters, one collector, one gateway, and an evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the full version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/ai-spend-telemetry-grafana/" rel="noopener noreferrer"&gt;Track AI Token Spend in Grafana (full guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/grafana-prometheus-proxmox/" rel="noopener noreferrer"&gt;Monitor Proxmox with Grafana and Prometheus&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/read-only-homelab-mcp-server/" rel="noopener noreferrer"&gt;Build a Read-Only Homelab MCP Server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://peira.dev/articles/ai-spend-telemetry-grafana/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>homelab</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Eleven Free Homelab Tools for the Questions Guides Skip</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Mon, 07 Sep 2026 15:20:12 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/eleven-free-homelab-tools-for-the-questions-guides-skip-203l</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/eleven-free-homelab-tools-for-the-questions-guides-skip-203l</guid>
      <description>&lt;p&gt;Every guide I write ends in the same handful of questions. How much hardware do I actually need? What happens when one box dies? Are my backups real or just a feeling? A guide can walk you through a setup, but it can't do arithmetic about &lt;em&gt;your&lt;/em&gt; lab — so I built eleven small tools that can, at &lt;a href="https://peira.dev/tools/" rel="noopener noreferrer"&gt;peira.dev/tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;They're free, none ask who you are, and seven of the eleven keep working once the page has loaded — network unplugged, laptop in a cupboard, whatever.&lt;/p&gt;

&lt;h2&gt;
  
  
  They share one lab profile
&lt;/h2&gt;

&lt;p&gt;This is the part that makes them a set rather than eleven unrelated pages. Describe your lab once — tick your services in the sizing calculator, press &lt;strong&gt;Save to profile&lt;/strong&gt; — and the others pick it up. The failure simulator opens with your nodes already modelled; the backup planner knows what data you have; the power-loss playbook knows what's plugged in. &lt;strong&gt;Lab doc&lt;/strong&gt; hands the whole thing back as a Markdown file.&lt;/p&gt;

&lt;p&gt;Nothing about that profile leaves your browser. No account, no sync, no server that could leak it — which is also why it doesn't follow you between devices. The Markdown export is how you carry it elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan the build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/sizing-calculator/" rel="noopener noreferrer"&gt;Sizing calculator&lt;/a&gt;&lt;/strong&gt; — asks what you want to run and recommends nodes, RAM, and storage. It cares most about RAM, because that's the constraint that actually bites; vCPUs overcommit happily, memory doesn't. Tick "survive one node failure" and it insists on three nodes (a two-node cluster loses quorum the moment one dies).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/node-failure-simulator/" rel="noopener noreferrer"&gt;Node failure simulator&lt;/a&gt;&lt;/strong&gt; — kill a node and see which workloads fit on the survivors. It places the critical ones first and names the stranded ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/backup-planner/" rel="noopener noreferrer"&gt;3-2-1 backup planner&lt;/a&gt;&lt;/strong&gt; — three copies, two devices, one offsite (the rule &lt;a href="https://www.cisa.gov/audiences/small-and-medium-businesses/secure-your-business/back-up-business-data" rel="noopener noreferrer"&gt;CISA recommends&lt;/a&gt;). It's blunt: a snapshot on the same disk as the original is versioning, not a backup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix what's broken
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;&lt;a href="https://peira.dev/tools/network-diagnostic/" rel="noopener noreferrer"&gt;overlay network diagnostic&lt;/a&gt;&lt;/strong&gt; is a decision tree born from a miserable afternoon: a container couldn't reach a machine across a Tailscale subnet router, and three layers had to be right — the route in the guest, the ACL grant, and a firewall on the destination. Two fail &lt;em&gt;silently&lt;/em&gt;. The tool walks them in order.&lt;/p&gt;

&lt;p&gt;Four more use a language model you point them at yourself (a Claude account, an API key for a provider you choose, or a model on your own hardware — the page calls it directly from your browser, nothing touches my site):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/log-triage/" rel="noopener noreferrer"&gt;Log triage&lt;/a&gt;&lt;/strong&gt; — paste the wall of output, get the one line that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/compose-review/" rel="noopener noreferrer"&gt;Compose review&lt;/a&gt;&lt;/strong&gt; — reads a Docker Compose stack for what bites on the next update, reboot, or dead disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/explain-command/" rel="noopener noreferrer"&gt;Explain before you run&lt;/a&gt;&lt;/strong&gt; — paste a command from a forum reply, get a plain-English breakdown and a red/amber/green "can you undo it?".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/troubleshooter/" rel="noopener noreferrer"&gt;The troubleshooter&lt;/a&gt;&lt;/strong&gt; — describe the symptom, get ranked causes and the commands to check them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Docker fact I got wrong building the compose reviewer, since half the advice online is stale: since Docker 23.0, &lt;code&gt;docker system prune --volumes&lt;/code&gt; and &lt;code&gt;docker volume prune&lt;/code&gt; remove only &lt;strong&gt;anonymous&lt;/strong&gt; volumes, and "unused" means &lt;em&gt;no container attached at all&lt;/em&gt; — a stopped container still protects its volume. Named volumes need &lt;code&gt;docker volume prune --all&lt;/code&gt;. The command that genuinely deletes named volumes is &lt;code&gt;docker compose down -v&lt;/code&gt;. That's the one that eats a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Survive the bad day
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/blast-radius/" rel="noopener noreferrer"&gt;Blast radius mapper&lt;/a&gt;&lt;/strong&gt; — map dependencies, click to kill something, watch the cascade. The ranking is the useful part: it usually shows one unglamorous box (the switch, DNS, the container host) carrying most of the lab. Your recovery plan for that box &lt;em&gt;is&lt;/em&gt; your recovery plan for everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/power-loss-playbook/" rel="noopener noreferrer"&gt;Power-loss playbook&lt;/a&gt;&lt;/strong&gt; — orders machines into shutdown waves (workloads, then compute, then storage, network last, because shutdown commands travel over it) and checks whether the sequence fits inside your battery, with an honest allowance for battery age. It'll generate matching &lt;a href="https://networkupstools.org/" rel="noopener noreferrer"&gt;NUT&lt;/a&gt; settings too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://peira.dev/tools/bus-factor/" rel="noopener noreferrer"&gt;Bus factor tool&lt;/a&gt;&lt;/strong&gt; — the one I'd most like people to use. If you were unavailable for a month, could the people you live with get their photos back and keep the heating on? It asks nine questions and writes a letter for someone who has never used a terminal. It never asks for a password — it asks &lt;em&gt;where the credentials are&lt;/em&gt; and who may open them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it costs and needs
&lt;/h2&gt;

&lt;p&gt;Nothing, and not much. No accounts, no cookies, no analytics on the tool pages. Seven are single HTML files — save the page and they work offline. Four need a model you choose; if you paste a key it stays in your browser and goes only to the provider you picked. Every tool is also a public artifact you can fork, prompts included.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;They're worksheets, not oracles.&lt;/strong&gt; Each is a model of your lab, and a model is always simpler than the thing. Use them to find the question you should be asking, then verify on your actual hardware. The only real backup test is a restore; the only real UPS test is pulling the plug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Read the full versions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/homelab-field-tools/" rel="noopener noreferrer"&gt;Eleven Free Homelab Tools (full post)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://peira.dev/articles/field-tools-how-they-were-built/" rel="noopener noreferrer"&gt;How they were built&lt;/a&gt; — the constraints, three design iterations, and bugs that got past me&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/field-tools-any-model/" rel="noopener noreferrer"&gt;Point the tools at any model&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://peira.dev/articles/homelab-field-tools/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>hardware</category>
      <category>tools</category>
    </item>
    <item>
      <title>Build a Read-Only Homelab MCP Server for Your LLM</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Sun, 06 Sep 2026 18:28:52 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/build-a-read-only-homelab-mcp-server-for-your-llm-336o</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/build-a-read-only-homelab-mcp-server-for-your-llm-336o</guid>
      <description>&lt;p&gt;I wanted to ask my AI assistant plain questions about my homelab — "are all my nodes up?", "any firing alerts?" — and get real answers from the real cluster. What I absolutely did &lt;strong&gt;not&lt;/strong&gt; want was to hand a language model a button that could reboot a node or delete a VM. Convenience is lovely right up until the model confidently does the wrong thing at 2 a.m.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;read-only MCP server&lt;/strong&gt;: a small program that exposes your homelab to an AI as &lt;em&gt;query&lt;/em&gt; tools and nothing else, connecting with credentials that can only read. The safety isn't a promise you extract from the model — it's baked into the architecture. There is simply no tool, and no permission, to change anything. Every number below came out of my own running cluster.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Make the addresses your own.&lt;/strong&gt; Replace &lt;code&gt;10.0.0.104&lt;/code&gt; with your Prometheus host. Keep the read-only API token in your secret store and pass it via an environment variable — never paste a real token into the server file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What MCP is, in one minute
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) is an open standard — originally from Anthropic — for connecting AI assistants to tools. An &lt;strong&gt;MCP server&lt;/strong&gt; publishes typed-function tools; an &lt;strong&gt;MCP client&lt;/strong&gt; (Claude Desktop, Claude Code, and a growing list) lets the model discover and call them. Instead of hallucinating your cluster's state, the model &lt;em&gt;asks&lt;/em&gt; your server and gets the real answer.&lt;/p&gt;

&lt;p&gt;The protocol is neutral about safety — a tool can do anything you program. Which is why the interesting decision is &lt;strong&gt;what you choose to expose&lt;/strong&gt;. We expose only reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the server
&lt;/h2&gt;

&lt;p&gt;I'll use Python and &lt;a href="https://github.com/jlowin/fastmcp" rel="noopener noreferrer"&gt;FastMCP&lt;/a&gt;, where a decorated function becomes a tool. Two tools is enough to be genuinely useful: a general Prometheus query, and a friendly "are my targets up?" summary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;

&lt;span class="n"&gt;PROM_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PROM_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://10.0.0.104:9090&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;homelab-readonly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prometheus_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run a read-only Prometheus instant query.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PROM_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;promql&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;promql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;series&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sample&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
                       &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]]}&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cluster_targets_up&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;How many scrape targets are up vs down (read-only).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PROM_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/v1/query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;up&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;targets_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;targets_up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;targets_down&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# stdio transport by default
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; here: no &lt;code&gt;reboot&lt;/code&gt;, no &lt;code&gt;delete&lt;/code&gt;, no &lt;code&gt;create&lt;/code&gt;. &lt;strong&gt;The tool surface is the security boundary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;FastMCP ships an in-memory client, so a five-line script exercises the real protocol. This is unedited output from my cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tools/list -&amp;gt; ['prometheus_query', 'cluster_targets_up']
call cluster_targets_up  -&amp;gt; {'targets_total': 27, 'targets_up': 27, 'targets_down': 0}
call prometheus_query('count(smartctl_device_smart_status)')
                         -&amp;gt; {'series': 1, 'sample': [{'value': '8'}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-seven scrape targets, all up; eight disks reporting SMART. The model asked; the cluster answered; nothing changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read-only &lt;em&gt;by design&lt;/em&gt; — four layers
&lt;/h2&gt;

&lt;p&gt;"Read-only" isn't one setting; it's a posture you build in layers, so a mistake at any single layer can't hand an AI the keys:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read-only credentials&lt;/strong&gt; — a Proxmox token bound to the built-in &lt;code&gt;PVEAuditor&lt;/code&gt; role; a query-only DB user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read tools only&lt;/strong&gt; — no write/delete tool is ever registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded inputs&lt;/strong&gt; — instant queries only, no admin endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private scope&lt;/strong&gt; — localhost / Tailscale, never the public internet.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The credential is the real lock.&lt;/strong&gt; The single most important choice is the read-only credential. In Proxmox that's a token bound to &lt;code&gt;PVEAuditor&lt;/code&gt; — it can read cluster state and nothing else. Even if you later fat-finger a write tool into the server, an audit token has no permission to carry it out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Connect it to Claude
&lt;/h2&gt;

&lt;p&gt;Register the server in your MCP client (Claude Desktop or Claude Code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"homelab-readonly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homelab-mcp/server.py"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"PROM_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://10.0.0.104:9090"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can ask, in plain English, "are all my Prometheus targets up?" and the assistant calls &lt;code&gt;cluster_targets_up&lt;/code&gt; and tells you. I keep mine reachable only over Tailscale — available from my laptop or phone, never from the open internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flourish: a fully self-hosted loop
&lt;/h2&gt;

&lt;p&gt;Pair this with a local Ollama cluster behind a load-balancing endpoint and the whole loop is yours: a &lt;strong&gt;local&lt;/strong&gt; LLM calling a &lt;strong&gt;read-only&lt;/strong&gt; tool server to answer questions about &lt;strong&gt;your&lt;/strong&gt; cluster — no cloud in the path, and no way for any of it to change a thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the full, updated versions
&lt;/h2&gt;

&lt;p&gt;I keep the maintained versions of this and the surrounding series on my homelab site, documented from a working 4-node Proxmox cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/read-only-homelab-mcp-server/" rel="noopener noreferrer"&gt;Build a Read-Only Homelab MCP Server (full guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/olla-ollama-load-balancer/" rel="noopener noreferrer"&gt;Load-Balance a Multi-Node Ollama Cluster With Olla&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/searxng-owui-private-web-search/" rel="noopener noreferrer"&gt;Give Your Local AI Private Web Search With SearXNG&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://peira.dev/articles/read-only-homelab-mcp-server/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>What Is the *arr Stack? Sonarr, Radarr, and Prowlarr Explained</title>
      <dc:creator>Josh Hall</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:12:06 +0000</pubDate>
      <link>https://dev.to/josh_hall_b54941047f33661/what-is-the-arr-stack-sonarr-radarr-and-prowlarr-explained-4ocf</link>
      <guid>https://dev.to/josh_hall_b54941047f33661/what-is-the-arr-stack-sonarr-radarr-and-prowlarr-explained-4ocf</guid>
      <description>&lt;p&gt;If you've spent any time in homelab or self-hosting circles, you've seen people mention their "Arr stack" — often written &lt;code&gt;*arr&lt;/code&gt;, where the asterisk is a wildcard for Son*&lt;em&gt;arr&lt;/em&gt;&lt;em&gt;, Rad&lt;/em&gt;&lt;em&gt;arr&lt;/em&gt;&lt;em&gt;, Lid&lt;/em&gt;&lt;em&gt;arr&lt;/em&gt;*, and friends. It sounds like arcane jargon, but the idea is simple: a set of apps that turn "I want to watch this" into an organized file in your library without you touching a download client.&lt;/p&gt;

&lt;p&gt;This post explains what each piece does and how they fit together — the concepts, no setup yet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A note on use: the arr stack is just a tool. Point it only at content you have the legal right to download where you live — your own media, public-domain works, and Linux ISOs are the intended use.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem the Arr stack solves
&lt;/h2&gt;

&lt;p&gt;Managing a media library by hand means searching a dozen indexers, squinting at release names to guess which is good, downloading, renaming the file into something your media server understands, moving it into the right folder, and refreshing the library. Then doing it again for the next episode. It starts as a hobby and quietly becomes a part-time job.&lt;/p&gt;

&lt;p&gt;The Arr stack automates every step. You tell it &lt;em&gt;what&lt;/em&gt; you want, and it handles the searching, grabbing, renaming, and filing, then tells your media server to rescan so the title just appears, ready to watch.&lt;/p&gt;

&lt;p&gt;One thing I learned running this myself: "hands-off" holds right up until the auto-downloading quietly stops. Nothing errors — the library just stops growing, and because everything still &lt;em&gt;looks&lt;/em&gt; fine, you don't catch it for a while. Build it so you'll notice when one link stops handing off to the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The players
&lt;/h2&gt;

&lt;p&gt;Each app is a focused piece of the pipeline. All are open-source and part of the &lt;a href="https://wiki.servarr.com" rel="noopener noreferrer"&gt;Servarr&lt;/a&gt; family, so they share a near-identical interface.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Handles&lt;/th&gt;
&lt;th&gt;Thinks in terms of&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://sonarr.tv" rel="noopener noreferrer"&gt;Sonarr&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;TV shows&lt;/td&gt;
&lt;td&gt;series → seasons → episodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://radarr.video" rel="noopener noreferrer"&gt;Radarr&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Movies&lt;/td&gt;
&lt;td&gt;single films + editions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://prowlarr.com" rel="noopener noreferrer"&gt;Prowlarr&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Indexers&lt;/td&gt;
&lt;td&gt;the sources everything searches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A download client (e.g. &lt;a href="https://www.qbittorrent.org" rel="noopener noreferrer"&gt;qBittorrent&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;The actual transfer&lt;/td&gt;
&lt;td&gt;torrents / Usenet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A media server (&lt;a href="https://www.plex.tv" rel="noopener noreferrer"&gt;Plex&lt;/a&gt; / &lt;a href="https://jellyfin.org" rel="noopener noreferrer"&gt;Jellyfin&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Playback&lt;/td&gt;
&lt;td&gt;libraries you stream&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Sonarr and Radarr are essentially the same program&lt;/strong&gt; with different logic — Sonarr understands episode numbering and air schedules, Radarr understands releases and cuts. There are siblings too: Lidarr (music), Readarr (books, now retired). Most media setups only need Sonarr and Radarr.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How Prowlarr changed the game
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;indexer&lt;/strong&gt; is a searchable catalog of available releases — a torrent tracker or a Usenet index. Before Prowlarr, you added every indexer to Sonarr &lt;strong&gt;and&lt;/strong&gt; Radarr separately, kept two credential sets current, and repeated it every time an indexer changed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://prowlarr.com" rel="noopener noreferrer"&gt;Prowlarr&lt;/a&gt; is the &lt;strong&gt;indexer manager&lt;/strong&gt;: you add each indexer once, and it &lt;strong&gt;syncs it into every connected Arr app automatically&lt;/strong&gt;. Prowlarr stores the indexer definitions; Sonarr and Radarr register as "applications" and receive the synced indexers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Prowlarr decides where to search; Sonarr and Radarr decide what to grab.&lt;/strong&gt; Prowlarr never downloads anything — it just gives the others a shared, always-current list of places to look.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The pipeline, end to end
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  You add a title  (or an import list adds it automatically)
        |
        v
  Radarr / Sonarr   -- decides it's "wanted", asks Prowlarr's indexers
        |
        v
  Prowlarr indexers -- return candidate releases, scored by quality rules
        |
        v
  Download client   -- grabs the chosen release (torrent behind a VPN)
        |
        v
  Radarr / Sonarr   -- imports: renames + moves the file into the library
        |
        v
  Media server      -- rescans; the title appears, ready to stream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every arrow is automatic. The only human input is the first line — and even that can be automated with import lists that pull from a watchlist or "popular" feeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where quality rules fit in
&lt;/h2&gt;

&lt;p&gt;The Arr apps don't grab the first result they find. Each uses a &lt;strong&gt;quality profile&lt;/strong&gt; — your rules for resolution, source, file size, and codecs — to score candidates and pick the best fit. The community-maintained &lt;a href="https://trash-guides.info" rel="noopener noreferrer"&gt;TRaSH Guides&lt;/a&gt; are the reference for tuning these.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about a VPN?
&lt;/h2&gt;

&lt;p&gt;Only one piece needs one. Sonarr, Radarr, and Prowlarr move metadata and searches — no swarm traffic. The &lt;strong&gt;download client&lt;/strong&gt; is what joins torrent swarms and exposes your IP, so the hardened setup routes &lt;strong&gt;only the torrent client&lt;/strong&gt; through a VPN container with a kill-switch, leaving the rest on your LAN.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wider *arr family
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.bazarr.media/" rel="noopener noreferrer"&gt;Bazarr&lt;/a&gt;&lt;/strong&gt; watches your Sonarr and Radarr libraries and fetches &lt;strong&gt;subtitles&lt;/strong&gt; automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://lidarr.audio/" rel="noopener noreferrer"&gt;Lidarr&lt;/a&gt;&lt;/strong&gt; does for &lt;strong&gt;music&lt;/strong&gt; what Sonarr does for TV; &lt;strong&gt;Whisparr&lt;/strong&gt; handles adult content.&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;requests&lt;/strong&gt;, &lt;a href="https://github.com/fallenbagel/jellyseerr" rel="noopener noreferrer"&gt;Jellyseerr&lt;/a&gt; gives the household a streaming-style page that hands approved titles to Radarr/Sonarr.&lt;/li&gt;
&lt;li&gt;One to skip: &lt;strong&gt;Readarr&lt;/strong&gt; (books) was retired in 2025 when its metadata source broke.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;I keep the full, continually-updated versions of these guides — deploy with Docker Compose, automation, subtitles, music, and requests — on my homelab site, documented from a working 4-node Proxmox cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/arr-stack-explained/" rel="noopener noreferrer"&gt;What Is the Arr Stack? (full guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/arr-stack-docker-deploy/" rel="noopener noreferrer"&gt;Deploy the Arr Stack with Docker Compose&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peira.dev/articles/arr-stack-automation/" rel="noopener noreferrer"&gt;Automating the Arr Stack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://peira.dev/articles/bazarr-subtitles-arr-stack/" rel="noopener noreferrer"&gt;Bazarr: Automatic Subtitles&lt;/a&gt; · &lt;a href="https://peira.dev/articles/lidarr-music-arr-stack/" rel="noopener noreferrer"&gt;Lidarr: Music&lt;/a&gt; · &lt;a href="https://peira.dev/articles/jellyseerr-requests-arr-stack/" rel="noopener noreferrer"&gt;Jellyseerr: Requests&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://peira.dev/articles/arr-stack-explained/" rel="noopener noreferrer"&gt;peira.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
