<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://fungijr.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://fungijr.github.io/" rel="alternate" type="text/html" /><updated>2026-09-01T21:19:09+00:00</updated><id>https://fungijr.github.io/feed.xml</id><title type="html">Unix Magick</title><subtitle>Notes on *nix, infra, security, networking, and whatever else breaks.</subtitle><entry><title type="html">Build a Hardened Arr Stack with Docker Compose: Gluetun, Hardlinks, and Health Checks</title><link href="https://fungijr.github.io/2026/09/01/build-a-hardened-arr-stack/" rel="alternate" type="text/html" title="Build a Hardened Arr Stack with Docker Compose: Gluetun, Hardlinks, and Health Checks" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://fungijr.github.io/2026/09/01/build-a-hardened-arr-stack</id><content type="html" xml:base="https://fungijr.github.io/2026/09/01/build-a-hardened-arr-stack/"><![CDATA[<p>The three Arr posts in Lab Notes documented how this stack evolved: service ownership, a smaller VPN failure domain, defense in depth around untrusted downloads, and stack-wide health invariants.</p>

<p>This is the version meant to be followed.</p>

<p>The goal is to start with a Linux host that already has Docker Engine and the Docker Compose plugin, build the stack in a deliberate order, and end with a deployment whose important assumptions can be tested.</p>

<p>The worked example uses Gluetun, ProtonVPN over OpenVPN, qBittorrent, Jackett, FlareSolverr, Sonarr, Radarr, Recyclarr, Cleanuparr, and Tdarr.</p>

<blockquote>
  <p><strong>Scope:</strong> The values below are sanitized. Replace placeholders and host paths for your environment. Do not publish private addresses, credentials, API keys, VPN account data, or real media names.</p>
</blockquote>

<hr />

<h2 id="final-topology">Final topology</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         Internet
                            |
                         VPN provider
                            |
                         Gluetun
                  VPN / firewall / tun0
                            |
             +--------------+--------------+
             |              |              |
        qBittorrent       Jackett      FlareSolverr

Docker project network
├── gluetun
├── sonarr       -&gt; gluetun:8080 -&gt; qBittorrent
├── radarr       -&gt; gluetun:8080 -&gt; qBittorrent
├── recyclarr    -&gt; sonarr:8989 / radarr:7878
└── cleanuparr   -&gt; sonarr / radarr / gluetun

Separate bridge
└── tdarr
    ├── /media read-only
    └── /temp writable
</code></pre></div></div>

<p>Only qBittorrent, Jackett, and FlareSolverr share Gluetun’s network namespace. Sonarr, Radarr, Recyclarr, and Cleanuparr stay on the normal Compose network so the control plane remains available when the VPN itself is unhealthy.</p>

<p>Docker’s service-name DNS is what makes that split practical: Sonarr and Radarr can reach services inside the Gluetun namespace through <code class="language-plaintext highlighter-rouge">gluetun:&lt;port&gt;</code> rather than a changing container IP.</p>

<hr />

<h2 id="prerequisites">Prerequisites</h2>

<p>This guide assumes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Linux host
Docker Engine
Docker Compose plugin
curl
jq
filesystem with hardlink support
VPN provider supported by Gluetun
</code></pre></div></div>

<p>Check Docker:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker version
docker compose version
</code></pre></div></div>

<p>The example is ProtonVPN-specific where noted. If you use another provider, keep the architecture but use that provider’s current Gluetun settings.</p>

<hr />

<h2 id="step-1-build-the-storage-model-first">Step 1: build the storage model first</h2>

<p>Hardlinks require the completed download and final media library to live on the same filesystem.</p>

<p>Use one canonical container-visible tree:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/srv/media-stack
├── torrents
│   ├── incomplete
│   ├── tv
│   └── movies
└── media
    ├── tv
    └── movies
</code></pre></div></div>

<p>Keep application state separate:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/srv/arr
├── appdata
├── secrets
├── .env
└── docker-compose.yml
</code></pre></div></div>

<p>Create it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo mkdir</span> <span class="nt">-p</span> <span class="se">\</span>
  /srv/arr/appdata/<span class="o">{</span>gluetun,qbittorrent,jackett,sonarr,radarr,recyclarr,cleanuparr,tdarr/<span class="o">{</span>server,configs,logs<span class="o">}}</span> <span class="se">\</span>
  /srv/arr/secrets <span class="se">\</span>
  /srv/media-stack/torrents/<span class="o">{</span>incomplete,tv,movies<span class="o">}</span> <span class="se">\</span>
  /srv/media-stack/media/<span class="o">{</span>tv,movies<span class="o">}</span> <span class="se">\</span>
  /srv/media-stack/.tdarr-temp

<span class="nb">sudo chown</span> <span class="nt">-R</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span><span class="s2">:</span><span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
  /srv/arr <span class="se">\</span>
  /srv/media-stack

<span class="nb">chmod </span>700 /srv/arr/secrets
</code></pre></div></div>

<p>Verify the data trees are on the same filesystem:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">stat</span> <span class="nt">-c</span> <span class="s1">'device=%d path=%n'</span> <span class="se">\</span>
  /srv/media-stack/torrents <span class="se">\</span>
  /srv/media-stack/media
</code></pre></div></div>

<p>The device IDs must match.</p>

<p>If they do not, fix the storage design before configuring Sonarr or Radarr.</p>

<hr />

<h2 id="step-2-create-host-settings">Step 2: create host settings</h2>

<p>Create <code class="language-plaintext highlighter-rouge">/srv/arr/.env</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/arr

<span class="nb">cat</span> <span class="o">&gt;</span> .env <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
ARR_BIND_IP=127.0.0.1
PUID=1000
PGID=1000
TZ=Etc/UTC

APPDATA_ROOT=/srv/arr/appdata
DATA_ROOT=/srv/media-stack

VPN_COUNTRY=United States
</span><span class="no">EOF

</span><span class="nb">chmod </span>600 .env
</code></pre></div></div>

<p>Set the UID/GID to the account that should own the files:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">id</span> <span class="nt">-u</span>
<span class="nb">id</span> <span class="nt">-g</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ARR_BIND_IP=127.0.0.1</code> is deliberate. If direct LAN access is required, use the host’s trusted management address instead of <code class="language-plaintext highlighter-rouge">0.0.0.0</code>.</p>

<hr />

<h2 id="step-3-create-credentials-as-files">Step 3: create credentials as files</h2>

<p>For ProtonVPN port forwarding over OpenVPN, Gluetun’s current provider documentation requires the OpenVPN username to end in <code class="language-plaintext highlighter-rouge">+pmp</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/arr

<span class="nb">read</span> <span class="nt">-r</span> <span class="nt">-p</span> <span class="s1">'Proton OpenVPN username (include +pmp): '</span> SECRET
<span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$SECRET</span><span class="s2">"</span> <span class="o">&gt;</span> secrets/openvpn_user
<span class="nb">unset </span>SECRET

<span class="nb">read</span> <span class="nt">-r</span> <span class="nt">-s</span> <span class="nt">-p</span> <span class="s1">'Proton OpenVPN password: '</span> SECRET
<span class="nb">echo
printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$SECRET</span><span class="s2">"</span> <span class="o">&gt;</span> secrets/openvpn_password
<span class="nb">unset </span>SECRET

<span class="nb">read</span> <span class="nt">-r</span> <span class="nt">-p</span> <span class="s1">'qBittorrent WebUI username: '</span> SECRET
<span class="nb">printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$SECRET</span><span class="s2">"</span> <span class="o">&gt;</span> secrets/qbit_username
<span class="nb">unset </span>SECRET

<span class="nb">read</span> <span class="nt">-r</span> <span class="nt">-s</span> <span class="nt">-p</span> <span class="s1">'qBittorrent WebUI password: '</span> SECRET
<span class="nb">echo
printf</span> <span class="s1">'%s'</span> <span class="s2">"</span><span class="nv">$SECRET</span><span class="s2">"</span> <span class="o">&gt;</span> secrets/qbit_password
<span class="nb">unset </span>SECRET

<span class="nb">chmod </span>600 secrets/<span class="k">*</span>

<span class="nb">grep</span> <span class="nt">-q</span> <span class="s1">'+pmp$'</span> secrets/openvpn_user <span class="se">\</span>
    <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s1">'PASS: Proton username has +pmp'</span> <span class="se">\</span>
    <span class="o">||</span> <span class="nb">echo</span> <span class="s1">'FAIL: Proton username is missing +pmp'</span>
</code></pre></div></div>

<p>If you use a different VPN provider, do not copy the <code class="language-plaintext highlighter-rouge">+pmp</code> requirement; use that provider’s current Gluetun instructions instead.</p>

<p>Compose file-backed secrets keep credentials out of the YAML, but the files are still plaintext on the host. Protect and back them up accordingly.</p>

<hr />

<h2 id="step-4-install-the-authenticated-qbittorrent-port-hook">Step 4: install the authenticated qBittorrent port hook</h2>

<p>The invariant we want is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VPN provider forwarded port
            ==
qBittorrent listen port
</code></pre></div></div>

<p>A Docker <code class="language-plaintext highlighter-rouge">6881:6881</code> mapping does not create a VPN-provider port forward.</p>

<p>Gluetun can run commands when provider-side port forwarding comes up or goes down. The following adapter updates qBittorrent through its authenticated Web API.</p>

<p>This implementation assumes qBittorrent 5.2.0 or newer.</p>

<p>Create <code class="language-plaintext highlighter-rouge">/srv/arr/appdata/gluetun/qbit-port.sh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> /srv/arr/appdata/gluetun/qbit-port.sh <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
#!/bin/sh
set -u

ACTION="</span><span class="k">${</span><span class="nv">1</span><span class="k">:-}</span><span class="sh">"
PORT="</span><span class="k">${</span><span class="nv">2</span><span class="k">:-}</span><span class="sh">"
VPN_IFACE="</span><span class="k">${</span><span class="nv">3</span><span class="k">:-</span><span class="nv">tun0</span><span class="k">}</span><span class="sh">"

QBIT_URL="http://127.0.0.1:8080"
USER_FILE="/run/secrets/qbit_username"
PASS_FILE="/run/secrets/qbit_password"

log() {
    printf '%s</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$*</span><span class="sh">"
}

load_auth() {
    if [ ! -r "</span><span class="nv">$USER_FILE</span><span class="sh">" ] || [ ! -r "</span><span class="nv">$PASS_FILE</span><span class="sh">" ]; then
        log "ERROR: qBittorrent credential secrets unavailable"
        return 1
    fi

    USER="</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$USER_FILE</span><span class="s2">"</span><span class="si">)</span><span class="sh">"
    PASS="</span><span class="si">$(</span><span class="nb">cat</span> <span class="s2">"</span><span class="nv">$PASS_FILE</span><span class="s2">"</span><span class="si">)</span><span class="sh">"

    AUTH="</span><span class="si">$(</span>
        <span class="nb">printf</span> <span class="s1">'%s:%s'</span> <span class="s2">"</span><span class="nv">$USER</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$PASS</span><span class="s2">"</span> |
        <span class="nb">base64</span> |
        <span class="nb">tr</span> <span class="nt">-d</span> <span class="s1">'\n'</span>
    <span class="si">)</span><span class="sh">"

    unset USER PASS
}

wait_for_webui() {
    attempt=1

    while [ "</span><span class="nv">$attempt</span><span class="sh">" -le 60 ]; do
        RESPONSE="</span><span class="si">$(</span>
            wget <span class="nt">-S</span> <span class="nt">-O</span> /dev/null <span class="se">\</span>
                <span class="nt">--header</span><span class="o">=</span><span class="s2">"Authorization: Basic </span><span class="nv">$AUTH</span><span class="s2">"</span> <span class="se">\</span>
                <span class="nt">--header</span><span class="o">=</span><span class="s2">"Referer: </span><span class="nv">$QBIT_URL</span><span class="s2">"</span> <span class="se">\</span>
                <span class="s2">"</span><span class="nv">$QBIT_URL</span><span class="s2">/api/v2/app/version"</span> <span class="se">\</span>
                2&gt;&amp;1 <span class="o">||</span> <span class="nb">true</span>
        <span class="si">)</span><span class="sh">"

        if printf '%s</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$RESPONSE</span><span class="sh">" |
            grep -qE 'HTTP/1</span><span class="se">\.</span><span class="sh">[01] 200'; then
            return 0
        fi

        sleep 1
        attempt=</span><span class="k">$((</span>attempt <span class="o">+</span> <span class="m">1</span><span class="k">))</span><span class="sh">
    done

    return 1
}

set_preferences() {
    JSON="</span><span class="nv">$1</span><span class="sh">"

    RESPONSE="</span><span class="si">$(</span>
        wget <span class="nt">-S</span> <span class="nt">-O</span> /dev/null <span class="se">\</span>
            <span class="nt">--header</span><span class="o">=</span><span class="s2">"Authorization: Basic </span><span class="nv">$AUTH</span><span class="s2">"</span> <span class="se">\</span>
            <span class="nt">--header</span><span class="o">=</span><span class="s2">"Referer: </span><span class="nv">$QBIT_URL</span><span class="s2">"</span> <span class="se">\</span>
            <span class="nt">--post-data</span><span class="o">=</span><span class="s2">"json=</span><span class="k">${</span><span class="nv">JSON</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
            <span class="s2">"</span><span class="nv">$QBIT_URL</span><span class="s2">/api/v2/app/setPreferences"</span> <span class="se">\</span>
            2&gt;&amp;1 <span class="o">||</span> <span class="nb">true</span>
    <span class="si">)</span><span class="sh">"

    if printf '%s</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$RESPONSE</span><span class="sh">" |
        grep -qE 'HTTP/1</span><span class="se">\.</span><span class="sh">[01] 200'; then
        return 0
    fi

    log "ERROR: qBittorrent setPreferences request failed"

    printf '%s</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$RESPONSE</span><span class="sh">" |
        grep -E 'HTTP/|ERROR|Forbidden|Unauthorized' ||
        true

    return 1
}

get_preferences() {
    wget -qO- </span><span class="se">\</span><span class="sh">
        --header="Authorization: Basic </span><span class="nv">$AUTH</span><span class="sh">" </span><span class="se">\</span><span class="sh">
        --header="Referer: </span><span class="nv">$QBIT_URL</span><span class="sh">" </span><span class="se">\</span><span class="sh">
        "</span><span class="nv">$QBIT_URL</span><span class="sh">/api/v2/app/preferences"
}

set_vpn_port() {
    WANTED="</span><span class="nv">$1</span><span class="sh">"
    IFACE="</span><span class="nv">$2</span><span class="sh">"

    case "</span><span class="nv">$WANTED</span><span class="sh">" in
        ''|*[!0-9]*)
            log "ERROR: invalid port: </span><span class="nv">$WANTED</span><span class="sh">"
            return 1
            ;;
    esac

    if [ "</span><span class="nv">$WANTED</span><span class="sh">" -lt 1 ] || [ "</span><span class="nv">$WANTED</span><span class="sh">" -gt 65535 ]; then
        log "ERROR: port out of range: </span><span class="nv">$WANTED</span><span class="sh">"
        return 1
    fi

    load_auth || return 1

    if ! wait_for_webui; then
        log "ERROR: qBittorrent WebUI did not become available"
        return 1
    fi

    JSON="{</span><span class="se">\"</span><span class="sh">listen_port</span><span class="se">\"</span><span class="sh">:</span><span class="k">${</span><span class="nv">WANTED</span><span class="k">}</span><span class="sh">,</span><span class="se">\"</span><span class="sh">current_network_interface</span><span class="se">\"</span><span class="sh">:</span><span class="se">\"</span><span class="k">${</span><span class="nv">IFACE</span><span class="k">}</span><span class="se">\"</span><span class="sh">,</span><span class="se">\"</span><span class="sh">random_port</span><span class="se">\"</span><span class="sh">:false,</span><span class="se">\"</span><span class="sh">upnp</span><span class="se">\"</span><span class="sh">:false}"

    log "Setting qBittorrent listen port to </span><span class="nv">$WANTED</span><span class="sh"> on </span><span class="nv">$IFACE</span><span class="sh">"

    set_preferences "</span><span class="nv">$JSON</span><span class="sh">" || return 1

    PREFS="</span><span class="si">$(</span>get_preferences 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="sh">"

    ACTUAL="</span><span class="si">$(</span>
        <span class="nb">printf</span> <span class="s1">'%s\n'</span> <span class="s2">"</span><span class="nv">$PREFS</span><span class="s2">"</span> |
        <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'s/.*"listen_port":\([0-9][0-9]*\).*/\1/p'</span>
    <span class="si">)</span><span class="sh">"

    if [ "</span><span class="nv">$ACTUAL</span><span class="sh">" != "</span><span class="nv">$WANTED</span><span class="sh">" ]; then
        log "ERROR: requested=</span><span class="nv">$WANTED</span><span class="sh"> actual=</span><span class="k">${</span><span class="nv">ACTUAL</span><span class="k">:-</span><span class="nv">unknown</span><span class="k">}</span><span class="sh">"
        return 1
    fi

    log "qBittorrent listen port verified: </span><span class="nv">$ACTUAL</span><span class="sh">"
}

reset_vpn_port() {
    load_auth || return 1

    if ! wait_for_webui; then
        log "qBittorrent WebUI unavailable during teardown"
        return 0
    fi

    JSON='{"listen_port":0,"current_network_interface":"lo","random_port":false,"upnp":false}'

    log "Resetting qBittorrent peer listener"

    set_preferences "</span><span class="nv">$JSON</span><span class="sh">" ||
        log "WARNING: qBittorrent listener reset failed"

    return 0
}

case "</span><span class="nv">$ACTION</span><span class="sh">" in
    up|set)
        [ -n "</span><span class="nv">$PORT</span><span class="sh">" ] || {
            log "ERROR: usage: </span><span class="nv">$0</span><span class="sh"> {up|set} PORT [VPN_INTERFACE]"
            exit 2
        }

        set_vpn_port "</span><span class="nv">$PORT</span><span class="sh">" "</span><span class="nv">$VPN_IFACE</span><span class="sh">"
        ;;

    down)
        reset_vpn_port
        ;;

    *)
        log "ERROR: usage: </span><span class="nv">$0</span><span class="sh"> {up|set} PORT [VPN_INTERFACE] | down"
        exit 2
        ;;
esac
</span><span class="no">EOF

</span><span class="nb">chmod </span>755 /srv/arr/appdata/gluetun/qbit-port.sh

sh <span class="nt">-n</span> /srv/arr/appdata/gluetun/qbit-port.sh <span class="o">&amp;&amp;</span>
    <span class="nb">echo</span> <span class="s1">'PASS: qbit-port.sh syntax'</span>
</code></pre></div></div>

<p>Do not enable qBittorrent’s localhost-authentication bypass. qBittorrent, Jackett, and FlareSolverr share Gluetun’s namespace and should be treated as mutually adjacent services.</p>

<hr />

<h2 id="step-5-create-the-compose-file">Step 5: create the Compose file</h2>

<p>Create <code class="language-plaintext highlighter-rouge">/srv/arr/docker-compose.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">gluetun</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">qmcgaw/gluetun</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

    <span class="na">cap_add</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">NET_ADMIN</span>

    <span class="na">devices</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/dev/net/tun:/dev/net/tun</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">VPN_SERVICE_PROVIDER</span><span class="pi">:</span> <span class="s">protonvpn</span>
      <span class="na">VPN_TYPE</span><span class="pi">:</span> <span class="s">openvpn</span>
      <span class="na">SERVER_COUNTRIES</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${VPN_COUNTRY:-United</span><span class="nv"> </span><span class="s">States}"</span>

      <span class="na">OPENVPN_USER_SECRETFILE</span><span class="pi">:</span> <span class="s">/run/secrets/openvpn_user</span>
      <span class="na">OPENVPN_PASSWORD_SECRETFILE</span><span class="pi">:</span> <span class="s">/run/secrets/openvpn_password</span>

      <span class="na">VPN_PORT_FORWARDING</span><span class="pi">:</span> <span class="s2">"</span><span class="s">on"</span>
      <span class="na">VPN_PORT_FORWARDING_UP_COMMAND</span><span class="pi">:</span> <span class="pi">&gt;-</span>
        <span class="s">/bin/sh -c '/gluetun/qbit-port.sh up "{{PORT}}" tun0'</span>
      <span class="na">VPN_PORT_FORWARDING_DOWN_COMMAND</span><span class="pi">:</span> <span class="pi">&gt;-</span>
        <span class="s">/bin/sh -c '/gluetun/qbit-port.sh down'</span>

      <span class="na">FIREWALL</span><span class="pi">:</span> <span class="s2">"</span><span class="s">on"</span>
      <span class="na">FIREWALL_INPUT_PORTS</span><span class="pi">:</span> <span class="s">8080,9117</span>

      <span class="na">VPN_INTERFACE</span><span class="pi">:</span> <span class="s">tun0</span>
      <span class="na">IPV6</span><span class="pi">:</span> <span class="s2">"</span><span class="s">off"</span>

    <span class="na">secrets</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">openvpn_user</span>
      <span class="pi">-</span> <span class="s">openvpn_password</span>
      <span class="pi">-</span> <span class="s">qbit_username</span>
      <span class="pi">-</span> <span class="s">qbit_password</span>

    <span class="na">security_opt</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">no-new-privileges:true</span>

    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${ARR_BIND_IP:-127.0.0.1}:8080:8080"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${ARR_BIND_IP:-127.0.0.1}:9117:9117"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/gluetun:/gluetun"</span>


  <span class="na">qbittorrent</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/qbittorrent:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>

    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">gluetun</span><span class="pi">:</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">service_healthy</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PUID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}"</span>
      <span class="na">PGID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PGID:-1000}"</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>
      <span class="na">WEBUI_PORT</span><span class="pi">:</span> <span class="s2">"</span><span class="s">8080"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/qbittorrent:/config"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${DATA_ROOT}:/data"</span>


  <span class="na">jackett</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/jackett:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>

    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">gluetun</span><span class="pi">:</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">service_healthy</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PUID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}"</span>
      <span class="na">PGID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PGID:-1000}"</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>
      <span class="na">AUTO_UPDATE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">RUN_OPTS</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/jackett:/config"</span>


  <span class="na">flaresolverr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/flaresolverr/flaresolverr:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>

    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">gluetun</span><span class="pi">:</span>
        <span class="na">condition</span><span class="pi">:</span> <span class="s">service_healthy</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">LOG_LEVEL</span><span class="pi">:</span> <span class="s">info</span>
      <span class="na">LOG_HTML</span><span class="pi">:</span> <span class="s2">"</span><span class="s">false"</span>
      <span class="na">CAPTCHA_SOLVER</span><span class="pi">:</span> <span class="s">none</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>


  <span class="na">sonarr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/sonarr:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PUID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}"</span>
      <span class="na">PGID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PGID:-1000}"</span>
      <span class="na">UMASK</span><span class="pi">:</span> <span class="s2">"</span><span class="s">002"</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>

    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${ARR_BIND_IP:-127.0.0.1}:8989:8989"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/sonarr:/config"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${DATA_ROOT}:/data"</span>


  <span class="na">radarr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/radarr:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PUID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}"</span>
      <span class="na">PGID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PGID:-1000}"</span>
      <span class="na">UMASK</span><span class="pi">:</span> <span class="s2">"</span><span class="s">002"</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>

    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${ARR_BIND_IP:-127.0.0.1}:7878:7878"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/radarr:/config"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${DATA_ROOT}:/data"</span>


  <span class="na">recyclarr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/recyclarr/recyclarr:8</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}:${PGID:-1000}"</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>
      <span class="na">CRON_SCHEDULE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">@daily"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/recyclarr:/config"</span>

    <span class="na">read_only</span><span class="pi">:</span> <span class="no">true</span>

    <span class="na">tmpfs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/tmp</span>

    <span class="na">security_opt</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">no-new-privileges:true</span>


  <span class="na">cleanuparr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/cleanuparr/cleanuparr:latest</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">PORT</span><span class="pi">:</span> <span class="s2">"</span><span class="s">11011"</span>
      <span class="na">BIND_ADDRESS</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0.0.0.0"</span>
      <span class="na">BASE_PATH</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
      <span class="na">PUID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}"</span>
      <span class="na">PGID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PGID:-1000}"</span>
      <span class="na">UMASK</span><span class="pi">:</span> <span class="s2">"</span><span class="s">022"</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>

    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${ARR_BIND_IP:-127.0.0.1}:11011:11011"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/cleanuparr:/config"</span>

    <span class="na">security_opt</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">no-new-privileges:true</span>

    <span class="na">healthcheck</span><span class="pi">:</span>
      <span class="na">test</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">CMD</span>
        <span class="pi">-</span> <span class="s">curl</span>
        <span class="pi">-</span> <span class="s">-f</span>
        <span class="pi">-</span> <span class="s">http://127.0.0.1:11011/health</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">30s</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="s">10s</span>
      <span class="na">start_period</span><span class="pi">:</span> <span class="s">30s</span>
      <span class="na">retries</span><span class="pi">:</span> <span class="m">3</span>


  <span class="na">tdarr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/haveagitgat/tdarr:2.81.01</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s">bridge</span>

    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${ARR_BIND_IP:-127.0.0.1}:8265:8265"</span>

    <span class="na">env_file</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./secrets/tdarr-auth.env</span>

    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${TZ:-Etc/UTC}"</span>
      <span class="na">PUID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PUID:-1000}"</span>
      <span class="na">PGID</span><span class="pi">:</span> <span class="s2">"</span><span class="s">${PGID:-1000}"</span>
      <span class="na">UMASK_SET</span><span class="pi">:</span> <span class="s2">"</span><span class="s">002"</span>

      <span class="na">serverIP</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0.0.0.0"</span>
      <span class="na">serverPort</span><span class="pi">:</span> <span class="s2">"</span><span class="s">8266"</span>
      <span class="na">webUIPort</span><span class="pi">:</span> <span class="s2">"</span><span class="s">8265"</span>

      <span class="na">internalNode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">inContainer</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">serverURL</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://127.0.0.1:8266"</span>
      <span class="na">nodeName</span><span class="pi">:</span> <span class="s2">"</span><span class="s">arr-health"</span>

      <span class="na">ffmpegVersion</span><span class="pi">:</span> <span class="s2">"</span><span class="s">7"</span>

      <span class="na">auth</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">openBrowser</span><span class="pi">:</span> <span class="s2">"</span><span class="s">false"</span>
      <span class="na">maxLogSizeMB</span><span class="pi">:</span> <span class="s2">"</span><span class="s">10"</span>

      <span class="na">transcodegpuWorkers</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0"</span>
      <span class="na">transcodecpuWorkers</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0"</span>
      <span class="na">healthcheckgpuWorkers</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0"</span>
      <span class="na">healthcheckcpuWorkers</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>

    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/tdarr/server:/app/server"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/tdarr/configs:/app/configs"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${APPDATA_ROOT}/tdarr/logs:/app/logs"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${DATA_ROOT}/media:/media:ro"</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">${DATA_ROOT}/.tdarr-temp:/temp"</span>

    <span class="na">security_opt</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">no-new-privileges:true</span>


<span class="na">secrets</span><span class="pi">:</span>
  <span class="na">openvpn_user</span><span class="pi">:</span>
    <span class="na">file</span><span class="pi">:</span> <span class="s">./secrets/openvpn_user</span>

  <span class="na">openvpn_password</span><span class="pi">:</span>
    <span class="na">file</span><span class="pi">:</span> <span class="s">./secrets/openvpn_password</span>

  <span class="na">qbit_username</span><span class="pi">:</span>
    <span class="na">file</span><span class="pi">:</span> <span class="s">./secrets/qbit_username</span>

  <span class="na">qbit_password</span><span class="pi">:</span>
    <span class="na">file</span><span class="pi">:</span> <span class="s">./secrets/qbit_password</span>
</code></pre></div></div>

<p>Do not add <code class="language-plaintext highlighter-rouge">networks:</code> to a service using <code class="language-plaintext highlighter-rouge">network_mode: "service:gluetun"</code>; those are mutually exclusive Compose networking models.</p>

<p>The baseline also leaves Gluetun’s health server at its loopback-only default (<code class="language-plaintext highlighter-rouge">127.0.0.1:9999</code>). If another container genuinely needs to query that endpoint, explicitly set <code class="language-plaintext highlighter-rouge">HEALTH_SERVER_ADDRESS=0.0.0.0:9999</code> and add <code class="language-plaintext highlighter-rouge">9999</code> to <code class="language-plaintext highlighter-rouge">FIREWALL_INPUT_PORTS</code>; otherwise do not open an unused internal port.</p>

<p>Several images above use moving <code class="language-plaintext highlighter-rouge">latest</code> tags because that matches their normal upstream release channel. For a controlled deployment, record the versions or digests you actually validate and update them intentionally rather than blindly auto-updating. The Tdarr tag shown here is the version this health-check workflow was validated against, not a claim that it is the newest release.</p>

<hr />

<h2 id="step-6-create-tdarrs-internal-api-secret">Step 6: create Tdarr’s internal API secret</h2>

<p>Tdarr UI authentication and Node authentication are separate pieces.</p>

<p>Generate a private key for the internal Node:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/arr

<span class="nv">TDARR_KEY</span><span class="o">=</span><span class="s2">"tapi_</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 24<span class="si">)</span><span class="s2">"</span>

<span class="nb">umask </span>077

<span class="nb">cat</span> <span class="o">&gt;</span> secrets/tdarr-auth.env <span class="o">&lt;&lt;</span><span class="no">EOF</span><span class="sh">
seededApiKey=</span><span class="nv">$TDARR_KEY</span><span class="sh">
apiKey=</span><span class="nv">$TDARR_KEY</span><span class="sh">
</span><span class="no">EOF

</span><span class="nb">unset </span>TDARR_KEY
</code></pre></div></div>

<p>Only host port <code class="language-plaintext highlighter-rouge">8265</code> is published.</p>

<p>Port <code class="language-plaintext highlighter-rouge">8266</code> is intentionally internal:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Tdarr Web UI      host -&gt; 8265
Tdarr Node API    127.0.0.1:8266 inside container
</code></pre></div></div>

<hr />

<h2 id="step-7-validate-before-starting">Step 7: validate before starting</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/arr

sh <span class="nt">-n</span> appdata/gluetun/qbit-port.sh <span class="o">&amp;&amp;</span>
    <span class="nb">echo</span> <span class="s1">'PASS: qBit hook'</span>

docker compose config <span class="nt">-q</span> <span class="o">&amp;&amp;</span>
    <span class="nb">echo</span> <span class="s1">'PASS: Compose'</span>

docker compose config <span class="nt">--services</span>
</code></pre></div></div>

<p>Expected service set:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gluetun
qbittorrent
jackett
flaresolverr
sonarr
radarr
recyclarr
cleanuparr
tdarr
</code></pre></div></div>

<p>Make sure there is no fixed peer-port mapping:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-n</span> <span class="s1">'6881'</span> docker-compose.yml <span class="o">||</span>
    <span class="nb">echo</span> <span class="s1">'PASS: no fixed 6881 mapping'</span>
</code></pre></div></div>

<hr />

<h2 id="step-8-bring-up-gluetun-first">Step 8: bring up Gluetun first</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> gluetun

<span class="nv">GLUE</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> gluetun<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>Check health:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker inspect <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--format</span> <span class="s1">'status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}'</span>
</code></pre></div></div>

<p>Check VPN IPv4:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
  wget <span class="nt">-qO-</span> https://api.ipify.org

<span class="nb">echo</span>
</code></pre></div></div>

<p>That uses a third-party IP-echo service only for diagnostics.</p>

<p>If IPv6 is intentionally disabled:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> sh <span class="nt">-c</span> <span class="s1">'
timeout 5 wget -6 -qO- https://api64.ipify.org &amp;&amp;
echo "FAIL: unexpected IPv6 Internet path" ||
echo "PASS: no usable IPv6 Internet path"
'</span>
</code></pre></div></div>

<p>Read the provider-forwarded port:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nb">cat</span> /tmp/gluetun/forwarded_port
</code></pre></div></div>

<p>Current Gluetun documentation still supports <code class="language-plaintext highlighter-rouge">/tmp/gluetun/forwarded_port</code>, but marks that status file for deprecation in the v4.0.0 release. The actual qBittorrent synchronization in this guide uses Gluetun’s up/down hooks; the file is used here only for bootstrap and verification.</p>

<p>Because qBittorrent has not been started yet, the first Gluetun-only boot may also log that the qBittorrent hook could not reach the WebUI. Step 9 starts qBittorrent and replays the hook manually after authentication is configured.</p>

<p>Do not continue until the VPN path and forwarding state are correct.</p>

<hr />

<h2 id="step-9-bootstrap-qbittorrent">Step 9: bootstrap qBittorrent</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> qbittorrent
</code></pre></div></div>

<p>The Web UI is exposed by Gluetun:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://&lt;ARR_BIND_IP&gt;:8080
</code></pre></div></div>

<p>For a fresh installation, inspect qBittorrent logs for the temporary WebUI credential if needed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose logs <span class="se">\</span>
  <span class="nt">--since</span> 5m <span class="se">\</span>
  <span class="nt">--no-color</span> <span class="se">\</span>
  qbittorrent
</code></pre></div></div>

<p>Sign in and set the username/password to match:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>secrets/qbit_username
secrets/qbit_password
</code></pre></div></div>

<p>Keep authentication enabled and do not enable localhost bypass.</p>

<p>Then run the hook once manually:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">PF</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>
  docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
    <span class="nb">cat</span> /tmp/gluetun/forwarded_port
<span class="si">)</span><span class="s2">"</span>

docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
  /gluetun/qbit-port.sh up <span class="s2">"</span><span class="nv">$PF</span><span class="s2">"</span> tun0
</code></pre></div></div>

<hr />

<h2 id="step-10-prove-the-torrent-transport">Step 10: prove the torrent transport</h2>

<p>Resolve qBittorrent:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">QBIT</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> qbittorrent<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>Check live TCP/UDP listeners:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$QBIT</span><span class="s2">"</span> sh <span class="nt">-c</span> <span class="s1">'
netstat -lntup 2&gt;/dev/null ||
ss -lntup 2&gt;/dev/null
'</span> |
<span class="nb">grep</span> <span class="s2">":</span><span class="k">${</span><span class="nv">PF</span><span class="k">}</span><span class="s2">"</span>
</code></pre></div></div>

<p>Then query preferences through the authenticated API:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">QBIT_BIND</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>
  <span class="nb">sed</span> <span class="nt">-n</span> <span class="s1">'s/^ARR_BIND_IP=//p'</span> .env |
  <span class="nb">head</span> <span class="nt">-n</span> 1
<span class="si">)</span><span class="s2">"</span>

<span class="nv">QBIT_URL</span><span class="o">=</span><span class="s2">"http://</span><span class="k">${</span><span class="nv">QBIT_BIND</span><span class="k">:-</span><span class="nv">127</span><span class="p">.0.0.1</span><span class="k">}</span><span class="s2">:8080"</span>

<span class="nv">QUSER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cat </span>secrets/qbit_username<span class="si">)</span><span class="s2">"</span>
<span class="nv">QPASS</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cat </span>secrets/qbit_password<span class="si">)</span><span class="s2">"</span>

<span class="nv">COOKIE</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span><span class="s2">"</span>
<span class="nb">trap</span> <span class="s1">'rm -f "$COOKIE"'</span> EXIT

curl <span class="nt">-fsS</span> <span class="se">\</span>
  <span class="nt">-c</span> <span class="s2">"</span><span class="nv">$COOKIE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Referer: </span><span class="nv">$QBIT_URL</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"username=</span><span class="nv">$QUSER</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--data-urlencode</span> <span class="s2">"password=</span><span class="nv">$QPASS</span><span class="s2">"</span> <span class="se">\</span>
  <span class="s2">"</span><span class="nv">$QBIT_URL</span><span class="s2">/api/v2/auth/login"</span> <span class="se">\</span>
  <span class="o">&gt;</span>/dev/null

<span class="nb">unset </span>QUSER QPASS

curl <span class="nt">-fsS</span> <span class="se">\</span>
  <span class="nt">-b</span> <span class="s2">"</span><span class="nv">$COOKIE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Referer: </span><span class="nv">$QBIT_URL</span><span class="s2">"</span> <span class="se">\</span>
  <span class="s2">"</span><span class="nv">$QBIT_URL</span><span class="s2">/api/v2/app/preferences"</span> |
jq <span class="s1">'{
  listen_port,
  current_network_interface,
  random_port,
  upnp
}'</span>
</code></pre></div></div>

<p>Required invariants:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>listen_port == provider forwarded port
current_network_interface == tun0
random_port == false
upnp == false
</code></pre></div></div>

<p>The Web UI working does not prove these things.</p>

<hr />

<h2 id="step-11-configure-canonical-paths">Step 11: configure canonical paths</h2>

<p>Use:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBittorrent default:
  /data/torrents

incomplete:
  /data/torrents/incomplete

Sonarr category:
  /data/torrents/tv

Radarr category:
  /data/torrents/movies

Sonarr root:
  /data/media/tv

Radarr root:
  /data/media/movies
</code></pre></div></div>

<p>qBittorrent, Sonarr, and Radarr all mount the same host filesystem as <code class="language-plaintext highlighter-rouge">/data</code>.</p>

<p>That is the important part.</p>

<hr />

<h2 id="step-12-add-sonarr-and-radarr">Step 12: add Sonarr and Radarr</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> sonarr radarr
</code></pre></div></div>

<p>Open:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr  http://&lt;ARR_BIND_IP&gt;:8989
Radarr  http://&lt;ARR_BIND_IP&gt;:7878
</code></pre></div></div>

<p>Enable authentication before broadening access beyond loopback.</p>

<p>Configure qBittorrent as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>URL: http://gluetun:8080
</code></pre></div></div>

<p>Use the matching qBittorrent category for each application.</p>

<p>Do not use the host IP for container-to-container traffic when Docker DNS already supplies the intended path.</p>

<hr />

<h2 id="step-13-add-jackett-and-flaresolverr">Step 13: add Jackett and FlareSolverr</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> jackett flaresolverr
</code></pre></div></div>

<p>Jackett:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://&lt;ARR_BIND_IP&gt;:9117
</code></pre></div></div>

<p>FlareSolverr has no host-published port.</p>

<p>Because Jackett and FlareSolverr share Gluetun’s namespace, Jackett can use:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://127.0.0.1:8191
</code></pre></div></div>

<p>for compatible FlareSolverr integration.</p>

<p>Sonarr/Radarr reach Jackett through:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://gluetun:9117
</code></pre></div></div>

<p>Test every indexer independently.</p>

<hr />

<h2 id="step-14-prove-the-network-split">Step 14: prove the network split</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>svc <span class="k">in</span> <span class="se">\</span>
  gluetun <span class="se">\</span>
  qbittorrent <span class="se">\</span>
  jackett <span class="se">\</span>
  flaresolverr <span class="se">\</span>
  sonarr <span class="se">\</span>
  radarr <span class="se">\</span>
  recyclarr <span class="se">\</span>
  cleanuparr <span class="se">\</span>
  tdarr
<span class="k">do
    </span><span class="nv">cid</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>

    <span class="nb">printf</span> <span class="s1">'%-14s '</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span>

    docker inspect <span class="s2">"</span><span class="nv">$cid</span><span class="s2">"</span> <span class="se">\</span>
      <span class="nt">--format</span> <span class="s1">'mode={{.HostConfig.NetworkMode}} {{range $n,$v := .NetworkSettings.Networks}}net={{$n}} ip={{$v.IPAddress}} {{end}}'</span>
<span class="k">done</span>
</code></pre></div></div>

<p>Expected conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gluetun        project network
sonarr         project network
radarr         project network
recyclarr      project network
cleanuparr     project network

qbittorrent    container:&lt;gluetun-id&gt;
jackett        container:&lt;gluetun-id&gt;
flaresolverr   container:&lt;gluetun-id&gt;

tdarr          bridge
</code></pre></div></div>

<p>Check control-plane reachability:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>svc <span class="k">in </span>sonarr radarr<span class="p">;</span> <span class="k">do
    </span><span class="nb">echo</span> <span class="s2">"=== </span><span class="nv">$svc</span><span class="s2"> ==="</span>

    docker <span class="nb">exec</span> <span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> sh <span class="nt">-c</span> <span class="s1">'
      curl -sS -o /dev/null \
        -w "qbit=%{http_code}\n" \
        --max-time 5 \
        http://gluetun:8080/

      curl -sS -o /dev/null \
        -w "jackett=%{http_code}\n" \
        --max-time 5 \
        http://gluetun:9117/
    '</span>
<span class="k">done</span>
</code></pre></div></div>

<p>An expected redirect or authentication error can still prove the network path is alive. Do not require HTTP 200 when an application intentionally answers with 401, 403, or 3xx.</p>

<hr />

<h2 id="step-15-prove-one-hardlink-import">Step 15: prove one hardlink import</h2>

<p>First:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">stat</span> <span class="nt">-c</span> <span class="s1">'device=%d %n'</span> <span class="se">\</span>
  /srv/media-stack/torrents <span class="se">\</span>
  /srv/media-stack/media
</code></pre></div></div>

<p>Then complete one known-good download and let Sonarr or Radarr import it.</p>

<p>Compare the download file and library file:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">stat</span> <span class="nt">-c</span> <span class="se">\</span>
  <span class="s1">'device=%d inode=%i links=%h size=%s path=%n'</span> <span class="se">\</span>
  /srv/media-stack/torrents/&lt;downloaded-file&gt; <span class="se">\</span>
  /srv/media-stack/media/&lt;imported-file&gt;
</code></pre></div></div>

<p>A real hardlink should have:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>same device
same inode
same size
link count &gt;= 2
</code></pre></div></div>

<p>Do not call the storage design finished until this has been proven once.</p>

<hr />

<h2 id="step-16-add-recyclarr">Step 16: add Recyclarr</h2>

<p>Only do this after ordinary Sonarr/Radarr imports work.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> recyclarr
</code></pre></div></div>

<p>Create its initial configuration if needed:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose run <span class="nt">--rm</span> <span class="se">\</span>
  recyclarr <span class="se">\</span>
  config create
</code></pre></div></div>

<p>Use Docker service names in the config:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">sonarr</span><span class="pi">:</span>
  <span class="na">main</span><span class="pi">:</span>
    <span class="na">base_url</span><span class="pi">:</span> <span class="s">http://sonarr:8989</span>
    <span class="na">api_key</span><span class="pi">:</span> <span class="kt">!secret</span> <span class="s">sonarr</span>

<span class="na">radarr</span><span class="pi">:</span>
  <span class="na">main</span><span class="pi">:</span>
    <span class="na">base_url</span><span class="pi">:</span> <span class="s">http://radarr:7878</span>
    <span class="na">api_key</span><span class="pi">:</span> <span class="kt">!secret</span> <span class="s">radarr</span>
</code></pre></div></div>

<p>Use Recyclarr’s supported secret mechanism for API keys.</p>

<p>Preview before applying:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose <span class="nb">exec</span> <span class="se">\</span>
  recyclarr <span class="se">\</span>
  recyclarr <span class="nb">sync</span> <span class="nt">--preview</span> <span class="nt">--log</span> info
</code></pre></div></div>

<p>Then verify both that the profiles exist and that the intended movies/series are actually assigned to them.</p>

<hr />

<h2 id="step-17-add-cleanuparr">Step 17: add Cleanuparr</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> cleanuparr
</code></pre></div></div>

<p>Check health:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker inspect <span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> cleanuparr<span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--format</span> <span class="s1">'{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}'</span>
</code></pre></div></div>

<p>Use these internal targets:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr:
  http://sonarr:8989

Radarr:
  http://radarr:7878

qBittorrent:
  http://gluetun:8080
</code></pre></div></div>

<p>Enable automation conservatively:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>configure
verify connections
observe
dry-run where available
one narrow live rule
review evidence
expand
</code></pre></div></div>

<p>Cleanuparr is a queue/download management layer, not an antivirus scanner.</p>

<hr />

<h2 id="step-18-configure-qbittorrent-filename-policy">Step 18: configure qBittorrent filename policy</h2>

<p>A first-pass blocked list can include:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>*.exe
*.sh
*.bat
*.cmd
*.com
*.ps1
*.vbs
*.scr
*.msi
*.lnk
</code></pre></div></div>

<p>This is filename policy, not malware detection.</p>

<p>After configuring it, query qBittorrent’s preferences and verify the setting is actually present.</p>

<p>More importantly, test the same ingestion path production uses. A torrent manually injected into qBittorrent may not exercise the same workflow as one originated by Sonarr or Radarr.</p>

<hr />

<h2 id="step-19-test-blocked-content-handling-with-an-inert-canary">Step 19: test blocked-content handling with an inert canary</h2>

<p>Do not use real malware.</p>

<p>Use synthetic torrent metadata that declares an intentionally blocked filename without distributing malicious payload content.</p>

<p>The full test path should be:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr/Radarr
    |
    v
candidate accepted
    |
    v
qBittorrent receives metadata
    |
    v
blocked file becomes unwanted
    |
    v
Arr cannot import
    |
    v
Cleanuparr sees queue condition
    |
    +--&gt; strike/remove/block
    `--&gt; replacement if configured
</code></pre></div></div>

<p>A canary rejected by a quality profile or minimum-size rule is still useful evidence, but it proves only that upstream gate.</p>

<hr />

<h2 id="step-20-add-tdarr-as-read-only-validation">Step 20: add Tdarr as read-only validation</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> tdarr
</code></pre></div></div>

<p>Open:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>http://&lt;ARR_BIND_IP&gt;:8265
</code></pre></div></div>

<p>Create the initial UI account.</p>

<p>Check host publication:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker port <span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> tdarr<span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>You should see <code class="language-plaintext highlighter-rouge">8265</code>.</p>

<p>You should <strong>not</strong> see <code class="language-plaintext highlighter-rouge">8266</code>.</p>

<p>Verify mounts:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker inspect <span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> tdarr<span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--format</span> <span class="s1">'{{range .Mounts}}{{println .Destination "RW=" .RW}}{{end}}'</span>
</code></pre></div></div>

<p>Expected:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/media RW= false
/temp  RW= true
</code></pre></div></div>

<p>This deployment is health-check only:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>transcodegpuWorkers=0
transcodecpuWorkers=0
healthcheckgpuWorkers=0
healthcheckcpuWorkers=1
</code></pre></div></div>

<p>Do not pass a GPU into Tdarr unless you intentionally expand its role.</p>

<p>A successful media health check is not a malware verdict and is not a quarantine gate.</p>

<hr />

<h2 id="step-21-harden-management-access">Step 21: harden management access</h2>

<p>For every Web UI, answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Who can reach it?
How do they authenticate?
Does it need host publication at all?
</code></pre></div></div>

<p>The Compose example defaults to loopback.</p>

<p>If moving to a management LAN:</p>

<ol>
  <li>enable application authentication first;</li>
  <li>change <code class="language-plaintext highlighter-rouge">ARR_BIND_IP</code>;</li>
  <li>recreate the affected services;</li>
  <li>inspect listeners;</li>
  <li>verify authentication again from a fresh browser session.</li>
</ol>

<p>Check listeners:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ss <span class="nt">-lnt</span> |
<span class="nb">grep</span> <span class="nt">-E</span> <span class="se">\</span>
<span class="s1">':(8080|9117|8989|7878|11011|8265|8266)[[:space:]]'</span>
</code></pre></div></div>

<p>There should be no host listener for Tdarr <code class="language-plaintext highlighter-rouge">8266</code>.</p>

<p>FlareSolverr and Recyclarr should have no host-published ports.</p>

<p>Do not expose management UIs to the public Internet simply because torrent peer traffic is intentionally reachable through the VPN provider.</p>

<hr />

<h2 id="step-22-validate-the-stack-as-relationships">Step 22: validate the stack as relationships</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>
docker compose ps <span class="nt">-a</span>
docker compose config <span class="nt">-q</span>
</code></pre></div></div>

<p>Then check the real invariants.</p>

<h3 id="vpn-boundary">VPN boundary</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Gluetun healthy
only qBit / Jackett / FlareSolverr share Gluetun
VPN IPv4 works
unexpected IPv6 path absent if disabled
forwarded port exists
</code></pre></div></div>

<h3 id="torrent-transport">Torrent transport</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBit API reachable
listen port == forwarded port
interface == tun0
random port disabled
UPnP disabled
TCP listener present
UDP listener present
</code></pre></div></div>

<h3 id="control-plane">Control plane</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr / Radarr / Recyclarr / Cleanuparr on project network
Sonarr/Radarr reach qBit at gluetun:8080
Sonarr/Radarr reach Jackett at gluetun:9117
</code></pre></div></div>

<h3 id="storage">Storage</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>download and library trees same filesystem
one real hardlink proven
</code></pre></div></div>

<h3 id="management">Management</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UIs bound only where intended
auth enabled
internal-only ports not published
</code></pre></div></div>

<h3 id="tdarr">Tdarr</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>auth enabled
/media read-only
/temp writable
8266 not published
no GPU
transcode workers zero
health worker enabled
</code></pre></div></div>

<hr />

<h2 id="step-23-keep-a-compact-health-script">Step 23: keep a compact health script</h2>

<p>A minimal local checker can validate service state, topology, the forwarded port, and the Tdarr read-only boundary.</p>

<p>Create <code class="language-plaintext highlighter-rouge">/srv/arr/health-lite.sh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> /srv/arr/health-lite.sh <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
#!/usr/bin/env bash
set -u

cd "</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$0</span><span class="s2">"</span><span class="si">)</span><span class="sh">" || exit 1

FAILURES=0

pass() {
    printf 'PASS: %s</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$*</span><span class="sh">"
}

fail() {
    printf 'FAIL: %s</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$*</span><span class="sh">" &gt;&amp;2
    FAILURES=</span><span class="k">$((</span>FAILURES <span class="o">+</span> <span class="m">1</span><span class="k">))</span><span class="sh">
}

echo '=== COMPOSE ==='

docker compose config -q </span><span class="se">\</span><span class="sh">
  &amp;&amp; pass 'Compose valid' </span><span class="se">\</span><span class="sh">
  || fail 'Compose invalid'

for svc in </span><span class="se">\</span><span class="sh">
  gluetun qbittorrent jackett flaresolverr </span><span class="se">\</span><span class="sh">
  sonarr radarr recyclarr cleanuparr tdarr
do
    cid="</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="sh">"

    [ -n "</span><span class="nv">$cid</span><span class="sh">" ] || {
        fail "</span><span class="nv">$svc</span><span class="sh"> missing"
        continue
    }

    state="</span><span class="si">$(</span>
        docker inspect <span class="s2">"</span><span class="nv">$cid</span><span class="s2">"</span> <span class="se">\</span>
          <span class="nt">--format</span> <span class="s1">'{{.State.Status}}'</span> <span class="se">\</span>
          2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
    <span class="si">)</span><span class="sh">"

    [ "</span><span class="nv">$state</span><span class="sh">" = running ] </span><span class="se">\</span><span class="sh">
      &amp;&amp; pass "</span><span class="nv">$svc</span><span class="sh"> running" </span><span class="se">\</span><span class="sh">
      || fail "</span><span class="nv">$svc</span><span class="sh"> state=</span><span class="nv">$state</span><span class="sh">"
done

echo
echo '=== GLUETUN ==='

GLUE="</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> gluetun 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="sh">"

if [ -n "</span><span class="nv">$GLUE</span><span class="sh">" ]; then
    health="</span><span class="si">$(</span>
        docker inspect <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
          <span class="nt">--format</span> <span class="s1">'{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}'</span>
    <span class="si">)</span><span class="sh">"

    [ "</span><span class="nv">$health</span><span class="sh">" = healthy ] </span><span class="se">\</span><span class="sh">
      &amp;&amp; pass 'Gluetun healthy' </span><span class="se">\</span><span class="sh">
      || fail "Gluetun health=</span><span class="nv">$health</span><span class="sh">"

    PF="</span><span class="si">$(</span>
        docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
          <span class="nb">cat</span> /tmp/gluetun/forwarded_port <span class="se">\</span>
          2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
    <span class="si">)</span><span class="sh">"

    case "</span><span class="nv">$PF</span><span class="sh">" in
        ''|*[!0-9]*)
            fail 'forwarded port unavailable'
            ;;
        *)
            pass 'forwarded port present'
            ;;
    esac
fi

echo
echo '=== VPN-BOUND SERVICES ==='

for svc in qbittorrent jackett flaresolverr; do
    cid="</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="sh">"
    mode="</span><span class="si">$(</span>
        docker inspect <span class="s2">"</span><span class="nv">$cid</span><span class="s2">"</span> <span class="se">\</span>
          <span class="nt">--format</span> <span class="s1">'{{.HostConfig.NetworkMode}}'</span> <span class="se">\</span>
          2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
    <span class="si">)</span><span class="sh">"

    case "</span><span class="nv">$mode</span><span class="sh">" in
        container:*)
            pass "</span><span class="nv">$svc</span><span class="sh"> shares container namespace"
            ;;
        *)
            fail "</span><span class="nv">$svc</span><span class="sh"> network mode=</span><span class="nv">$mode</span><span class="sh">"
            ;;
    esac
done

echo
echo '=== TDARR ==='

TDARR="</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> tdarr 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="sh">"

if [ -n "</span><span class="nv">$TDARR</span><span class="sh">" ]; then
    MEDIA_RW="</span><span class="si">$(</span>
        docker inspect <span class="s2">"</span><span class="nv">$TDARR</span><span class="s2">"</span> <span class="se">\</span>
          <span class="nt">--format</span> <span class="s1">'{{range .Mounts}}{{if eq .Destination "/media"}}{{.RW}}{{end}}{{end}}'</span>
    <span class="si">)</span><span class="sh">"

    [ "</span><span class="nv">$MEDIA_RW</span><span class="sh">" = false ] </span><span class="se">\</span><span class="sh">
      &amp;&amp; pass 'Tdarr /media read-only' </span><span class="se">\</span><span class="sh">
      || fail "Tdarr /media RW=</span><span class="nv">$MEDIA_RW</span><span class="sh">"

    if docker port "</span><span class="nv">$TDARR</span><span class="sh">" 8266/tcp 2&gt;/dev/null |
       grep -q .; then
        fail 'Tdarr 8266 is host-published'
    else
        pass 'Tdarr 8266 not host-published'
    fi
fi

echo
echo '=== SUMMARY ==='
printf 'Failures: %d</span><span class="se">\n</span><span class="sh">' "</span><span class="nv">$FAILURES</span><span class="sh">"

if [ "</span><span class="nv">$FAILURES</span><span class="sh">" -eq 0 ]; then
    echo 'ARR STACK HEALTH: PASS'
    exit 0
else
    echo 'ARR STACK HEALTH: FAIL'
    exit 1
fi
</span><span class="no">EOF

</span><span class="nb">chmod </span>755 /srv/arr/health-lite.sh
</code></pre></div></div>

<p>Run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/arr
./health-lite.sh
</code></pre></div></div>

<p>A fuller watchdog can add authenticated API checks, exact bind validation, capacity thresholds, qBittorrent preference comparison, Recyclarr targets, Cleanuparr policy, and retained failure evidence.</p>

<hr />

<h2 id="step-24-test-failure-modes-before-trusting-alerts">Step 24: test failure modes before trusting alerts</h2>

<p>Useful tests:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VPN outage
management binding drift
forwarded-port mismatch detection
Tdarr read-only mount validation
application authentication
</code></pre></div></div>

<p>Do not deliberately expose a random torrent port to test monitoring.</p>

<p>Do not modify real library content merely to prove the Tdarr mount is read-only.</p>

<p>Test the narrowest safe condition that proves the invariant.</p>

<hr />

<h2 id="step-25-alert-on-state-transitions">Step 25: alert on state transitions</h2>

<p>Polling every minute is fine.</p>

<p>Alerting every minute during the same outage is not.</p>

<p>Use:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>previous       current       action
--------       -------       ------
UNKNOWN/PASS   PASS          silence
UNKNOWN/PASS   FAIL          DOWN
FAIL           FAIL          silence
FAIL           PASS          RECOVERED
PASS           PASS          silence
</code></pre></div></div>

<p>Start with:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>detect
record
notify
</code></pre></div></div>

<p>—not automatic restart.</p>

<p>A local watchdog cannot detect the disappearance of its own host, so add an external dead-man check if host-level liveness matters.</p>

<hr />

<h2 id="operational-commands">Operational commands</h2>

<p>Validate:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/arr
docker compose config <span class="nt">-q</span>
</code></pre></div></div>

<p>Status:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose ps <span class="nt">-a</span>
</code></pre></div></div>

<p>Logs:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose logs <span class="se">\</span>
  <span class="nt">--since</span> 15m <span class="se">\</span>
  <span class="nt">--no-color</span> <span class="se">\</span>
  &lt;service&gt;
</code></pre></div></div>

<p>Recreate an ordinary service:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--no-deps</span> <span class="se">\</span>
  <span class="nt">--force-recreate</span> <span class="se">\</span>
  &lt;service&gt;
</code></pre></div></div>

<p>Do <strong>not</strong> recreate Gluetun by itself. qBittorrent, Jackett, and FlareSolverr share its network namespace. If Gluetun itself must be recreated, recreate that dependency group together:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--force-recreate</span> <span class="se">\</span>
  gluetun qbittorrent jackett flaresolverr
</code></pre></div></div>

<p>Current forwarded port:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GLUE</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> gluetun<span class="si">)</span><span class="s2">"</span>

docker <span class="nb">exec</span> <span class="s2">"</span><span class="nv">$GLUE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nb">cat</span> /tmp/gluetun/forwarded_port
</code></pre></div></div>

<p>Topology:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for </span>svc <span class="k">in</span> <span class="se">\</span>
  gluetun qbittorrent jackett flaresolverr <span class="se">\</span>
  sonarr radarr recyclarr cleanuparr tdarr
<span class="k">do
    </span><span class="nv">cid</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>docker compose ps <span class="nt">-q</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>

    <span class="nb">printf</span> <span class="s1">'%-14s '</span> <span class="s2">"</span><span class="nv">$svc</span><span class="s2">"</span>

    docker inspect <span class="s2">"</span><span class="nv">$cid</span><span class="s2">"</span> <span class="se">\</span>
      <span class="nt">--format</span> <span class="s1">'mode={{.HostConfig.NetworkMode}} {{range $n,$v := .NetworkSettings.Networks}}net={{$n}} {{end}}'</span>
<span class="k">done</span>
</code></pre></div></div>

<p>Health:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./health-lite.sh
</code></pre></div></div>

<hr />

<h2 id="what-a-passing-stack-actually-proves">What a passing stack actually proves</h2>

<p>A defensible claim is:</p>

<blockquote>
  <p>The stack is using the expected VPN and container-network topology, the torrent listener matches the provider forwarding state, management exposure matches the configured boundary, Arr imports can hardlink on the shared filesystem, and the configured post-import structural media check has no reported finding.</p>
</blockquote>

<p>That does <strong>not</strong> mean:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>the download is guaranteed malware-free
the media file is safe under every parser
the VPN provider is trustworthy
the container images are bug-free
Tdarr is a quarantine gate
</code></pre></div></div>

<p>Each layer answers a narrower question.</p>

<hr />

<h2 id="build-order-recap">Build order recap</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>storage
  -&gt;
VPN boundary
  -&gt;
qBittorrent transport
  -&gt;
Sonarr / Radarr
  -&gt;
Jackett / FlareSolverr
  -&gt;
one proven hardlink import
  -&gt;
Recyclarr
  -&gt;
Cleanuparr
  -&gt;
filename policy + inert canary
  -&gt;
read-only Tdarr
  -&gt;
health invariants
  -&gt;
stateful alerting
</code></pre></div></div>

<p>That order keeps troubleshooting local: each layer is added only after the layer below it has a known-good state.</p>

<hr />

<h2 id="related-lab-notes">Related Lab Notes</h2>

<ul>
  <li><a href="/2026/08/26/building-an-arr-stack/">Building an Arr Stack: What Each Service Should Own</a></li>
  <li><a href="/2026/08/27/hardening-an-arr-stack/">Hardening an Arr Stack: Defense in Depth from Torrent to Library</a></li>
  <li><a href="/2026/08/28/operationalizing-an-arr-stack/">Operationalizing an Arr Stack: Health Invariants and Stateful Alerting</a></li>
</ul>

<p>Those posts explain the design decisions and failure modes. This guide is the implementation.</p>

<hr />

<h2 id="upstream-references">Upstream references</h2>

<ul>
  <li><a href="https://docs.docker.com/compose/how-tos/networking/">Docker Compose networking</a></li>
  <li><a href="https://docs.docker.com/reference/compose-file/services/">Docker Compose service reference</a></li>
  <li><a href="https://docs.linuxserver.io/images/docker-sonarr/">LinuxServer Sonarr container</a></li>
  <li><a href="https://docs.linuxserver.io/images/docker-radarr/">LinuxServer Radarr container</a></li>
  <li><a href="https://github.com/qdm12/gluetun-wiki/blob/main/setup/options/port-forwarding.md">Gluetun VPN port-forwarding options</a></li>
  <li><a href="https://github.com/qbittorrent/qBittorrent/wiki/Basic-Authentication-%28%E2%89%A5v5.2.0%29">qBittorrent Basic Authentication 5.2.0+</a></li>
  <li><a href="https://recyclarr.dev/guide/installation/docker/">Recyclarr Docker installation</a></li>
  <li><a href="https://cleanuparr.github.io/">Cleanuparr documentation</a></li>
  <li><a href="https://docs.tdarr.io/docs/other/authentication/">Tdarr authentication</a></li>
  <li><a href="https://docs.tdarr.io/docs/installation/variables/">Tdarr configuration variables</a></li>
</ul>]]></content><author><name></name></author><category term="docker" /><category term="arr" /><category term="networking" /><category term="security" /><category term="gluetun" /><category term="qbittorrent" /><category term="sonarr" /><category term="radarr" /><summary type="html"><![CDATA[The three Arr posts in Lab Notes documented how this stack evolved: service ownership, a smaller VPN failure domain, defense in depth around untrusted downloads, and stack-wide health invariants.]]></summary></entry><entry><title type="html">Operationalizing an Arr Stack: Health Invariants and Stateful Alerting</title><link href="https://fungijr.github.io/2026/08/28/operationalizing-an-arr-stack/" rel="alternate" type="text/html" title="Operationalizing an Arr Stack: Health Invariants and Stateful Alerting" /><published>2026-08-28T00:00:00+00:00</published><updated>2026-08-28T00:00:00+00:00</updated><id>https://fungijr.github.io/2026/08/28/operationalizing-an-arr-stack</id><content type="html" xml:base="https://fungijr.github.io/2026/08/28/operationalizing-an-arr-stack/"><![CDATA[<p>A stack is not finished when every container says <code class="language-plaintext highlighter-rouge">Up</code>.</p>

<p>For a multi-service media pipeline, the useful question is whether the <strong>relationships between the services</strong> are still correct:</p>

<ul>
  <li>Is the torrent client still behind the VPN?</li>
  <li>Does its listen port still match the VPN provider’s forwarded port?</li>
  <li>Can Sonarr and Radarr still reach the downloader and indexer bridge?</li>
  <li>Are management UIs bound only where expected?</li>
  <li>Is the cleanup layer still pointing at the intended services?</li>
  <li>Is the post-import checker still read-only?</li>
</ul>

<p>I turned those assumptions into executable health invariants and designed the monitoring system around state transitions rather than repeated alerts.</p>

<hr />

<h2 id="container-health-is-necessary-but-insufficient">Container health is necessary but insufficient</h2>

<p>Docker can tell you that a container is running. That does not prove the application is correctly wired into the rest of the system.</p>

<p>For example, all of the following can happen while every container remains <code class="language-plaintext highlighter-rouge">Up</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBittorrent listen port no longer matches VPN forwarded port
Sonarr points at an obsolete hostname
Jackett is reachable only through a stale network path
management UI is accidentally wildcard-published
Tdarr server API is exposed to the host unnecessarily
cleanup service points at an old endpoint
</code></pre></div></div>

<p>The health check therefore validates <strong>invariants</strong>, not only process state.</p>

<hr />

<h2 id="a-useful-health-model">A useful health model</h2>

<p>The stack-wide health script checks several categories.</p>

<h3 id="compose-and-lifecycle">Compose and lifecycle</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Compose parses
expected services exist
services are running
restart policy matches policy
containers belong to the intended Compose project
</code></pre></div></div>

<h3 id="vpn-boundary">VPN boundary</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Gluetun healthy
only intended services share Gluetun namespace
input firewall contains only required ports
VPN IPv4 works
unexpected IPv6 Internet path is absent
provider-forwarded port exists
</code></pre></div></div>

<h3 id="torrent-transport">Torrent transport</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBittorrent API reachable
listen port == provider forwarded port
network interface == VPN tunnel
random port disabled
UPnP disabled
TCP listener present
UDP listener present
no stale default listener
</code></pre></div></div>

<h3 id="control-plane-routing">Control-plane routing</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr/Radarr on normal Docker network
Recyclarr/Cleanuparr on normal Docker network
Sonarr/Radarr can reach qBit via Gluetun service name
Sonarr/Radarr can reach Jackett via Gluetun service name
</code></pre></div></div>

<h3 id="management-boundary">Management boundary</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>expected UIs bound only to trusted management path
no wildcard publication
no host publication for internal-only services
</code></pre></div></div>

<h3 id="application-policy">Application policy</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Cleanuparr healthy
local-address authentication bypass disabled
Recyclarr read-only root filesystem
Recyclarr expected schedule and targets
Tdarr authentication enabled
Tdarr server/node API keys agree
Tdarr media mount read-only
Tdarr internal server listening but not host-published
Tdarr has no GPU device
</code></pre></div></div>

<h3 id="capacity">Capacity</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root filesystem below threshold
media/download filesystem below threshold
</code></pre></div></div>

<p>The script exits non-zero when a hard invariant fails.</p>

<hr />

<h2 id="why-exact-host-bindings-are-worth-checking">Why exact host bindings are worth checking</h2>

<p>A Compose file can accidentally regress from:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;trusted-management-address&gt;:&lt;port&gt;
</code></pre></div></div>

<p>back to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0.0.0.0:&lt;port&gt;
</code></pre></div></div>

<p>without affecting whether the application itself starts.</p>

<p>That is precisely the sort of configuration drift a health script should detect.</p>

<p>The same applies to ports that should not exist on the host at all. If an internal application server is only used by a process in the same container, its host publication should be treated as a failed invariant rather than an innocent extra mapping.</p>

<hr />

<h2 id="authentication-should-be-verified-before-exposure-changes">Authentication should be verified before exposure changes</h2>

<p>One management service was initially loopback-only. Before moving it to the trusted management network, the account state was checked and a real login was verified.</p>

<p>The sequence was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>verify account initialized
verify local-address auth bypass disabled
verify password login works
only then change host binding
verify again from a fresh browser session
</code></pre></div></div>

<p>A second service required a slightly more involved bootstrap because enabling Web UI authentication also meant its internal node needed an API key.</p>

<p>The safe sequence was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>generate private API key
keep UI loopback-only
enable authentication
configure internal node with matching key
verify node reconnects
create/login to UI account
confirm auth errors stop
only then expose UI to trusted management network
</code></pre></div></div>

<p>This order prevents “enable auth and hope” deployments where an internal worker silently stops functioning after the UI is secured.</p>

<hr />

<h2 id="build-monitoring-around-state-transitions">Build monitoring around state transitions</h2>

<p>Running a health script every minute is easy. Sending a notification every minute during an outage is not useful.</p>

<p>The local watchdog should remember the previous state:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>previous       current       action
--------       -------       ------
UNKNOWN/PASS   PASS          nothing
UNKNOWN/PASS   FAIL          send one DOWN
FAIL           FAIL          suppress duplicate
FAIL           PASS          send one RECOVERED
PASS           PASS          nothing
</code></pre></div></div>

<p>The important property is that notification behavior depends on the <strong>transition</strong>, not simply on the current exit code.</p>

<hr />

<h2 id="a-simple-local-watchdog-design">A simple local watchdog design</h2>

<p>The planned local architecture is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemd timer
    |
    v
watchdog service
    |
    v
stack health script
    |
    +--&gt; save latest output
    +--&gt; compare state with previous run
    +--&gt; notify on transition
    `--&gt; preserve last failure for diagnosis
</code></pre></div></div>

<p>Persistent state can live somewhere such as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/var/lib/&lt;watchdog-name&gt;/
├── state
├── last-health.txt
└── last-failure.txt
</code></pre></div></div>

<p>The notifier should be a small adapter so the state machine does not care whether delivery is through Home Assistant, ntfy, Gotify, Pushover, email, or another service.</p>

<hr />

<h2 id="do-not-auto-restart-on-the-first-failure">Do not auto-restart on the first failure</h2>

<p>Automatic remediation is attractive, but it can erase the evidence needed to understand a failure.</p>

<p>For the initial deployment, the watchdog should:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>detect
record
notify
</code></pre></div></div>

<p>—not restart.</p>

<p>After real incidents have been observed, narrow remediations can be added for failure modes that are well understood and safe to automate.</p>

<p>For example, a stale application process may eventually justify a restart. A VPN/authentication/route failure may not.</p>

<hr />

<h2 id="a-local-watchdog-cannot-detect-its-own-disappearance">A local watchdog cannot detect its own disappearance</h2>

<p>There is one unavoidable blind spot:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>host/LXC down
     |
     +--&gt; timer cannot run
     +--&gt; health script cannot run
     `--&gt; local notifier cannot run
</code></pre></div></div>

<p>That requires an external dead-man monitor on a different failure domain.</p>

<p>The external check can be much simpler than the local one. It does not need to understand every application. It only needs to answer something like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is the host reachable?
Is one trusted management endpoint alive?
Is a heartbeat still fresh?
</code></pre></div></div>

<p>The local watchdog provides depth. The external monitor provides liveness from the outside.</p>

<hr />

<h2 id="three-notification-layers-work-well-together">Three notification layers work well together</h2>

<p>A complete monitoring strategy can use three layers:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Layer 1: native application notifications
         - best event context
         - import/queue/cleanup-specific events

Layer 2: local stateful watchdog
         - deep cross-service invariants
         - DOWN / RECOVERED transitions

Layer 3: external dead-man monitor
         - host/container disappearance
         - independent failure domain
</code></pre></div></div>

<p>No single layer has to solve every problem.</p>

<hr />

<h2 id="preserve-evidence-when-a-health-check-fails">Preserve evidence when a health check fails</h2>

<p>A useful watchdog should retain the full health output rather than reducing everything to “red” or “green.”</p>

<p>A failure notification can be short:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Media stack DOWN: 3 failed invariants
</code></pre></div></div>

<p>while the local evidence contains the actual details:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VPN healthy
forwarded port present
qBit port mismatch
Sonarr reachable
Radarr reachable
</code></pre></div></div>

<p>That immediately narrows the incident from “the stack is broken” to “torrent transport state drifted.”</p>

<hr />

<h2 id="the-final-operational-goal">The final operational goal</h2>

<p>The point of all of this is not maximal complexity. It is to make the system answer, quickly and unambiguously:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What failed?
What is still safe?
What is still available?
Did the failure recover?
Do I need to intervene?
</code></pre></div></div>

<p>A good Docker media stack is not just automated. It is <strong>observable, bounded, and recoverable without guessing</strong>.</p>

<hr />

<h2 id="further-reading">Further reading</h2>

<ul>
  <li>systemd timers: https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html</li>
  <li>Docker Compose networking: https://docs.docker.com/compose/how-tos/networking/</li>
  <li>Gluetun firewall behavior: https://github.com/qdm12/gluetun-wiki/blob/main/faq/firewall.md</li>
  <li>Tdarr authentication: https://docs.tdarr.io/docs/other/authentication/</li>
  <li>Cleanuparr account configuration: https://cleanuparr.github.io/docs/configuration/account/</li>
</ul>]]></content><author><name></name></author><category term="docker" /><category term="arr" /><category term="monitoring" /><category term="security" /><category term="systemd" /><summary type="html"><![CDATA[A stack is not finished when every container says Up.]]></summary></entry><entry><title type="html">Hardening an Arr Stack: Defense in Depth from Torrent to Library</title><link href="https://fungijr.github.io/2026/08/27/hardening-an-arr-stack/" rel="alternate" type="text/html" title="Hardening an Arr Stack: Defense in Depth from Torrent to Library" /><published>2026-08-27T00:00:00+00:00</published><updated>2026-08-27T00:00:00+00:00</updated><id>https://fungijr.github.io/2026/08/27/hardening-an-arr-stack</id><content type="html" xml:base="https://fungijr.github.io/2026/08/27/hardening-an-arr-stack/"><![CDATA[<p>Part 1 was mostly about ownership.</p>

<p>I wanted each service to have one clear job, the storage layout to support hardlinks, and the VPN failure domain to be as small as possible.</p>

<p>Once that was in place, the next question was less about architecture and more about trust.</p>

<p>Everything entering the download side of the stack comes from systems I do not control.</p>

<p>Torrent metadata is untrusted input.</p>

<p>The files described by that metadata are untrusted input.</p>

<p>A successful download only proves that the download client transferred what it was told to transfer.</p>

<p>It does not prove that I wanted the payload.</p>

<p>That changed the question from:</p>

<blockquote>
  <p>How do I make the stack download reliably?</p>
</blockquote>

<p>to:</p>

<blockquote>
  <p>How many opportunities do I have to reject something before I treat it like normal media?</p>
</blockquote>

<p>I did not want one oversized security component pretending to answer that question.</p>

<p>I wanted several narrow controls whose behavior I could test independently.</p>

<p>The resulting path looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>network containment
        |
        v
filename policy
        |
        v
queue enforcement
        |
        v
authoritative import
        |
        v
read-only structural validation
        |
        v
media consumption
</code></pre></div></div>

<p>None of those layers proves that a file is malware-free.</p>

<p>That distinction matters.</p>

<p>The goal is to reduce exposure, catch specific classes of unwanted content early, and make every decision point visible enough to test.</p>

<hr />

<h2 id="layer-1-contain-the-network-path">Layer 1: contain the network path</h2>

<p>The first control is the one established in Part 1.</p>

<p>qBittorrent shares Gluetun’s network namespace.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBittorrent
     |
     v
 Gluetun
     |
     v
   VPN
</code></pre></div></div>

<p>If the VPN path disappears, the torrent client should lose its Internet path instead of quietly falling back to the host’s ordinary WAN connection.</p>

<p>That answers one specific question:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Where is this traffic allowed to go?
</code></pre></div></div>

<p>It does not answer:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What is inside this torrent?
</code></pre></div></div>

<p>A VPN kill switch is containment.</p>

<p>It is not content inspection.</p>

<p>Keeping those claims separate makes the rest of the stack easier to reason about.</p>

<hr />

<h2 id="layer-2-reject-obviously-unwanted-filenames-early">Layer 2: reject obviously unwanted filenames early</h2>

<p>The download client is one of the earliest places where the torrent’s file list becomes visible.</p>

<p>qBittorrent has an <code class="language-plaintext highlighter-rouge">Excluded file names</code> feature that can be used as a first-pass policy gate.</p>

<p>For a media-only workflow, an example list might include:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>*.exe
*.sh
*.bat
*.cmd
*.com
*.ps1
*.vbs
*.scr
*.msi
*.lnk
</code></pre></div></div>

<p>That is an example, not a universal blacklist.</p>

<p>The useful rule is simpler:</p>

<blockquote>
  <p>If a file type has no legitimate reason to appear in this workflow, do not select it for transfer by default.</p>
</blockquote>

<p>In a working exclusion path, a matching file should become unwanted rather than being selected for transfer.</p>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>torrent metadata
      |
      v
inspect file names
      |
      +--&gt; expected media ----&gt; normal download
      |
      `--&gt; blocked pattern ---&gt; unwanted
</code></pre></div></div>

<p>That is valuable because the decision happens before import.</p>

<p>It is much better to reject an unexpected executable or script while the torrent is still download-side state than after it has already reached the media library.</p>

<p>But filename filtering has obvious limits.</p>

<p>A file can have a misleading extension.</p>

<p>Unwanted content can be packaged inside another file.</p>

<p>A valid media container can still contain malformed or hostile data.</p>

<p>So this layer should be described as a <strong>filename policy</strong>, not malware detection.</p>

<hr />

<h2 id="configuration-is-not-proof">Configuration is not proof</h2>

<p>There is another reason I do not treat an enabled checkbox as evidence that a control is working.</p>

<p>qBittorrent 5.2.x has had a confirmed WebUI regression where <code class="language-plaintext highlighter-rouge">Excluded file names</code> could be ignored when a torrent was added through the WebUI.</p>

<p>That distinction is important.</p>

<p>The upstream report is specifically about the WebUI path. It does not prove that every API, watch-folder, or automation ingestion path behaves the same way.</p>

<p>So the useful invariant is not:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Excluded file names = enabled
</code></pre></div></div>

<p>The useful invariant is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>a torrent added through the real production path
causes a matching file to become unwanted
</code></pre></div></div>

<p>Version matters.</p>

<p>Ingestion path matters.</p>

<p>Observed behavior matters more than configuration state.</p>

<p>That lesson applies to much more than qBittorrent.</p>

<hr />

<h2 id="layer-3-finish-the-failure-in-the-queue">Layer 3: finish the failure in the queue</h2>

<p>Rejecting a file is only half of the workflow.</p>

<p>Sonarr or Radarr may already be tracking the release that produced the torrent.</p>

<p>If qBittorrent refuses every useful payload file, the Arr application can be left waiting for something that will never become importable.</p>

<p>That is where the queue supervisor becomes useful.</p>

<p>In this stack, that job belongs to Cleanuparr.</p>

<p>The failure path becomes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr / Radarr approve release
             |
             v
qBittorrent receives metadata
             |
             v
payload violates filename policy
             |
             v
files become unwanted
             |
             v
nothing useful can be imported
             |
             v
Cleanuparr sees failed queue state
             |
             +--&gt; remove download
             +--&gt; fail / clean queue item
             `--&gt; replacement when configured
</code></pre></div></div>

<p>This is a good example of why I prefer small components with explicit ownership.</p>

<p>qBittorrent does not need to understand Sonarr’s queue.</p>

<p>Sonarr does not need to implement qBittorrent’s filename policy.</p>

<p>Cleanuparr does not need to inspect media containers.</p>

<p>Each one handles the state it already understands.</p>

<p>The important part is that a blocked download reaches a complete workflow outcome instead of becoming a permanent queue mystery.</p>

<hr />

<h2 id="safe-canaries-are-better-than-real-malware">Safe canaries are better than real malware</h2>

<p>I wanted to prove the blocked-download path without downloading, storing, or distributing an actual malicious executable.</p>

<p>There was no reason to introduce a real payload just to test plumbing.</p>

<p>The useful canary was torrent metadata describing a deliberately blocked synthetic filename.</p>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>arr-canary/
└── blocked-test.exe
</code></pre></div></div>

<p>The filename is the test condition.</p>

<p>The file does not need to contain executable code.</p>

<p>That makes it possible to test:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>torrent metadata parsing
        |
        v
filename exclusion
        |
        v
unwanted-file state
        |
        v
Arr tracked-download state
        |
        v
Cleanuparr classification
        |
        v
removal / replacement behavior
</code></pre></div></div>

<p>without turning a workflow test into a malware-handling exercise.</p>

<p>This also exposed one of the more useful lessons from rebuilding the stack.</p>

<p>My first instinct was to insert the test directly into qBittorrent.</p>

<p>That proved the qBittorrent rule.</p>

<p>It did not prove the stack.</p>

<p>The production workflow begins farther upstream.</p>

<p>Sonarr or Radarr creates the tracked download state that the rest of the system later observes.</p>

<p>A torrent inserted manually into qBittorrent therefore does not necessarily carry the same application context.</p>

<p>The better test begins where the real workflow begins.</p>

<blockquote>
  <p>If the real event originates upstream, inject the canary upstream.</p>
</blockquote>

<p>That sounds obvious after the fact, but it is an easy mistake to make when testing distributed systems one component at a time.</p>

<hr />

<h2 id="a-failed-canary-can-still-be-a-successful-test">A failed canary can still be a successful test</h2>

<p>Not every synthetic release reached qBittorrent on the first try.</p>

<p>One candidate did not satisfy the active quality/profile policy.</p>

<p>Another fell below the application’s minimum-size rules.</p>

<p>Initially those looked like failed tests.</p>

<p>They were actually useful evidence.</p>

<p>The application rejected something it was supposed to reject, and it did so before creating downstream state.</p>

<p>A safe test should have gates:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>metadata parses
      |
      v
correct target identified
      |
      v
release policy accepts candidate
      |
      v
only then create downstream state
</code></pre></div></div>

<p>If the canary stops at one of those gates, that is information.</p>

<p>The important part is knowing <strong>which</strong> gate stopped it.</p>

<p>This is another reason I prefer test evidence over a final green checkbox.</p>

<p>A failed canary with a clear reason tells me more than a successful canary whose path I never inspected.</p>

<hr />

<h2 id="layer-4-keep-sonarr-and-radarr-authoritative-for-import">Layer 4: keep Sonarr and Radarr authoritative for import</h2>

<p>qBittorrent owns transfer state.</p>

<p>It should not own the media library.</p>

<p>Once a download is acceptable, Sonarr or Radarr remains responsible for deciding what becomes library content.</p>

<p>That keeps one authoritative location for:</p>

<ul>
  <li>release tracking;</li>
  <li>quality and profile decisions;</li>
  <li>destination selection;</li>
  <li>renaming;</li>
  <li>import history;</li>
  <li>failed-import state.</li>
</ul>

<p>The import boundary therefore stays:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBittorrent
     |
 completed download
     |
     v
Sonarr / Radarr
     |
 authoritative import
     |
     v
media library
</code></pre></div></div>

<p>This was useful operationally in Part 1.</p>

<p>It is useful for security for the same reason: there is one well-defined transition between “downloaded content” and “library content.”</p>

<hr />

<h2 id="hardlinks-preserve-that-ownership-model">Hardlinks preserve that ownership model</h2>

<p>Part 1 covered the storage layout in more detail, but hardlinks are worth mentioning again here because they let the authoritative importer do its job without requiring a second physical copy.</p>

<p>Conceptually:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/data/torrents/example.mkv
             |
             | same inode
             |
/data/media/example.mkv
</code></pre></div></div>

<p>Sonarr or Radarr creates the library entry while the download-side path can remain available for seeding.</p>

<p>That gives me:</p>

<ul>
  <li>one importer;</li>
  <li>one set of underlying data blocks;</li>
  <li>separate filesystem names for download and library roles;</li>
  <li>no need to let the torrent client write directly into the final library tree.</li>
</ul>

<p>Hardlinks only work when the relevant paths are on the same filesystem and the container mounts expose that relationship correctly.</p>

<p>If those conditions are not true, the Arr application may fall back to copying.</p>

<p>The important security property is not the hardlink itself.</p>

<p>It is that the download client still does not become the library authority.</p>

<hr />

<h2 id="layer-5-validate-media-with-less-write-access">Layer 5: validate media with less write access</h2>

<p>After import, I wanted one more check that did not require handing another application unrestricted write access to the library.</p>

<p>That is where Tdarr fits.</p>

<p>I am not using it here as an automatic transcoding pipeline.</p>

<p>Its job in this design is narrower:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>imported media
     |
     v
structural health check
</code></pre></div></div>

<p>For this role, I do not need active transcode workers.</p>

<p>I also do not need GPU access simply because Tdarr supports it.</p>

<p>The mount policy can stay narrow:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">${MEDIA_ROOT}:/media:ro</span>
  <span class="pi">-</span> <span class="s">${TDARR_TEMP}:/temp</span>
</code></pre></div></div>

<p>The media tree is read-only.</p>

<p>The scratch area is writable.</p>

<p>That is the boundary I care about.</p>

<p>Tdarr can inspect the library, maintain its own application state, and write temporary data without receiving permission to rewrite the media tree.</p>

<hr />

<h2 id="health-checking-is-not-quarantine">Health checking is not quarantine</h2>

<p>This is another place where terminology matters.</p>

<p>A successful media health check does not tell me:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>this file is safe
</code></pre></div></div>

<p>It tells me something closer to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>the configured media health check
did not report structural corruption
</code></pre></div></div>

<p>There is also a timing issue.</p>

<p>In this design, Tdarr checks media <strong>after</strong> Sonarr or Radarr imports it.</p>

<p>That means Jellyfin can potentially see the imported item before the health check finishes.</p>

<p>So this is not a quarantine architecture.</p>

<p>A real quarantine system would need another boundary:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>download
   |
   v
staging area
   |
   v
authoritative validation gate
   |
 PASS / FAIL
   |
   v
final media library
</code></pre></div></div>

<p>That is a different design with different operational costs.</p>

<p>I am not claiming to have built it here.</p>

<p>The read-only Tdarr check is another layer of visibility, not a pre-consumption security gate.</p>

<hr />

<h2 id="the-complete-path">The complete path</h2>

<p>Put together, the stack now has several opportunities to say no:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sonarr / Radarr
      |
      v
indexer path
      |
      v
qBittorrent through VPN
      |
      +--&gt; blocked filename?
      |        |
      |       yes
      |        |
      |        v
      |     unwanted
      |        |
      |        v
      |    Cleanuparr
      |        |
      |        +--&gt; remove / fail
      |        `--&gt; replacement
      |
      `--&gt; acceptable payload
               |
               v
        Sonarr / Radarr
        authoritative import
               |
               v
          hardlink / copy
               |
               v
          media library
               |
               +--&gt; Tdarr read-only health check
               |
               `--&gt; Jellyfin
</code></pre></div></div>

<p>No box in that diagram gets to declare the entire result trustworthy.</p>

<p>That is intentional.</p>

<hr />

<h2 id="what-these-controls-actually-prove">What these controls actually prove</h2>

<p>I try to phrase the result narrowly.</p>

<p>Not:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>the file passed security scanning
</code></pre></div></div>

<p>And definitely not:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>the file is malware-free
</code></pre></div></div>

<p>A more defensible description is:</p>

<blockquote>
  <p>The download followed the expected network path, matched the configured torrent-file policy, survived queue enforcement, was imported by the authoritative Arr application, and did not produce a finding in the configured structural media check.</p>
</blockquote>

<p>That sentence is less exciting.</p>

<p>It is also much closer to what the system actually knows.</p>

<p>Defense in depth works best when every layer is allowed to make only the claim it can support.</p>

<hr />

<h2 id="the-larger-lesson">The larger lesson</h2>

<p>The pattern here is not specific to media automation.</p>

<p>The same structure works anywhere an automated pipeline consumes untrusted input:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>contain the network path
        |
        v
reject obviously invalid input early
        |
        v
let the workflow owner make state transitions
        |
        v
validate with reduced privileges
        |
        v
record what actually happened
</code></pre></div></div>

<p>The last line is where Part 3 begins.</p>

<p>At this point the stack has useful boundaries, but a boundary that silently stops working is not much of a boundary.</p>

<p>The final part of this series will turn the assumptions in Parts 1 and 2 into explicit health invariants: VPN state, forwarded-port state, authenticated management endpoints, queue behavior, read-only mounts, and stateful DOWN/RECOVERED monitoring.</p>

<p>The goal is to stop asking whether all the containers are merely “up” and start asking whether the system is still behaving the way it was designed to behave.</p>

<hr />

<h2 id="further-reading">Further reading</h2>

<ul>
  <li><a href="https://github.com/qbittorrent/qBittorrent/issues/24235">qBittorrent WebUI exclusion regression</a></li>
  <li><a href="https://github.com/qbittorrent/qBittorrent">qBittorrent</a></li>
  <li><a href="https://cleanuparr.github.io/docs/">Cleanuparr documentation</a></li>
  <li><a href="https://docs.tdarr.io/">Tdarr documentation</a></li>
</ul>]]></content><author><name></name></author><category term="docker" /><category term="arr" /><category term="security" /><category term="qbittorrent" /><category term="tdarr" /><summary type="html"><![CDATA[Part 1 was mostly about ownership.]]></summary></entry><entry><title type="html">Building an Arr Stack: What Each Service Should Own</title><link href="https://fungijr.github.io/2026/08/26/building-an-arr-stack/" rel="alternate" type="text/html" title="Building an Arr Stack: What Each Service Should Own" /><published>2026-08-26T00:00:00+00:00</published><updated>2026-08-26T00:00:00+00:00</updated><id>https://fungijr.github.io/2026/08/26/building-an-arr-stack</id><content type="html" xml:base="https://fungijr.github.io/2026/08/26/building-an-arr-stack/"><![CDATA[<p>An Arr stack is easy to assemble and surprisingly easy to make opaque. Every container can be healthy, every Web UI can load, and the overall system can still be difficult to reason about because the real workflow lives in the handoffs between services.</p>

<p>I ended up revisiting mine after a VPN and port-forwarding problem forced me to trace the path end to end. That was useful for a different reason: it exposed which services actually needed to share a failure domain, which ones only needed to talk to each other, and where I had let convenience blur the boundaries.</p>

<p>This is the first of three posts. It starts with the boring part that saves the most time later: deciding what each service owns, making the storage layout support hardlinks, and keeping the VPN boundary as small as it needs to be.</p>

<p>The examples are intentionally generic. There are no real addresses, hostnames, credentials, account details, media titles, or deployment-specific paths here.</p>

<h2 id="the-stack-is-a-pipeline-not-one-application">The stack is a pipeline, not one application</h2>

<p>The basic download path is short:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>request / monitored item
        |
        v
 Sonarr or Radarr
        |
        v
      Jackett
        |
        v
   qBittorrent
        |
        v
 Sonarr/Radarr import
        |
        v
   media library
        |
        +----&gt; Jellyfin
        |
        `----&gt; Tdarr health check
</code></pre></div></div>

<p>The supporting services sit around that path rather than replacing it:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Recyclarr   -&gt; configuration policy for Sonarr/Radarr
Cleanuparr  -&gt; queue cleanup and blocked-download handling
Gluetun     -&gt; VPN egress and firewall for selected services
FlareSolverr -&gt; browser-challenge helper for compatible indexers
</code></pre></div></div>

<p>That distinction matters. The easiest way I found to reason about the stack was to stop thinking in terms of “the Arr containers” and instead ask one question for each service:</p>

<blockquote>
  <p>What state is this application authoritative for?</p>
</blockquote>

<p>Once that answer is clear, most of the architecture follows naturally.</p>

<h2 id="give-each-service-one-job">Give each service one job</h2>

<h3 id="sonarr-and-radarr-own-media-state">Sonarr and Radarr own media state</h3>

<p>Sonarr is the authority for television and Radarr is the authority for movies.</p>

<p>They decide what is monitored, which quality policy applies, whether a release is acceptable, which download client receives it, and where an imported file belongs in the final library.</p>

<p>qBittorrent should not independently reorganize completed media into the library. It downloads. Sonarr and Radarr import.</p>

<p>That gives a clean boundary:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>qBittorrent:
  peer connections
  torrent state
  transfer state
  download paths

Sonarr / Radarr:
  media identity
  release policy
  naming
  import state
  final library placement
</code></pre></div></div>

<p>Keeping that boundary intact also makes failures easier to understand. If a torrent completes but never appears in the library, the question is no longer “which container moved it?” The download client completed a transfer; the Arr application either imported it or recorded why it could not.</p>

<h3 id="jackett-owns-indexer-translation">Jackett owns indexer translation</h3>

<p>Jackett sits between Sonarr/Radarr and the configured indexers.</p>

<p>Its purpose here is compatibility. Sonarr and Radarr speak to Jackett through a normalized interface, and Jackett deals with the indexer-specific side.</p>

<p>That means an indexer failure is not automatically a Sonarr or Radarr problem. The failure may be in one indexer, Jackett itself, DNS, browser-challenge handling, or the network path Jackett uses.</p>

<p>FlareSolverr belongs next to Jackett for the same reason. It is a narrow helper for sites that require supported browser-challenge handling. It is not a media manager and it does not need a public-facing UI.</p>

<h3 id="qbittorrent-owns-transfer-state">qBittorrent owns transfer state</h3>

<p>qBittorrent is the transfer engine.</p>

<p>It accepts work from the Arr applications, connects to peers, downloads and seeds data, reports progress, and maintains the category/download paths the rest of the stack expects.</p>

<p>Later in the series I use qBittorrent’s filename exclusion feature as one small security control. That control needs to be treated as a version- and workflow-specific policy gate, not as antivirus and not as proof that a download is safe.</p>

<h3 id="recyclarr-owns-configuration-policy">Recyclarr owns configuration policy</h3>

<p>Recyclarr is useful because quality definitions, profiles, custom formats, scores, and naming policy are configuration that can drift.</p>

<p>It synchronizes the desired policy into Sonarr and Radarr. It does not download anything and it should not be treated as proof that every existing movie or series is assigned to the profile you intended.</p>

<p>Those are separate checks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Does the desired profile exist?
        !=
Is this item actually assigned to it?
</code></pre></div></div>

<h3 id="cleanuparr-owns-queue-supervision">Cleanuparr owns queue supervision</h3>

<p>Cleanuparr watches the workflow for conditions that need cleanup or intervention.</p>

<p>In this design it is useful for things such as failed imports, blocked downloads, stalled or otherwise unusable queue entries, and replacement searches after a bad download is removed.</p>

<p>It is an automation and enforcement layer. It is not a malware scanner.</p>

<h3 id="tdarr-is-a-read-only-post-import-checker">Tdarr is a read-only post-import checker</h3>

<p>Tdarr is commonly used for transcoding, but I am deliberately using only a small part of it here: media health checking after import.</p>

<p>The media library is mounted read-only. Scratch space can be writable, but the checker does not need permission to rewrite the authoritative library.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>media library
     |
     | read-only
     v
   Tdarr
     |
     `--&gt; structural media health check
</code></pre></div></div>

<p>This is not a quarantine system. A successful media health check tells me something about the structure of the media file; it does not prove that the file is malware-free.</p>

<h3 id="jellyfin-consumes-the-result">Jellyfin consumes the result</h3>

<p>Jellyfin is at the far end of the pipeline. It reads the organized library and handles playback, clients, metadata and transcoding where required.</p>

<p>It has no reason to participate in torrent acquisition.</p>

<p>That separation is useful operationally and from a permissions standpoint: the media server should not need credentials or network access that only the acquisition side uses.</p>

<h2 id="fix-the-storage-model-before-adding-automation">Fix the storage model before adding automation</h2>

<p>Container networking gets a lot of attention in these stacks, but the path layout causes just as many avoidable problems.</p>

<p>The goal is for the downloader and importers to see one consistent filesystem tree:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/data
├── torrents
│   ├── incomplete
│   ├── tv
│   └── movies
└── media
    ├── tv
    └── movies
</code></pre></div></div>

<p>qBittorrent writes under <code class="language-plaintext highlighter-rouge">/data/torrents</code>. Sonarr and Radarr see those same paths and also see their destinations below <code class="language-plaintext highlighter-rouge">/data/media</code>.</p>

<p>That consistency matters because it lets the applications use the filesystem directly instead of translating unrelated container paths with Remote Path Mappings.</p>

<p>It also makes hardlinks possible when the download and media directories are on the same filesystem.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/data/torrents/tv/example.mkv
             |
             | hardlink
             v
/data/media/tv/Series/example.mkv
</code></pre></div></div>

<p>Both directory entries can refer to the same underlying file data. The torrent can continue seeding from the download path while the library gets its organized filename immediately, without storing a second full copy.</p>

<p>If the source and destination are on different filesystems, that does not work. I would verify this before configuring the applications rather than discover it after a large import starts copying data.</p>

<h2 id="build-it-in-an-order-that-leaves-useful-evidence">Build it in an order that leaves useful evidence</h2>

<p>Bringing up every container at once makes the first failure unnecessarily hard to isolate. A better order is to prove one boundary at a time.</p>

<h3 id="1-storage-first">1. Storage first</h3>

<p>Decide where incomplete downloads, completed downloads and final media live.</p>

<p>Verify that the container-visible paths are consistent and that the paths intended for hardlinks are on the same filesystem.</p>

<h3 id="2-gluetun-by-itself">2. Gluetun by itself</h3>

<p>Before attaching another service, verify the VPN tunnel, firewall behavior, intended IPv4/IPv6 policy and provider-side port forwarding if it is part of the design.</p>

<p>Do not treat “container is running” as proof that the network path is correct.</p>

<h3 id="3-qbittorrent">3. qBittorrent</h3>

<p>Attach qBittorrent to the VPN namespace and prove that it fails closed.</p>

<p>Then verify the actual torrent settings: listening interface, listening port, random-port behavior, UPnP policy and category paths.</p>

<h3 id="4-sonarr-and-radarr">4. Sonarr and Radarr</h3>

<p>Add the importers only after the download path works.</p>

<p>Configure one known-good download and import before adding more automation. If that path is not solid, Recyclarr and cleanup rules only add more variables.</p>

<h3 id="5-jackett-and-flaresolverr">5. Jackett and FlareSolverr</h3>

<p>Add indexers gradually. Test them independently.</p>

<p>“Jackett is running” does not mean every configured indexer works.</p>

<h3 id="6-recyclarr">6. Recyclarr</h3>

<p>Introduce policy synchronization after Sonarr and Radarr are stable. Use preview/dry-run behavior where available and inspect what it intends to change.</p>

<h3 id="7-cleanuparr">7. Cleanuparr</h3>

<p>Start with observation and narrow rules. Cleanup automation should not be the thing hiding a broken normal download/import path.</p>

<h3 id="8-tdarr">8. Tdarr</h3>

<p>Add read-only health checking after the library path is proven. Transcoding can be a separate project; it does not need to be mixed into the first validation pass.</p>

<h2 id="the-vpn-boundary-should-be-smaller-than-the-project-boundary">The VPN boundary should be smaller than the project boundary</h2>

<p>This was the architectural change that made the stack much easier to troubleshoot.</p>

<p>A common Compose layout puts every application behind Gluetun because it is convenient:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>
</code></pre></div></div>

<p>That is exactly what I want for qBittorrent. If the VPN disappears, qBittorrent should not quietly fall back to the host’s normal route.</p>

<p>It is not what I want for the entire control plane.</p>

<p>Sonarr and Radarr do not inherently need their own Internet traffic to share qBittorrent’s network namespace. Recyclarr and Cleanuparr do not need to disappear just because the VPN is unhealthy. Tdarr has nothing to do with acquisition egress at all.</p>

<p>The split I settled on is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>normal Compose network
├── gluetun
├── sonarr
├── radarr
├── recyclarr
└── cleanuparr

share Gluetun network namespace
├── qbittorrent
├── jackett
└── flaresolverr

separate bridge
└── tdarr
</code></pre></div></div>

<p>qBittorrent, Jackett and FlareSolverr are intentionally coupled to the VPN.</p>

<p>The control-plane applications are not.</p>

<p>Because Sonarr and Radarr share a Docker network with Gluetun, Docker service discovery and Gluetun’s shared-network model still let them reach services inside the Gluetun namespace through the Gluetun service name and the appropriate application port:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sonarr/radarr
     |
     +----&gt; gluetun:&lt;qbit-port&gt;
     |
     `----&gt; gluetun:&lt;jackett-port&gt;
</code></pre></div></div>

<p>That gives the failure mode I actually want:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VPN failure
 |
 +--&gt; qBittorrent loses Internet access
 +--&gt; Jackett loses Internet access
 +--&gt; FlareSolverr loses Internet access
 |
 +--&gt; Sonarr remains reachable
 +--&gt; Radarr remains reachable
 +--&gt; Recyclarr remains reachable
 +--&gt; Cleanuparr remains reachable
 `--&gt; Tdarr remains reachable
</code></pre></div></div>

<p>The VPN can fail without taking the tools needed to diagnose the failure down with it.</p>

<h2 id="do-not-publish-every-ui-just-because-docker-makes-it-easy">Do not publish every UI just because Docker makes it easy</h2>

<p>This is convenient:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">ports</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">8080:8080"</span>
</code></pre></div></div>

<p>but Docker interprets a short port mapping without a host address as a publication on all host interfaces.</p>

<p>For management interfaces I prefer an explicit trusted binding:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">ports</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">${MGMT_IP}:8080:8080"</span>
</code></pre></div></div>

<p>or no host publication at all when another controlled access path already exists.</p>

<p>Helper services that do not need direct user access should remain internal.</p>

<p>The same principle applies to authentication. “It is only on my LAN” is not a useful reason to leave an administrative interface unauthenticated.</p>

<h2 id="keep-secrets-out-of-the-compose-file-you-publish">Keep secrets out of the Compose file you publish</h2>

<p>Public examples should contain placeholders, not sanitized-looking real credentials.</p>

<p>For Gluetun specifically, Docker secret files are supported for sensitive values such as OpenVPN credentials. That is preferable to teaching readers to paste real passwords directly into a Compose file.</p>

<p>At a minimum, the public version of a stack should never contain:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VPN credentials
Arr API keys
Jackett API keys
qBittorrent passwords or cookies
webhook URLs
authentication databases
resolved Compose output containing secrets
</code></pre></div></div>

<p>The same rule applies to screenshots and diagnostic output. A screenshot can undo all the work that went into sanitizing the article around it.</p>

<h2 id="where-this-leaves-the-stack">Where this leaves the stack</h2>

<p>At this point each service has a clear owner, storage paths are designed for the import behavior we want, and the VPN contains only the applications that actually need to fail with it.</p>

<p>That is enough architecture for one post.</p>

<p>Part 2 will deal with the less comfortable side of a media automation stack: untrusted download metadata and payloads, filename exclusions, queue enforcement, safe canaries, and read-only post-import validation.</p>

<p>Part 3 will turn the assumptions from the first two posts into health invariants and stateful monitoring.</p>

<h2 id="further-reading">Further reading</h2>

<ul>
  <li>Docker Compose networking: https://docs.docker.com/compose/how-tos/networking/</li>
  <li>Docker Compose service/network settings: https://docs.docker.com/reference/compose-file/services/</li>
  <li>Gluetun inter-container networking: https://github.com/qdm12/gluetun-wiki/blob/main/setup/inter-containers-networking.md</li>
  <li>Gluetun firewall behavior: https://github.com/qdm12/gluetun-wiki/blob/main/faq/firewall.md</li>
  <li>Gluetun Docker secrets: https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/docker-secrets.md</li>
  <li>Recyclarr features: https://recyclarr.dev/guide/features/</li>
  <li>Cleanuparr features: https://cleanuparr.github.io/docs/features/</li>
  <li>Tdarr health checking: https://docs.tdarr.io/docs/library-setup/healthcheck/</li>
</ul>]]></content><author><name></name></author><category term="docker" /><category term="arr" /><category term="networking" /><category term="security" /><category term="homelab" /><summary type="html"><![CDATA[An Arr stack is easy to assemble and surprisingly easy to make opaque. Every container can be healthy, every Web UI can load, and the overall system can still be difficult to reason about because the real workflow lives in the handoffs between services.]]></summary></entry><entry><title type="html">How to Create a Free Blog on GitHub (CLI-Based)</title><link href="https://fungijr.github.io/2026/08/25/create-free-blog-github-cli/" rel="alternate" type="text/html" title="How to Create a Free Blog on GitHub (CLI-Based)" /><published>2026-08-25T00:00:00+00:00</published><updated>2026-08-25T00:00:00+00:00</updated><id>https://fungijr.github.io/2026/08/25/create-free-blog-github-cli</id><content type="html" xml:base="https://fungijr.github.io/2026/08/25/create-free-blog-github-cli/"><![CDATA[<p>I wanted a blog that was boring to operate: Markdown in a Git repository, a push to publish, and no web server for me to maintain.</p>

<p>GitHub Pages is enough for that.</p>

<p>This walkthrough starts with the smallest possible site and verifies that GitHub can actually serve it before adding Jekyll, layouts, CSS, or anything else. That makes troubleshooting a lot easier than building the whole site first and wondering which part broke.</p>

<p>The commands use GitHub CLI because I wanted to do the setup from the terminal. <code class="language-plaintext highlighter-rouge">gh</code> is not a requirement for GitHub Pages. If you would rather create the repository and configure Pages in the GitHub web interface, that works too.</p>

<p>All usernames, paths, and other identifying values below are examples. Replace them with your own.</p>

<h2 id="what-you-need">What You Need</h2>

<p>At minimum:</p>

<ul>
  <li>a GitHub account</li>
  <li>Git</li>
  <li>a text editor</li>
</ul>

<p>For the CLI-only version:</p>

<ul>
  <li>GitHub CLI (<code class="language-plaintext highlighter-rouge">gh</code>)</li>
</ul>

<p>Check Git:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">--version</span>
</code></pre></div></div>

<p>Check GitHub CLI:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh <span class="nt">--version</span>
</code></pre></div></div>

<p>On Gentoo, GitHub CLI is available as:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>doas emerge <span class="nt">--ask</span> github-cli
</code></pre></div></div>

<p>If you are not using <code class="language-plaintext highlighter-rouge">gh</code>, create the repository through GitHub’s web interface and skip the CLI-specific commands below.</p>

<h2 id="authenticate-github-cli">Authenticate GitHub CLI</h2>

<p>Start the login:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh auth login
</code></pre></div></div>

<p>For GitHub.com, the browser-based login is the easiest option on a normal workstation.</p>

<p>Afterward:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh auth status
</code></pre></div></div>

<p>You should see the account you intended to use and no authentication errors.</p>

<p>One security rule worth establishing immediately: do not put access tokens into blog screenshots, shell examples, or Markdown files. There is almost never a reason to run a command that prints your GitHub token while writing a tutorial.</p>

<h2 id="get-the-username-from-github">Get the Username from GitHub</h2>

<p>Rather than typing the account name into every command, get it from the authenticated session:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GHUSER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>gh api user <span class="nt">--jq</span> <span class="s1">'.login'</span><span class="si">)</span><span class="s2">"</span>

<span class="nb">printf</span> <span class="s1">'GitHub username: %s\n'</span> <span class="s2">"</span><span class="nv">$GHUSER</span><span class="s2">"</span>
</code></pre></div></div>

<p>For a personal GitHub Pages site, the repository name is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>YOUR-GITHUB-USERNAME.github.io
</code></pre></div></div>

<p>Using <code class="language-plaintext highlighter-rouge">$GHUSER</code> also avoids accidentally creating the repository with a typo.</p>

<h2 id="create-the-repository">Create the Repository</h2>

<p>I keep blog repositories under <code class="language-plaintext highlighter-rouge">~/blog/git</code>, but the local path does not matter to GitHub.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> ~/blog/git
<span class="nb">cd</span> ~/blog/git
</code></pre></div></div>

<p>Create the repository:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GHUSER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>gh api user <span class="nt">--jq</span> <span class="s1">'.login'</span><span class="si">)</span><span class="s2">"</span>

gh repo create <span class="s2">"</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io"</span> <span class="se">\</span>
    <span class="nt">--public</span> <span class="se">\</span>
    <span class="nt">--add-readme</span> <span class="se">\</span>
    <span class="nt">--description</span> <span class="s2">"Personal blog"</span> <span class="se">\</span>
    <span class="nt">--clone</span>
</code></pre></div></div>

<p>Then enter it:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> <span class="s2">"</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--add-readme</code> is useful here for more than convenience. It gives the new repository actual content immediately, which is enough for the first Pages smoke test.</p>

<h2 id="verify-the-repository-before-doing-anything-else">Verify the Repository Before Doing Anything Else</h2>

<p>Check the local side:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">--no-pager</span> status
git remote <span class="nt">-v</span>
git branch <span class="nt">--show-current</span>
</code></pre></div></div>

<p>A fresh repository should be on its default branch with a clean working tree.</p>

<p>Now check GitHub’s view of it:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh repo view <span class="se">\</span>
  <span class="nt">--json</span> nameWithOwner,visibility,url,defaultBranchRef
</code></pre></div></div>

<p>Things I care about at this point:</p>

<ul>
  <li>correct owner</li>
  <li>repository named <code class="language-plaintext highlighter-rouge">YOUR-GITHUB-USERNAME.github.io</code></li>
  <li>public visibility when using GitHub Free</li>
  <li>expected default branch</li>
  <li>correct <code class="language-plaintext highlighter-rouge">origin</code> remote</li>
</ul>

<p>If any of those are wrong, fix them now. There is no reason to start building a site on top of a bad repository setup.</p>

<h2 id="check-whether-pages-is-already-enabled">Check Whether Pages Is Already Enabled</h2>

<p>This surprised me during setup: by the time I checked the new user-site repository, Pages was already configured and built.</p>

<p>You can query it directly:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GHUSER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>gh api user <span class="nt">--jq</span> <span class="s1">'.login'</span><span class="si">)</span><span class="s2">"</span>

gh api <span class="se">\</span>
  <span class="s2">"repos/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/pages"</span> <span class="se">\</span>
  <span class="nt">--jq</span> <span class="s1">'{
    status: .status,
    url: .html_url,
    source: .source,
    https_enforced: .https_enforced
  }'</span>
</code></pre></div></div>

<p>A working configuration may look roughly like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  "https_enforced": true,
  "source": {
    "branch": "main",
    "path": "/"
  },
  "status": "built",
  "url": "https://YOUR-GITHUB-USERNAME.github.io/"
}
</code></pre></div></div>

<p>If you get valid Pages configuration, leave it alone.</p>

<p>If the API instead returns HTTP 404 because Pages has not been configured, it can be enabled from the CLI:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GHUSER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>gh api user <span class="nt">--jq</span> <span class="s1">'.login'</span><span class="si">)</span><span class="s2">"</span>

gh api <span class="se">\</span>
  <span class="nt">--method</span> POST <span class="se">\</span>
  <span class="s2">"repos/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/pages"</span> <span class="se">\</span>
  <span class="nt">--input</span> - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">JSON</span><span class="sh">'
{
  "source": {
    "branch": "main",
    "path": "/"
  }
}
</span><span class="no">JSON
</span></code></pre></div></div>

<p>If your default branch is not <code class="language-plaintext highlighter-rouge">main</code>, use the actual branch name rather than copying that value blindly.</p>

<p>Then query <code class="language-plaintext highlighter-rouge">/pages</code> again and make sure the configuration is what you expected.</p>

<h2 id="first-checkpoint-does-github-serve-anything">First Checkpoint: Does GitHub Serve Anything?</h2>

<p>Do this before adding Jekyll.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-I</span> <span class="s2">"https://</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/"</span>
</code></pre></div></div>

<p>Or, if all you care about is the result:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsS</span> <span class="nt">-o</span> /dev/null <span class="se">\</span>
  <span class="nt">-w</span> <span class="s1">'Homepage: %{http_code}\n'</span> <span class="se">\</span>
  <span class="s2">"https://</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/"</span>
</code></pre></div></div>

<p>The useful result is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Homepage: 200
</code></pre></div></div>

<p>At this point the important part is already proven:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>local Git repository
        |
        v
GitHub repository
        |
        v
GitHub Pages
        |
        v
HTTPS request returns 200
</code></pre></div></div>

<p>That is the publishing path.</p>

<p>Everything after this is site structure and design.</p>

<h2 id="turn-the-bare-site-into-a-blog">Turn the Bare Site Into a Blog</h2>

<p>Now add the minimum Jekyll structure.</p>

<p>Create a posts directory:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> _posts
<span class="nb">mkdir</span> <span class="nt">-p</span> _layouts
</code></pre></div></div>

<p>Create <code class="language-plaintext highlighter-rouge">_config.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">title</span><span class="pi">:</span> <span class="s">YOUR-BLOG-NAME</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Notes on Unix, infrastructure, security, networking, and whatever else breaks.</span>
<span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://YOUR-GITHUB-USERNAME.github.io"</span>
<span class="na">baseurl</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>

<span class="na">markdown</span><span class="pi">:</span> <span class="s">kramdown</span>
<span class="na">permalink</span><span class="pi">:</span> <span class="s">/:year/:month/:day/:title/</span>
</code></pre></div></div>

<p>Do not put secrets in this file. It is part of a public site repository.</p>

<h2 id="add-a-minimal-layout">Add a Minimal Layout</h2>

<p>Create <code class="language-plaintext highlighter-rouge">_layouts/default.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!doctype html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">lang=</span><span class="s">"en"</span><span class="nt">&gt;</span>
<span class="nt">&lt;head&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"utf-8"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"viewport"</span> <span class="na">content=</span><span class="s">"width=device-width, initial-scale=1"</span><span class="nt">&gt;</span>

  <span class="nt">&lt;title&gt;</span>
    How to Create a Free Blog on GitHub (CLI-Based) | Unix Magick
  <span class="nt">&lt;/title&gt;</span>
<span class="nt">&lt;/head&gt;</span>

<span class="nt">&lt;body&gt;</span>
  <span class="nt">&lt;main&gt;</span>
    
  <span class="nt">&lt;/main&gt;</span>
<span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>There is intentionally no theme or CSS here.</p>

<p>The goal is still to prove the content pipeline before working on appearance.</p>

<h2 id="add-the-homepage">Add the Homepage</h2>

<p>Create <code class="language-plaintext highlighter-rouge">index.md</code>:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">layout</span><span class="pi">:</span> <span class="s">default</span>
<span class="na">title</span><span class="pi">:</span> <span class="s">YOUR-BLOG-NAME</span>
<span class="nn">---</span>

<span class="gh"># YOUR-BLOG-NAME</span>

A place for technical notes, troubleshooting, and project write-ups.

<span class="gu">## Posts</span><span class="sb">


</span><span class="p">-</span> <span class="p">[</span><span class="nv">Build a Hardened Arr Stack with Docker Compose: Gluetun, Hardlinks, and Health Checks</span><span class="p">](</span><span class="sx">/2026/09/01/build-a-hardened-arr-stack/</span><span class="p">)</span> — 2026-09-01
<span class="p">
-</span> <span class="p">[</span><span class="nv">Operationalizing an Arr Stack: Health Invariants and Stateful Alerting</span><span class="p">](</span><span class="sx">/2026/08/28/operationalizing-an-arr-stack/</span><span class="p">)</span> — 2026-08-28
<span class="p">
-</span> <span class="p">[</span><span class="nv">Hardening an Arr Stack: Defense in Depth from Torrent to Library</span><span class="p">](</span><span class="sx">/2026/08/27/hardening-an-arr-stack/</span><span class="p">)</span> — 2026-08-27
<span class="p">
-</span> <span class="p">[</span><span class="nv">Building an Arr Stack: What Each Service Should Own</span><span class="p">](</span><span class="sx">/2026/08/26/building-an-arr-stack/</span><span class="p">)</span> — 2026-08-26
<span class="p">
-</span> <span class="p">[</span><span class="nv">How to Create a Free Blog on GitHub (CLI-Based)</span><span class="p">](</span><span class="sx">/2026/08/25/create-free-blog-github-cli/</span><span class="p">)</span> — 2026-08-25

</code></pre></div></div>

<p>That loop means new posts automatically appear on the homepage.</p>

<h2 id="add-the-first-post">Add the First Post</h2>

<p>Jekyll posts use a date-prefixed filename:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_posts/YYYY-MM-DD-post-name.md
</code></pre></div></div>

<p>For a simple test:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">layout</span><span class="pi">:</span> <span class="s">default</span>
<span class="na">title</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Site</span><span class="nv"> </span><span class="s">Online"</span>
<span class="na">date</span><span class="pi">:</span> <span class="s">2026-01-01</span>
<span class="nn">---</span>

The site is online.
</code></pre></div></div>

<p>Use the real publication date when you create your own file.</p>

<p>At this stage the site is ugly, which is fine. Ugly and working is much easier to improve than attractive and broken.</p>

<h2 id="review-the-repository-before-the-first-push">Review the Repository Before the First Push</h2>

<p>This is the part I would not skip on a technical blog.</p>

<p>Start with:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">--no-pager</span> status <span class="nt">--short</span>
</code></pre></div></div>

<p>Then stage only the files you meant to add:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git add <span class="se">\</span>
  _config.yml <span class="se">\</span>
  _layouts/default.html <span class="se">\</span>
  index.md <span class="se">\</span>
  _posts/
</code></pre></div></div>

<p>Review the staged diff:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">--no-pager</span> diff <span class="nt">--cached</span>
</code></pre></div></div>

<p>Actually read it.</p>

<p>For a public technical blog, I specifically look for:</p>

<ul>
  <li>usernames that were supposed to be anonymized</li>
  <li>email addresses</li>
  <li>internal hostnames</li>
  <li>internal IP addresses</li>
  <li>tokens or API keys</li>
  <li>terminal prompts containing identifying information</li>
  <li>paths containing names that do not need to be public</li>
  <li>screenshots that reveal more than the text around them</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">.gitignore</code> is useful, but it is not a substitute for reviewing what you are about to publish.</p>

<p>If a secret was already committed, adding it to <code class="language-plaintext highlighter-rouge">.gitignore</code> afterward does not remove it from Git history.</p>

<h2 id="commit-and-push">Commit and Push</h2>

<p>Once the staged diff is clean:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git commit <span class="nt">-m</span> <span class="s2">"Create initial blog"</span>
git push
</code></pre></div></div>

<p>Now GitHub has the Jekyll source.</p>

<h2 id="verify-the-pages-build">Verify the Pages Build</h2>

<p>Check the latest build:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GHUSER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>gh api user <span class="nt">--jq</span> <span class="s1">'.login'</span><span class="si">)</span><span class="s2">"</span>

gh api <span class="se">\</span>
  <span class="s2">"repos/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/pages/builds/latest"</span> <span class="se">\</span>
  <span class="nt">--jq</span> <span class="s1">'{
    status: .status,
    error: .error.message,
    commit: .commit,
    created_at: .created_at,
    updated_at: .updated_at
  }'</span>
</code></pre></div></div>

<p>You are looking for a successful build and no error message.</p>

<p>Then test the homepage again:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsS</span> <span class="nt">-o</span> /dev/null <span class="se">\</span>
  <span class="nt">-w</span> <span class="s1">'Homepage: %{http_code}\n'</span> <span class="se">\</span>
  <span class="s2">"https://</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/"</span>
</code></pre></div></div>

<p>And test the first post using its actual permalink:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsS</span> <span class="nt">-o</span> /dev/null <span class="se">\</span>
  <span class="nt">-w</span> <span class="s1">'Post: %{http_code}\n'</span> <span class="se">\</span>
  <span class="s2">"https://</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/YYYY/MM/DD/POST-NAME/"</span>
</code></pre></div></div>

<p>Both should return:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>200
</code></pre></div></div>

<p>Now there are two verified paths:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Markdown -&gt; Jekyll -&gt; homepage -&gt; 200

Markdown -&gt; Jekyll -&gt; post     -&gt; 200
</code></pre></div></div>

<p>That is enough infrastructure for a real blog.</p>

<h2 id="what-i-would-not-do-yet">What I Would Not Do Yet</h2>

<p>At this point I would stop.</p>

<p>No custom domain.</p>

<p>No analytics.</p>

<p>No third-party JavaScript.</p>

<p>No theme dependency.</p>

<p>No giant pile of plugins.</p>

<p>No redesign directly on the production branch.</p>

<p>Get the basic publishing path working first. Once that is boring and repeatable, make a separate branch for layout and design work and test it locally before merging it into production.</p>

<p>That is exactly where the interesting part starts, but it should not be mixed into the initial bring-up.</p>

<h2 id="a-few-commands-worth-keeping-around">A Few Commands Worth Keeping Around</h2>

<p>Repository state:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">--no-pager</span> status <span class="nt">--short</span>
</code></pre></div></div>

<p>Review staged changes:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git <span class="nt">--no-pager</span> diff <span class="nt">--cached</span>
</code></pre></div></div>

<p>GitHub authentication:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh auth status
</code></pre></div></div>

<p>Pages state:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh api <span class="se">\</span>
  <span class="s2">"repos/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/pages"</span>
</code></pre></div></div>

<p>Latest Pages build:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gh api <span class="se">\</span>
  <span class="s2">"repos/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/pages/builds/latest"</span>
</code></pre></div></div>

<p>Public smoke test:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-fsS</span> <span class="nt">-o</span> /dev/null <span class="se">\</span>
  <span class="nt">-w</span> <span class="s1">'%{http_code}\n'</span> <span class="se">\</span>
  <span class="s2">"https://</span><span class="k">${</span><span class="nv">GHUSER</span><span class="k">}</span><span class="s2">.github.io/"</span>
</code></pre></div></div>

<p>That is most of the operational surface area for a small static blog.</p>

<p>The rest is writing.</p>]]></content><author><name></name></author><category term="github" /><category term="github-pages" /><category term="cli" /><category term="jekyll" /><category term="security" /><summary type="html"><![CDATA[I wanted a blog that was boring to operate: Markdown in a Git repository, a push to publish, and no web server for me to maintain.]]></summary></entry></feed>