Contents

KVM Media VM: migrating Sonarr, Radarr and SABnzbd into an isolated VM

โšก TL;DR

Migrating the media stack (Sonarr, Radarr, SABnzbd) into a dedicated KVM VM running Ubuntu 24.04: security isolation, easy snapshots, QNAP NFS mounted inside the VM, nginx reverse proxy on the host. The migration fully preserves SQLite databases and existing configuration.

Target stack:

  • ๐Ÿ–ฅ๏ธ Host: NUC8i3BEH, Ubuntu Server, nginx reverse proxy, Plex (GPU transcoding)
  • ๐Ÿ“ฆ VM media-vm: Ubuntu 24.04, 2 vCPU, 8 GB RAM, 120 GB (X5 NVMe), QNAP NFS
  • ๐ŸŽฌ Migrated services: Sonarr (port 8989), Radarr (port 7878), SABnzbd (port 6789)

๐Ÿง  Why isolate the media stack in a VM

Sonarr, Radarr and SABnzbd present a significant attack surface: network calls to external indexers, post-download script execution, filesystem access to the media library. Confining them in a VM provides:

  • Security isolation โ€” a compromised Sonarr cannot reach the host directly
  • Snapshots before updates โ€” via virsh snapshot-create-as, rollback in 30 seconds if an update breaks the SQLite database
  • Plex stays on the host โ€” access to the Intel GPU (/dev/dri) for hardware transcoding, not easily virtualizable

Since the QNAP NFS was already mounted on the host, downloads already transit over the network โ€” the VM changes nothing in this regard, it mounts the same shares directly.

๐Ÿ”ง Step 1 โ€” Prepare the libvirt pool on the X5

The virtual disk is stored on the X5 NVMe SSD (exFAT filesystem), via an ext4 loop image. fallocate is not supported on exFAT โ€” use dd instead.

dd if=/dev/zero of=/mnt/X5/libvirt-media.img bs=1G count=150 status=progress
mkfs.ext4 /mnt/X5/libvirt-media.img
mkdir -p /mnt/libvirt-media
mount -o loop /mnt/X5/libvirt-media.img /mnt/libvirt-media
echo '/mnt/X5/libvirt-media.img /mnt/libvirt-media ext4 loop 0 0' >> /etc/fstab
systemctl daemon-reload
virsh pool-define-as media-pool dir --target /mnt/libvirt-media
virsh pool-autostart media-pool
virsh pool-start media-pool

๐Ÿ”ง Step 2 โ€” Download the Ubuntu 24.04 cloud image

wget -P /mnt/libvirt-media \
  https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
qemu-img resize /mnt/libvirt-media/noble-server-cloudimg-amd64.img 120G

๐Ÿ”ง Step 3 โ€” Create the cloud-init image

Files must be named exactly user-data and meta-data โ€” without any prefix โ€” otherwise cloud-init will not detect them.

โš ๏ธ Common pitfall: always include two SSH keys in authorized_keys โ€” the laptop key AND the NUCโ†’VM key. Without the NUCโ†’VM key, SSH from the host is impossible.

cat > /tmp/user-data <<'EOF'
#cloud-config
hostname: media-vm
users:
  - name: jm
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    lock_passwd: false
    passwd: $YOUR_HASH$...
    ssh_authorized_keys:
      - ssh-ed25519 AAAA... laptop-key
      - ssh-ed25519 AAAA... nuc-to-vm-key
chpasswd:
  expire: false
ssh_pwauth: true
package_update: true
packages:
  - curl
  - wget
  - nfs-common
runcmd:
  - systemctl enable nfs-client.target
instance-id: media-vm-001
local-hostname: media-vm
EOF

cat > /tmp/meta-data <<'EOF'
instance-id: media-vm-001
local-hostname: media-vm
EOF

genisoimage -output /mnt/libvirt-media/media-vm-cloud-init.img \
  -volid cidata -joliet -rock \
  /tmp/user-data /tmp/meta-data

๐Ÿ”ง Step 4 โ€” Create the VM

virt-install \
  --name media-vm \
  --ram 8192 \
  --vcpus 2 \
  --disk path=/mnt/libvirt-media/noble-server-cloudimg-amd64.img,format=qcow2 \
  --disk path=/mnt/libvirt-media/media-vm-cloud-init.img,device=cdrom \
  --os-variant ubuntu24.04 \
  --network network=default \
  --graphics none \
  --noautoconsole \
  --import

virsh autostart media-vm
virsh net-dhcp-leases default
ssh -i ~/.ssh/nuc-to-vm-key jm@192.168.122.X

๐Ÿ”ง Step 5 โ€” Mount QNAP NFS in the VM

sudo mkdir -p /mnt/Animes /mnt/Series /mnt/Films

sudo tee -a /etc/fstab <<'EOF'
192.168.1.11:/Animes /mnt/Animes nfs defaults,auto,vers=4.1,rw,nofail,bg,intr,rsize=262144,wsize=262144,noatime,_netdev 0 0
192.168.1.11:/Series /mnt/Series nfs defaults,auto,vers=4.1,rw,nofail,bg,intr,rsize=262144,wsize=262144,noatime,_netdev 0 0
192.168.1.11:/Films /mnt/Films nfs defaults,auto,vers=4.1,rw,nofail,bg,intr,rsize=262144,wsize=262144,noatime,_netdev 0 0
EOF

sudo systemctl daemon-reload && sudo mount -a

๐Ÿ”ง Step 6 โ€” Install Sonarr, Radarr, SABnzbd

๐ŸŽฌ Sonarr

curl -o- https://raw.githubusercontent.com/Sonarr/Sonarr/develop/distribution/debian/install.sh | sudo bash
# User: jm / Group: jm

๐ŸŽฌ Radarr

curl -o servarr-install-script.sh \
  https://raw.githubusercontent.com/Servarr/Wiki/master/servarr/servarr-install-script.sh
sudo bash servarr-install-script.sh
# Choose 3 (Radarr) โ€” User: jm / Group: jm

๐Ÿ“ฅ SABnzbd

sudo apt install -y sabnzbdplus
# Edit /etc/default/sabnzbdplus: USER=jm
sudo systemctl restart sabnzbdplus

โš™๏ธ Listen on all interfaces

By default, Sonarr and Radarr only listen on 127.0.0.1. Edit config.xml for both:

sudo systemctl stop sonarr radarr
# Change <BindAddress>0.0.0.0</BindAddress> in both config.xml files
sudo systemctl start sonarr radarr

And for SABnzbd in /home/jm/.sabnzbd/sabnzbd.ini:

host = 0.0.0.0
host_whitelist = media-vm, sabnzbd.yourdomain.com,

๐Ÿ”ง Step 7 โ€” Migrate data

# On the host
sudo systemctl stop sonarr radarr sabnzbdplus
scp -i ~/.ssh/nuc-to-vm-key -r /mnt/X5/config/sonarr jm@192.168.122.X:/tmp/
scp -i ~/.ssh/nuc-to-vm-key -r /mnt/X5/config/radarr jm@192.168.122.X:/tmp/
scp -i ~/.ssh/nuc-to-vm-key -r /mnt/X5/config/sabnzbd jm@192.168.122.X:/tmp/
# In the VM
sudo systemctl stop sonarr radarr sabnzbdplus
sudo rm -rf /var/lib/sonarr /var/lib/radarr /home/jm/.sabnzbd
sudo cp -r /tmp/sonarr /var/lib/sonarr
sudo cp -r /tmp/radarr /var/lib/radarr
sudo cp -r /tmp/sabnzbd /home/jm/.sabnzbd
sudo chown -R jm:jm /var/lib/sonarr /var/lib/radarr /home/jm/.sabnzbd
sudo systemctl start sonarr radarr sabnzbdplus

# On the host โ€” disable old services
sudo systemctl disable --now sonarr radarr sabnzbdplus

๐Ÿ”ง Step 8 โ€” nginx reverse proxy on the host

The VM runs on the libvirt NAT network 192.168.122.0/24. Update existing vhosts to point to the VM:

sed -i 's/http:\/\/127\.0\.0\.1:8989/http:\/\/192.168.122.X:8989/g' \
  /etc/nginx/sites-enabled/sonarr.yourdomain.com
sed -i 's/http:\/\/127\.0\.0\.1:7878/http:\/\/192.168.122.X:7878/g' \
  /etc/nginx/sites-enabled/radarr.yourdomain.com
nginx -t && systemctl reload nginx

๐Ÿ Result

The media stack runs in an isolated VM with full access to QNAP NFS shares. Sonarr and Radarr see the intact library and SABnzbd receives downloads on the VM’s local disk before import.

ServiceURLInternal port
Sonarrhttps://sonarr.arleo.eu192.168.122.X:8989
Radarrhttps://radarr.arleo.eu192.168.122.X:7878
SABnzbdhttps://sabnzbd.arleo.eu192.168.122.X:6789

Key takeaways:

  • ๐Ÿ’ก Always include two SSH keys in cloud-init (laptop + NUCโ†’VM)
  • ๐Ÿ’ก cloud-init files must be named exactly user-data and meta-data
  • ๐Ÿ’ก fallocate does not work on exFAT โ€” use dd
  • ๐Ÿ’ก Sonarr/Radarr listen on 127.0.0.1 by default โ€” change BindAddress in config.xml
  • ๐Ÿ’ก Add the vhost hostname to SABnzbd’s host_whitelist