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 —
syncoidsends only changed blocks over WireGuard - Boot environments — upgrade rtorrent or Sonarr fearlessly
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.
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.