| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

Appliance Recipe: Automated Seedbox on ZFS

A kldload seedbox where every stage of the media pipeline lives on its own ZFS dataset. Per-torrent isolation, instant clones for seeding, compression saves 30%+ on text-heavy media, snapshots protect your library, and zfs send replicates everything to your Plex host automatically.

Why ZFS for a seedbox:

  • Per-torrent datasets — isolate downloads, quota individual torrents
  • Instant clones for seeding — seed from the original, serve from organized copy, zero double storage
  • Compression saves 30%+ on subtitles, NFOs, and text-heavy content
  • Snapshots protect the library — bad rename script? Rollback in seconds
  • Replication to Plex host — syncoid sends only changed blocks over WireGuard
  • Boot environments — upgrade rtorrent or Sonarr fearlessly

The seedbox is a media automation pipeline — RSS feeds trigger downloads, Flexget or Sonarr/Radarr rename and organize, rtorrent seeds to ratio, and syncoid replicates the final library to your Plex server over WireGuard. Every stage has its own ZFS dataset. The landing zone (active downloads) has short-retention hourly snapshots — if a download corrupts, roll back. The media library (organized content) has daily snapshots with 30-day retention — if a rename script butchers your collection, roll back. The session data (rtorrent state) has its own 16K recordsize tuned for the small random I/O pattern of torrent metadata. The nftables kill switch ensures nothing leaks if WireGuard drops — every packet either goes through the tunnel or gets dropped. No DNS leaks, no fallback to the bare connection. This is the complete pipeline from RSS to Plex, fully automated, fully protected by ZFS at every stage.

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                      kldload Seedbox                                  │
│                                                                         │
│  RSS Feeds ──→ Flexget ──→ rtorrent ──→ Landing Zone ──→ Processing     │
│  (autodl-irssi)            (daemon)     (rpool/landing)   Pipeline      │
│                                                                         │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────────────┐   │
│  │  rtorrent    │    │  ruTorrent   │    │  Processing              │   │
│  │  :6881-6889  │    │  :8080 (web) │    │  unpackarr → filebot    │   │
│  │  SCGI :5000  │    │  nginx proxy │    │  or Sonarr/Radarr       │   │
│  └──────┬───────┘    └──────────────┘    └──────────┬───────────────┘   │
│         │                                           │                   │
│         ▼                                           ▼                   │
│  rpool/landing/              rpool/media/                               │
│  ├── torrent-a/              ├── tv/                                    │
│  ├── torrent-b/              │   ├── Breaking Bad/                      │
│  └── torrent-c/              │   │   └── Season 01/                     │
│  (hourly snaps,              │   │       └── S01E01 - Pilot.mkv         │
│   short retention)           │   └── The Expanse/                       │
│                              ├── movies/                                │
│                              │   ├── Dune (2021)/                       │
│                              │   │   └── Dune (2021).mkv                │
│                              │   └── Alien (1979)/                      │
│                              └── music/                                 │
│                              (daily snaps, long retention)              │
│                                                                         │
│  wg0: 10.200.0.1/24 ── WireGuard to Plex host                          │
│  nftables: VPN kill switch (nothing leaks if WireGuard drops)           │
└─────────────────────────────────────────────────────────────────────────┘
          │
          │ syncoid (every 30 min)
          │ WireGuard tunnel
          ▼
┌──────────────────────────────┐
│  kldload Plex Host         │
│  rpool/media (replica)       │
│  Plex Media Server (:32400)  │
│  Library auto-updates        │
└──────────────────────────────┘

Step 1: Install kldload

cat > /tmp/answers.env << 'EOF'
KLDLOAD_DISTRO=debian
KLDLOAD_DISK=/dev/sda
KLDLOAD_HOSTNAME=seedbox
KLDLOAD_USERNAME=admin
KLDLOAD_PASSWORD=changeme
KLDLOAD_PROFILE=server
KLDLOAD_NET_METHOD=dhcp
EOF
kldload-install-target --config /tmp/answers.env

Step 2: ZFS dataset layout

# Landing zone — where downloads complete before processing
zfs create -o mountpoint=/srv/landing -o compression=lz4 \
    -o recordsize=1M rpool/landing

# Normalized media library
zfs create -o canmount=off -o mountpoint=none rpool/media

# TV — per-show datasets created dynamically
zfs create -o canmount=off -o mountpoint=/srv/media/tv rpool/media/tv

# Movies — per-movie datasets created dynamically
zfs create -o canmount=off -o mountpoint=/srv/media/movies rpool/media/movies

# Music — compression benefits FLAC
zfs create -o mountpoint=/srv/media/music -o compression=lz4 rpool/media/music

# Application state
zfs create -o mountpoint=/opt/rtorrent -o compression=lz4 rpool/apps
zfs create -o mountpoint=/opt/rtorrent/session -o recordsize=16k rpool/apps/session
zfs create -o mountpoint=/opt/flexget -o compression=lz4 rpool/apps/flexget

Step 3: rtorrent + ruTorrent

# Install rtorrent
apt install -y rtorrent screen

# Create rtorrent user
useradd -r -s /bin/bash -d /opt/rtorrent rtorrent

cat > /opt/rtorrent/.rtorrent.rc << 'RTRC'
# ─── Connection ──────────────────────────────────────────────
network.port_range.set = 6881-6889
network.port_random.set = no
protocol.encryption.set = allow_incoming,try_outgoing,enable_retry

# ─── Directories ─────────────────────────────────────────────
directory.default.set = /srv/landing
session.path.set = /opt/rtorrent/session

# ─── Performance ─────────────────────────────────────────────
throttle.global_down.max_rate.set_kb = 0
throttle.global_up.max_rate.set_kb = 0
throttle.max_uploads.set = 100
throttle.max_uploads.global.set = 250
throttle.min_peers.normal.set = 20
throttle.max_peers.normal.set = 60
throttle.min_peers.seed.set = 30
throttle.max_peers.seed.set = 80

# ─── Resource limits ─────────────────────────────────────────
network.max_open_files.set = 65536
network.max_open_sockets.set = 999
pieces.memory.max.set = 2048M
network.xmlrpc.size_limit.set = 4M

# ─── SCGI (for ruTorrent) ────────────────────────────────────
network.scgi.open_port = 127.0.0.1:5000

# ─── Scheduling ──────────────────────────────────────────────
# Watch directory for .torrent files
schedule2 = watch_directory, 5, 5, \
    ((load.start, (cat, "/srv/landing/watch/", "*.torrent")))

# Remove tied torrent file when complete
schedule2 = untied_directory, 5, 5, \
    ((stop_untied))

# Close low-disk situations
schedule2 = low_diskspace, 5, 60, \
    ((close_low_diskspace, 10G))

# ─── Ratio management ────────────────────────────────────────
# Seed to 2.0 ratio, then stop
method.insert = d.get_finished_dir, simple, \
    "cat=/srv/landing/complete/,$d.custom1="
group.seeding.ratio.enable =
group2.seeding.ratio.min.set = 200
group2.seeding.ratio.max.set = 300
group2.seeding.ratio.upload.set = 20M
RTRC

mkdir -p /srv/landing/{watch,complete}
chown -R rtorrent: /opt/rtorrent /srv/landing

# Systemd service
cat > /etc/systemd/system/rtorrent.service << 'EOF'
[Unit]
Description=rtorrent BitTorrent client
After=network.target

[Service]
User=rtorrent
Type=simple
ExecStart=/usr/bin/screen -DmS rtorrent /usr/bin/rtorrent
ExecStop=/usr/bin/killall -w -s 2 rtorrent
Restart=on-failure
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now rtorrent

ruTorrent web UI

# Install nginx + PHP for ruTorrent
apt install -y nginx php-fpm php-cli php-curl php-xml git

# Clone ruTorrent
git clone https://github.com/Novik/ruTorrent.git /var/www/rutorrent
chown -R www-data: /var/www/rutorrent

cat > /etc/nginx/sites-available/rutorrent << 'EOF'
server {
    listen 8080;
    server_name _;
    root /var/www/rutorrent;
    index index.html index.php;

    auth_basic "Seedbox";
    auth_basic_user_file /etc/nginx/.htpasswd;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location /RPC2 {
        scgi_pass 127.0.0.1:5000;
        include scgi_params;
    }
}
EOF

# Create htpasswd
apt install -y apache2-utils
htpasswd -cb /etc/nginx/.htpasswd admin changeme

ln -sf /etc/nginx/sites-available/rutorrent /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
systemctl enable --now nginx php-fpm

Access ruTorrent at: http://seedbox:8080


Step 4: RSS automation with Flexget

apt install -y python3-pip python3-venv
python3 -m venv /opt/flexget/venv
/opt/flexget/venv/bin/pip install flexget

cat > /opt/flexget/config.yml << 'FLEXGET'
schedules:
  - tasks: [tv-rss, movies-rss]
    interval:
      minutes: 15

tasks:
  tv-rss:
    rss:
      url: "https://example.com/tv-feed.rss"
      all_entries: no
    series:
      settings:
        quality: hdtv+ 720p-1080p
        propers: 12 hours
      identified_by: ep
      shows:
        - Breaking Bad
        - The Expanse
        - Severance
        - Silo
    rtorrent:
      uri: scgi://127.0.0.1:5000
      directory: /srv/landing
      custom1: tv

  movies-rss:
    rss:
      url: "https://example.com/movie-feed.rss"
      all_entries: no
    quality: 1080p bluray+
    regexp:
      accept:
        - remux
        - 1080p
      reject:
        - cam
        - ts
        - hdcam
    rtorrent:
      uri: scgi://127.0.0.1:5000
      directory: /srv/landing
      custom1: movies

  clean-landing:
    filesystem:
      path: /srv/landing
      mask: "*.torrent"
    accept_all: yes
    delete:
      along:
        - .torrent
FLEXGET

# Systemd service for Flexget daemon
cat > /etc/systemd/system/flexget.service << 'EOF'
[Unit]
Description=Flexget RSS automation
After=network.target rtorrent.service

[Service]
User=rtorrent
ExecStart=/opt/flexget/venv/bin/flexget daemon start
ExecStop=/opt/flexget/venv/bin/flexget daemon stop
ExecReload=/opt/flexget/venv/bin/flexget daemon reload
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

systemctl enable --now flexget

Step 5: Processing pipeline

# Install filebot for automated renaming and organization
apt install -y openjdk-17-jre mediainfo libchromaprint-tools

# Download FileBot (check https://www.filebot.net for latest)
curl -fsSL "https://get.filebot.net/filebot/FileBot_5.1/FileBot_5.1-portable.tar.xz" | \
    tar -xJf - -C /opt/filebot

# Processing script — runs when a torrent completes
cat > /usr/local/bin/process-download << 'SCRIPT'
#!/bin/bash
# Called by rtorrent on completion
# Args: $1 = torrent name, $2 = base path, $3 = custom1 (tv/movies)

NAME="$1"
PATH_BASE="$2"
TYPE="$3"

LOG="/var/log/seedbox-process.log"

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; }

log "Processing: $NAME (type=$TYPE, path=$PATH_BASE)"

# Snapshot landing zone before processing
zfs snapshot "rpool/landing@before-process-$(date +%s)"

case "$TYPE" in
    tv)
        # FileBot: rename and move to normalized TV structure
        /opt/filebot/filebot.sh -rename "$PATH_BASE/$NAME" \
            --db TheTVDB \
            --format "/srv/media/tv/{n}/Season {s}/{n} - {s00e00} - {t}.{ext}" \
            --action hardlink \
            -non-strict \
            >> "$LOG" 2>&1

        # Create ZFS dataset for new shows if needed
        for show_dir in /srv/media/tv/*/; do
            show_name=$(basename "$show_dir")
            slug=$(echo "$show_name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
            if ! zfs list "rpool/media/tv/$slug" >/dev/null 2>&1; then
                zfs create -o mountpoint="/srv/media/tv/$show_name" \
                    -o compression=off -o recordsize=1M "rpool/media/tv/$slug"
                log "Created dataset: rpool/media/tv/$slug"
            fi
        done
        ;;
    movies)
        # FileBot: rename and move to normalized movie structure
        /opt/filebot/filebot.sh -rename "$PATH_BASE/$NAME" \
            --db TheMovieDB \
            --format "/srv/media/movies/{n} ({y})/{n} ({y}).{ext}" \
            --action hardlink \
            -non-strict \
            >> "$LOG" 2>&1
        ;;
    *)
        log "Unknown type: $TYPE — leaving in landing zone"
        ;;
esac

log "Completed: $NAME"
SCRIPT
chmod +x /usr/local/bin/process-download

Add the completion hook to rtorrent:

# Append to .rtorrent.rc
cat >> /opt/rtorrent/.rtorrent.rc << 'EOF'

# Call processing script on torrent completion
method.set_key = event.download.finished, process_complete, \
    "execute2={/usr/local/bin/process-download,$d.name=,$d.base_path=,$d.custom1=}"
EOF

# Restart rtorrent to pick up changes
systemctl restart rtorrent

Normalized library structure

rpool/media/tv/
├── Breaking Bad/
│   ├── Season 01/
│   │   ├── Breaking Bad - S01E01 - Pilot.mkv
│   │   ├── Breaking Bad - S01E02 - Cat's in the Bag.mkv
│   │   └── ...
│   └── Season 02/
│       └── ...
├── The Expanse/
│   └── Season 01/
│       └── ...
└── Severance/
    └── Season 01/
        └── ...

rpool/media/movies/
├── Dune (2021)/
│   └── Dune (2021).mkv
├── Alien (1979)/
│   └── Alien (1979).mkv
└── Blade Runner 2049 (2017)/
    └── Blade Runner 2049 (2017).mkv

Hardlinks mean the file exists in both the landing zone (for seeding) and the organized library (for Plex) without using double the disk space. ZFS clones achieve the same thing at the dataset level.


Step 6: ZFS replication to Plex host

# WireGuard tunnel to Plex host (see Firewall & Gateway recipe for details)
cat > /etc/wireguard/wg0.conf << EOF
[Interface]
Address = 10.200.0.1/24
PrivateKey = $(cat /etc/wireguard/private.key)

[Peer]
PublicKey = <PLEX_HOST_PUBLIC_KEY>
AllowedIPs = 10.200.0.2/32
Endpoint = plex-host.example.com:51820
PersistentKeepalive = 25
EOF

systemctl enable --now wg-quick@wg0

# Replicate media library to Plex host every 30 minutes
cat > /etc/cron.d/media-replicate << 'EOF'
*/30 * * * * root syncoid -r --no-sync-snap rpool/media 10.200.0.2:rpool/media 2>&1 | logger -t media-replicate
EOF

On the Plex host, point libraries at the replicated datasets:

# Plex host receives replicated data automatically
# Movies: /srv/media/movies (from rpool/media/movies)
# TV:     /srv/media/tv     (from rpool/media/tv)
# Music:  /srv/media/music  (from rpool/media/music)

# Plex detects new files and updates the library automatically.
# syncoid sends only changed blocks — a 50GB movie that already
# exists on the Plex host costs zero transfer on the next sync.

Step 7: Monitoring

cat > /usr/local/bin/seedbox-stats << 'SCRIPT'
#!/bin/bash
echo "=== Disk usage per dataset ==="
zfs list -r rpool/landing rpool/media -o name,used,avail,compressratio -S used

echo ""
echo "=== Compression savings ==="
zfs get -r compressratio rpool/media | grep -v "1.00"

echo ""
echo "=== Landing zone (active downloads) ==="
ls -la /srv/landing/ 2>/dev/null | tail -20

echo ""
echo "=== Seeding stats ==="
# Query rtorrent via XMLRPC
python3 -c "
import xmlrpc.client
s = xmlrpc.client.ServerProxy('http://127.0.0.1:5000')
try:
    torrents = s.download_list('', 'main')
    seeding = [t for t in torrents if s.d.is_active(t) and s.d.complete(t)]
    downloading = [t for t in torrents if s.d.is_active(t) and not s.d.complete(t)]
    print(f'Active torrents: {len(torrents)}')
    print(f'Seeding: {len(seeding)}')
    print(f'Downloading: {len(downloading)}')
    up = s.throttle.global_up.total()
    down = s.throttle.global_down.total()
    print(f'Total uploaded: {up / 1024**3:.1f} GB')
    print(f'Total downloaded: {down / 1024**3:.1f} GB')
    if down > 0:
        print(f'Global ratio: {up/down:.2f}')
except Exception as e:
    print(f'rtorrent query failed: {e}')
" 2>/dev/null

echo ""
echo "=== Snapshot count ==="
echo "Landing: $(zfs list -t snapshot -r rpool/landing -H | wc -l) snapshots"
echo "Media:   $(zfs list -t snapshot -r rpool/media -H | wc -l) snapshots"

echo ""
echo "=== WireGuard tunnel ==="
wg show wg0 2>/dev/null || echo "WireGuard not active"
SCRIPT
chmod +x /usr/local/bin/seedbox-stats

Step 8: Snapshot schedule

# Landing zone: hourly snapshots, 24-hour retention (short — it's transient)
cat > /etc/cron.d/seedbox-snaps << 'EOF'
# Landing zone — hourly, keep 24
0 * * * * root zfs snapshot rpool/landing@auto-$(date +\%Y\%m\%d-\%H\%M) 2>&1 | logger -t zfs-snap

# Media library — daily, keep 30
0 2 * * * root zfs snapshot -r rpool/media@auto-$(date +\%Y\%m\%d) 2>&1 | logger -t zfs-snap

# Cleanup: destroy landing snaps older than 24 hours
5 * * * * root zfs list -t snapshot -H -o name rpool/landing 2>/dev/null | head -n -24 | xargs -r -n1 zfs destroy 2>&1 | logger -t zfs-snap-clean

# Cleanup: destroy media snaps older than 30 days
10 2 * * * root zfs list -t snapshot -H -o name -r rpool/media 2>/dev/null | grep "@auto-" | head -n -30 | xargs -r -n1 zfs destroy 2>&1 | logger -t zfs-snap-clean
EOF

Hourly snapshots on the landing zone means you can recover any download that was accidentally deleted or corrupted within the last 24 hours. Daily snapshots on the media library give you 30 days of history — if a rename script mangles your entire TV collection, roll back to yesterday.


The VPN kill switch is non-negotiable for a seedbox. Without it, if WireGuard drops (interface restart, key rotation, network blip), rtorrent falls back to the bare connection and your real IP is exposed to every peer in every swarm. The nftables kill switch makes this impossible: the output chain policy is DROP, and the only exceptions are loopback, LAN (for SSH management), WireGuard UDP (to maintain the tunnel), and traffic through wg0 (the tunnel itself). If wg0 goes down, there is no fallback path — packets get dropped, not rerouted. DNS goes through the tunnel too (no DNS leaks). This is the same pattern the nftables Masterclass teaches for per-interface policies, applied to a specific use case.

Step 9: VPN kill switch with nftables

# Ensure NOTHING leaks if WireGuard drops
cat > /etc/nftables.conf << 'NFTEOF'
#!/usr/sbin/nft -f

flush ruleset

table inet killswitch {
    chain output {
        type filter hook output priority 0; policy drop;

        # Allow loopback
        oif lo accept

        # Allow LAN (for ruTorrent web UI, SSH)
        ip daddr 10.0.0.0/8 accept
        ip daddr 192.168.0.0/16 accept

        # Allow WireGuard establishment (UDP to endpoint)
        udp dport 51820 accept

        # Allow DNS to local resolver
        ip daddr 127.0.0.1 udp dport 53 accept

        # Allow everything through WireGuard tunnel
        oifname "wg0" accept

        # Drop everything else — if WireGuard is down, nothing gets out
        log prefix "killswitch-blocked: " limit rate 1/minute
        drop
    }

    chain input {
        type filter hook input priority 0; policy drop;

        # Loopback
        iif lo accept

        # Established
        ct state established,related accept

        # LAN access (SSH, ruTorrent)
        ip saddr 10.0.0.0/8 accept
        ip saddr 192.168.0.0/16 accept

        # WireGuard
        iifname "wg0" accept

        drop
    }
}
NFTEOF

nft -f /etc/nftables.conf
systemctl enable nftables

With this kill switch, if WireGuard goes down for any reason, all torrent traffic stops immediately. Your real IP never touches a tracker or peer. The only non-VPN traffic allowed is LAN access (so you can still SSH in and fix things) and the WireGuard handshake itself.


Step 10: Plex integration

On the Plex host, the replicated datasets appear automatically:

# The Plex host has rpool/media with mountpoints:
# /srv/media/tv      — rpool/media/tv
# /srv/media/movies  — rpool/media/movies
# /srv/media/music   — rpool/media/music

# Add these as Plex libraries:
# Movies → /srv/media/movies
# TV Shows → /srv/media/tv
# Music → /srv/media/music

# Enable partial scanning in Plex settings so only changed
# directories get re-scanned after a syncoid replication.

# The complete pipeline:
# 1. RSS feed detects new episode
# 2. Flexget adds torrent to rtorrent
# 3. rtorrent downloads to /srv/landing
# 4. On completion, process-download renames + hardlinks to /srv/media
# 5. syncoid replicates /srv/media to Plex host every 30 minutes
# 6. Plex detects new file, adds to library
# 7. You watch it on any device
#
# Time from release to watchable: ~45 minutes (download + sync cycle)

Bill of materials

Component Cost
VPS or mini PC (4+ cores, 8GB RAM) $200-500
Storage (2x 4TB HDD, mirror or 1x 8TB) $100-300
WireGuard VPN provider (optional, for IP privacy) $5/month
kldload on USB Free
rtorrent + ruTorrent + Flexget + FileBot Free (open source)
Total ~$300-800 + VPN

The entire pipeline is automated. RSS feeds trigger downloads, completed torrents get processed and organized, ZFS replicates the library to your Plex host, and Plex serves it to every screen in your house. The only manual step is adding new shows to your Flexget config — and even that can be automated with Sonarr/Radarr if you prefer a web UI for management.