Containers

ZimaBoard 2 Media Server: Jellyfin, Arr Stack and Navidrome

Eight gigabytes of soldered memory and two SATA ports are the two numbers that decide how a ZimaBoard media server should be built. Neither can be changed later, so the layout has to fit the board from the first day: where the operating system lives, how the hard disks are pooled, how much RAM each service is allowed to take, and which ports are reachable from outside your home network. Get those four right and the N150 runs a full Jellyfin, Sonarr, Radarr and Navidrome stack with more than half of its memory still free.

Original content from computingforgeeks.com - post 173001

This guide builds that stack on Debian 13 with OpenZFS, on the ZimaBoard 2 832 (the 8 GB model). Debian sits on an NVMe drive in the PCIe slot, the SATA disks become a ZFS pool, and a single Docker Compose file runs Jellyfin, Prowlarr, Radarr, Sonarr, Bazarr, Seerr and Transmission for films and series, plus DroppedNeedle, slskd and Navidrome for music. If your board is still on ZimaOS, the ZimaBoard 2 setup guide covers the first boot and the hardware checks, and it is worth running once before you replace the operating system. We rehearsed the whole build on a virtual machine cut to the exact shape of the 832 (four N150-class cores, a fixed 8 GB of RAM, an NVMe system disk and two SATA disks) running Debian 13 (installed from 13.1 media, updated to 13.7) with OpenZFS 2.3.9, Docker 29.8 and Jellyfin 12.1 in October 2026, and every command and output below comes from that machine. The one thing a VM can’t reproduce is Intel Quick Sync transcoding, which is flagged where it comes up.

How the video and music pipelines fit together

The stack is really two request pipelines that share one storage pool. On the video side, someone asks for a film in Seerr, Seerr passes the request to Radarr (or Sonarr for a series), Radarr asks Prowlarr which of your indexers has a release, Transmission downloads it, Radarr imports it into the library, and Jellyfin streams it. The music side has the same shape with fewer moving parts: DroppedNeedle takes the album request, searches the Soulseek network through slskd, files the finished album into the library, and Navidrome serves it to any Subsonic-compatible player.

ZimaBoard media server architecture diagram: Radarr, Sonarr, Transmission, DroppedNeedle and slskd write into tank/data, which Jellyfin and Navidrome read

The numbered arrows trace one film request from the household to the TV. Every green arrow pointing into tank/data is a write into the same dataset, and the two leaving it are reads. That is the whole trick: the import arrows from Radarr and DroppedNeedle copy nothing, they only give blocks that are already on disk a second name or a new path.

AppPortWhat it doesTalks to
Jellyfin8096Streams films and series to TVs, phones and browsersReads /data/media
Seerr5055Request page for the householdJellyfin, Radarr, Sonarr
Radarr / Sonarr7878 / 8989Find, grab and import films and seriesProwlarr, Transmission
Prowlarr9696Manages indexers once for both appsRadarr, Sonarr, FlareSolverr
Transmission9091, peer 51413BitTorrent clientWrites /data/downloads
Bazarr6767Subtitles for the films and seriesRadarr, Sonarr
DroppedNeedle8688Music requests, matching and importslskd, Navidrome
slskd5030, peer 50300Soulseek clientWrites /data/downloads/slskd
Navidrome4533Music streaming server (Subsonic API)Reads /data/media/music

DroppedNeedle is what makes the music side small. It replaces the Lidarr plus Prowlarr plus download client chain with one container that searches, scores and imports by itself, and its README is blunt about it (“No Lidarr. One container.”). The project is open source under AGPL-3.0 and lives on GitHub.

Why media and downloads live in one ZFS dataset

The key insight in the storage layout is that the library and the download folders sit in the same ZFS dataset, tank/data, mounted at /srv/data and bind-mounted into every container as /data. The reason is the hardlink. When Radarr imports a finished download it doesn’t copy the file, it creates a second directory entry that points at the same blocks, so the torrent keeps seeding from downloads/ while Jellyfin plays the same bytes from media/. A hardlink can only exist inside one filesystem, and every ZFS dataset is its own filesystem, so the moment downloads and media are two datasets the link() call fails with EXDEV and Radarr quietly falls back to a full copy. A 60 GB film then costs 120 GB.

The same rule applies inside Docker. Two bind mounts of the same dataset (one for /downloads, one for /movies) look like two filesystems to the process inside the container, and the hardlink fails exactly as it would across datasets. That is why every container in the Compose file below that moves files mounts /srv/data once, as /data, and Radarr sees both /data/downloads and /data/media through that one mount. DroppedNeedle relies on the same property for music: moving a finished album from the slskd folder into the library is a rename, not a copy. Later in the guide we show the inode numbers that prove it.

Where the NVMe fits, and why it is not a ZFS cache

The NVMe drive is the system disk: Debian, the Docker image store, the database of every application under /opt/media, the Jellyfin transcode scratch space and the swap file all live on it. That is where the small random reads and writes happen, so putting it on flash keeps the hard disks idle except while they stream or import. It isn’t given to ZFS as a cache, which is the first idea most people have with a spare NVMe and a ZFS pool.

An L2ARC read cache helps only with data that is read repeatedly, and a media library is mostly read once per viewing, so the cache fills with blocks that are never requested again while its index eats ARC memory the board cannot spare. A SLOG device speeds up synchronous writes only, and nothing in this stack writes synchronously to the pool. A special vdev for metadata would genuinely help directory scans, but it becomes part of the pool, so a single NVMe as the special vdev means the pool dies if that drive dies. On a board with one PCIe slot there is no second NVMe to mirror it with.

What the ZimaBoard media server needs

The hardware is a ZimaBoard 2 832, an NVMe drive on a PCIe adapter in the PCIe 3.0 slot (64 GB is enough for the system, and 256 GB leaves room for application databases to grow), one or two 3.5-inch hard disks on the SATA ports, a 12 V/5 A supply for two spinning disks, a USB stick with the Debian 13 netinst image, and for the install itself a monitor on the Mini DisplayPort output (with an adapter if your screen has only HDMI) plus a USB keyboard. Size the disks from the library, not from the board: a 1080p film runs roughly 5 to 15 GB and a 2160p release 20 to 60 GB, and the seeding copies cost nothing extra because of the hardlinks above. Our lab disks were 100 GB each, which is a floor for following along, not a recommendation.

Memory is the constraint that shapes everything else. The table later in the guide shows every container with a hard mem_limit and the ZFS ARC capped at 1.5 GiB, which keeps the worst case at about 7 GB even if every service hits its ceiling at once. In the settled state we measured the whole stack using about 2.1 GiB, so the caps are headroom, not a forecast. The music section additionally needs a Soulseek username, but you don’t register it anywhere: the first login with an unused name creates the account.

Step 1: Install Debian 13 on the NVMe

Plug in the netinst USB, press F11 at power-on for the boot menu and pick the entry marked UEFI for the stick (Delete opens the full firmware setup instead), then run the normal installer. If Secure Boot is enabled in the firmware, either turn it off or enroll the DKMS signing key, because the zfs-dkms module built in Step 3 is not signed by Debian and will not load otherwise. To enroll it after Step 3, run sudo mokutil --import /var/lib/dkms/mok.pub, choose a one-time password, reboot, and confirm the key in the blue MOK manager screen that appears. Two choices matter. At the partitioning step pick the NVMe drive (nvme0n1) as the install target, never mmcblk0, which is the 32 GB eMMC that still holds ZimaOS and makes a useful recovery system. At software selection keep only “SSH server” and “standard system utilities”, because a desktop wastes memory this board needs for the stack. Leave the SATA disks untouched; ZFS takes them over in Step 4. One board-specific caveat we could not test in a VM: IceWhale documents kernel panics in the igc driver for the board’s Intel I226-V network ports when PCIe power saving is active, and recommends adding pcie_aspm=off to the kernel command line on Debian. If the board ever freezes with nothing in the logs, that is the first thing to set: append pcie_aspm=off inside the quotes of GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub, run sudo update-grub and reboot (IceWhale’s I226 notes).

Everything after the install was run over SSH as a normal user with sudo. Our lab user is gedion and the host is media-node.

Step 2: Set the shell variables

Four values repeat through the commands: your LAN range, the board IP address, the network interface that faces your router, and your time zone. Export them once at the start of each SSH session:

export LAN_CIDR="192.168.1.0/24"
export HOST_IP="192.168.1.171"
export NIC="$(ip route show default | awk '{print $5; exit}')"
export TZ_NAME="Africa/Nairobi"

The interface name is detected from the default route, so check that all four came out right before going further:

echo "LAN=${LAN_CIDR} IP=${HOST_IP} NIC=${NIC} TZ=${TZ_NAME}"

Ours printed the following. On the real board the interface is usually named after the PCI slot, such as enp1s0, rather than eth0:

LAN=192.168.1.0/24 IP=192.168.1.171 NIC=eth0 TZ=Africa/Nairobi

These exports last only for the current shell. If you reconnect later, run the export block again before continuing.

Step 3: Update the kernel, then install OpenZFS

OpenZFS is in the Debian contrib component, which a default install does not enable. The first command converts an old-style sources.list into the newer debian.sources format (it does nothing if the file is already converted), and the second adds contrib to the component list:

sudo apt modernize-sources -y
sudo sed -i -E 's/^Components: main( non-free-firmware)?$/Components: main contrib non-free-firmware/' /etc/apt/sources.list.d/debian.sources

Check the result, because the sed only rewrites the two component lists a default install produces:

grep Components /etc/apt/sources.list.d/debian.sources

Every line must include contrib. If one still reads differently (for example main non-free non-free-firmware), add contrib to it by hand with sudo vim, or zfs-dkms will not be found:

Components: main contrib non-free-firmware
Components: main contrib non-free-firmware

Now upgrade and reboot before touching ZFS. This step looks optional, but it isn’t. The zfs-dkms package builds its kernel module against whatever kernel headers are installed, and linux-headers-amd64 always pulls the headers for the newest kernel in the archive. If the running kernel is older than that, DKMS builds the module for a kernel you are not running, and modprobe zfs fails until you reboot anyway. We hit exactly that on the first attempt.

sudo apt update
sudo apt full-upgrade -y
sudo systemctl reboot

After the reboot, reconnect, run the Step 2 exports again, and install ZFS together with the editor used later in the guide. The zfs-dkms install shows a notice about the OpenZFS and Linux licences; accept it and the module compiles, which takes a few minutes on the N150:

sudo apt install -y linux-headers-amd64 zfs-dkms zfsutils-linux vim

Load the module and confirm the userland and kernel versions match:

sudo modprobe zfs
zfs version

Both lines should report the same release:

zfs-2.3.9-0+deb13u1
zfs-kmod-2.3.9-0+deb13u1

The ARC, the read cache that ZFS keeps in RAM, defaults on OpenZFS 2.3 to all but 1 GiB of physical memory (or five eighths of it, whichever is larger). On an 8 GB board that is about 6.8 GiB the cache may grow into while the containers need the same memory, so cap it at 1.5 GiB, both in the module options (which apply at the next boot) and live:

echo "options zfs zfs_arc_max=1610612736" | sudo tee /etc/modprobe.d/zfs.conf
echo 1610612736 | sudo tee /sys/module/zfs/parameters/zfs_arc_max

Step 4: Create the ZFS pool from disk IDs

Linux names SATA disks sda, sdb and so on in the order it finds them, and that order is not stable. On our lab machine the two disks swapped names between the first boot and the second, with nothing changed in between. A pool built on /dev/sda still imports (ZFS reads its own labels), but every zpool status, every replacement command and every note you write about which bay holds which disk becomes a guessing game. Build the pool from the stable names in /dev/disk/by-id/ instead, which carry the drive model and serial number:

ls -l /dev/disk/by-id/ | grep ata- | grep -v part

Each physical disk shows once, and the optical drive line is only there because our test machine is a VM. The disks in our VM report as QEMU drives; a real WD or Seagate disk shows its model and serial in the same place:

lrwxrwxrwx 1 root root  9 Oct  5 16:33 ata-QEMU_DVD-ROM_QM00003 -> ../../sr0
lrwxrwxrwx 1 root root  9 Oct  5 16:33 ata-QEMU_HARDDISK_BAY1 -> ../../sda
lrwxrwxrwx 1 root root  9 Oct  5 16:33 ata-QEMU_HARDDISK_BAY2 -> ../../sdb

Set the full path of the first disk as a variable, then create the pool on it. Double-check this path, because zpool create wipes the disk it is given:

export DISK1="/dev/disk/by-id/ata-QEMU_HARDDISK_BAY1"
sudo zpool create -o ashift=12 -O compression=lz4 -O atime=off -O xattr=sa -O acltype=posixacl -O mountpoint=none tank "${DISK1}"

The options are the sensible defaults for a media pool: ashift=12 matches the 4 KiB sectors of every modern hard disk, lz4 compression costs almost nothing and saves space on metadata and subtitles, and atime=off stops every read from turning into a write. The pool itself is not mounted; the one dataset that holds everything is created next, with 1 MiB records because films and FLAC files are large and read sequentially:

sudo zfs create -o mountpoint=/srv/data -o recordsize=1M tank/data

Confirm the pool and the dataset:

zpool status tank
zfs list -o name,used,avail,mountpoint,recordsize,compression

A single-disk pool reports the disk directly under tank. The screenshot further down shows the same pool after the second disk is added as a mirror:

  pool: tank
 state: ONLINE
config:

	NAME                      STATE     READ WRITE CKSUM
	tank                      ONLINE       0     0     0
	  ata-QEMU_HARDDISK_BAY1  ONLINE       0     0     0

errors: No known data errors

Why primarycache stays at its default

A common tip for media pools is to set primarycache=metadata on the dataset, so that streamed video never pushes useful metadata out of the ARC. We built the first version of this server that way, then measured it. With metadata-only caching, ZFS discards each 1 MiB record as soon as it has been read, so an application that reads a file in small pieces makes ZFS fetch the same record from disk again and again. We read a 256 MiB file with a cold ARC at different read sizes and counted the bytes that actually came off the disk:

Read sizeprimarycache=metadataprimarycache=all (default)
4 KiB511 MiB from disk256 MiB
16 KiB319 MiB256 MiB
64 KiB271 MiB256 MiB
128 KiB263 MiB256 MiB
1 MiB256 MiB256 MiB

Large sequential reads are unaffected, but anything that reads in small chunks pays up to double the disk I/O, and the default never amplified at any size. The 1.5 GiB ARC cap already keeps the cache from crowding the containers, so the dataset keeps the default and the cap does the job the tip was meant to do. The effect is described in OpenZFS issue #12028, which reports much worse numbers on bare metal than our VM showed.

Step 5: Give 8 GB of RAM some headroom with zram and swap

Swap on this board is a safety net for short spikes, such as a library scan that runs while FlareSolverr starts a browser. The first tier is zram, a compressed swap device in RAM that costs CPU instead of disk I/O, and the second is a 4 GB swap file on the NVMe that only takes pages once zram is full. The priorities (100 for zram, 10 for the file) are what enforce that order.

sudo apt install -y zram-tools
printf 'ALGO=zstd\nSIZE=2048\nPRIORITY=100\n' | sudo tee /etc/default/zramswap
sudo systemctl stop zramswap
sudo systemctl start zramswap

Use a separate stop and start here. On our install, a plain systemctl restart straight after the package install left the device at the package default (lz4, half of RAM) instead of reading the new file; a stop followed by a start applied it. Next, the swap file and the kernel settings that suit compressed swap:

sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon -p 10 /swapfile
echo '/swapfile none swap sw,pri=10 0 0' | sudo tee -a /etc/fstab
printf 'vm.swappiness=100\nvm.page-cluster=0\nfs.inotify.max_user_watches=524288\n' | sudo tee /etc/sysctl.d/90-media.conf
sudo sysctl --system

vm.swappiness=100 looks aggressive, but with zram in front it means “compress idle pages early” rather than “write to disk early”, and vm.page-cluster=0 stops the kernel reading swap in eight-page batches, which only makes sense for spinning disks. The inotify limit lets Navidrome watch a large music library for changes. Check that the two swap devices came up in the right order:

sudo swapon --show

zram must show the higher priority:

NAME       TYPE      SIZE  USED PRIO
/swapfile  file        4G    0B   10
/dev/zram0 partition   2G 38.1M  100

Step 6: Install Docker and make it wait for ZFS

Docker comes from the Docker repository rather than from Debian, because the Debian docker.io package lags behind upstream releases. Our Docker CE install guide for Debian explains each step; the condensed version is:

sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
printf 'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: %s\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n' "$(. /etc/os-release && echo "$VERSION_CODENAME")" | sudo tee /etc/apt/sources.list.d/docker.sources
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Two small configuration files follow. The first switches Docker to its compact local log driver with rotation, so a chatty container cannot fill the NVMe:

sudo vim /etc/docker/daemon.json

Add the following:

{
    "log-driver": "local",
    "log-opts": {
        "max-file": "3",
        "max-size": "10m"
    }
}

The second is a systemd drop-in that makes Docker wait for ZFS. Without it, Docker can start before /srv/data is mounted after a reboot, and every container then writes into an empty directory on the NVMe that the ZFS mount later hides. Create the directory and the file:

sudo mkdir -p /etc/systemd/system/docker.service.d
sudo vim /etc/systemd/system/docker.service.d/10-zfs.conf

Add the ordering rules:

[Unit]
After=zfs-mount.service
Requires=zfs-mount.service

Reload systemd, restart Docker and check the versions:

sudo systemctl daemon-reload
sudo systemctl restart docker
docker --version
docker compose version

Our lab installed these releases:

Docker version 29.8.2, build 7fc2dff
Docker Compose version v5.6.0

Step 7: Keep Docker ports off the internet with UFW

This is the step most home media guides get wrong, and the failure is silent. What is actually happening comes down to the order of the firewall chains. Docker publishes container ports by adding NAT rules that divert traffic before it reaches the INPUT chain where UFW does its filtering, so a UFW default-deny policy does not apply to anything a container publishes. The Docker documentation says so plainly. In practice that means a router port forward added for torrent seeding, or a router with UPnP, can expose Radarr, Transmission and Prowlarr (none of which ask for a password from the LAN) to the whole internet even though ufw status looks locked down.

The fix has two parts. First, the normal UFW rules for the host itself: SSH, the Jellyfin web port and its discovery port from the LAN only, plus one rule that matters later. Jellyfin runs on the host network for performance, so when Seerr (in a container) talks to Jellyfin it connects to the host IP from the Docker bridge range, and that traffic arrives on INPUT like any other. The last rule allows exactly that, and only to port 8096:

sudo apt install -y ufw
sudo ufw allow from "${LAN_CIDR}" to any port 22 proto tcp
sudo ufw allow from "${LAN_CIDR}" to any port 8096 proto tcp
sudo ufw allow from "${LAN_CIDR}" to any port 7359 proto udp
sudo ufw allow from 172.16.0.0/12 to any port 8096 proto tcp

Second, the rules for published container ports go in the DOCKER-USER chain, which Docker consults before its own rules and never overwrites. UFW can own that chain through its after.rules file. Open it:

sudo vim /etc/ufw/after.rules

Append this block at the very end of the file, after the existing COMMIT line. NIC_HERE and LAN_CIDR_HERE are placeholders that the next command fills in from your variables:

# BEGIN DOCKER-USER
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i NIC_HERE -s LAN_CIDR_HERE -j RETURN
-A DOCKER-USER -i NIC_HERE -p tcp -m conntrack --ctorigdstport 51413 -j RETURN
-A DOCKER-USER -i NIC_HERE -p udp -m conntrack --ctorigdstport 51413 -j RETURN
-A DOCKER-USER -i NIC_HERE -p tcp -m conntrack --ctorigdstport 50300 -j RETURN
-A DOCKER-USER -i NIC_HERE -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
COMMIT
# END DOCKER-USER

Read top to bottom, the chain lets replies through, lets anything from your LAN through, lets the two peer ports (51413 for Transmission and 50300 for slskd) through from anywhere, and drops every other new connection that arrives from the internet side. The --ctorigdstport match is the important detail: by the time a packet reaches DOCKER-USER its destination has already been rewritten to the internal port of the container, so matching on the original port is the only reliable way to name it. Every rule is tied to the external interface, so traffic between containers on the Docker bridge is never touched. Fill in the placeholders and enable the firewall:

sudo sed -i "s|NIC_HERE|${NIC}|g; s|LAN_CIDR_HERE|${LAN_CIDR}|g" /etc/ufw/after.rules
sudo ufw --force enable

Run this from a machine inside LAN_CIDR, since the SSH rule only admits the LAN. If you are connected from somewhere else, add an SSH rule for that address first. Then check both halves:

sudo ufw status
sudo iptables -S DOCKER-USER

On our board the output showed the LAN rules in UFW and the Docker ports closed to the internet in the DOCKER-USER chain:

UFW status and DOCKER-USER iptables chain blocking internet access to Docker ports

If you later forward ports on your router, forward only 51413 (TCP and UDP) and 50300 (TCP). Nothing else in this stack belongs on the internet, and the UFW command reference covers adding a rule if you ever need one. For remote viewing, a VPN back into the LAN is a far better answer than exposing Jellyfin.

Step 8: Create the media user and the folder tree

The LinuxServer.io images run their process as whatever user and group IDs you give them, so the host needs a matching user and group to own the files. We use group media (GID 3000) and a login-less user apps (UID 568). The exact numbers do not matter as long as the Compose file uses the same ones:

sudo groupadd -g 3000 media
sudo useradd -r -u 568 -g media -M -s /usr/sbin/nologin apps

Now the tree on the pool. The setgid bit (the 2 in 2775) makes every new file inherit the media group, which is what lets Radarr, Transmission, slskd and DroppedNeedle all write into the same folders:

sudo mkdir -p /srv/data/media/{movies,tv,music} /srv/data/downloads/{.incomplete,radarr,sonarr} /srv/data/downloads/slskd/{complete,incomplete}
sudo chown -R apps:media /srv/data
sudo find /srv/data -type d -exec chmod 2775 {} +

Application state goes on the NVMe. Seerr ignores PUID and PGID and always runs as UID 1000, so its folder is owned by that UID instead (slskd and Navidrome also ignore PUID, so the Compose file gives them a user: line instead):

sudo mkdir -p /opt/media/{compose,jellyfin/config,transmission/config,prowlarr/config,radarr/config,sonarr/config,bazarr/config,seerr/config,slskd,navidrome} /opt/media/droppedneedle/{config,cache,plugins,imports} /var/cache/jellyfin-transcode
sudo chown -R apps:media /opt/media /var/cache/jellyfin-transcode
sudo chown -R 1000:1000 /opt/media/seerr
sudo chown root:root /opt/media/compose

Step 9: Write the Compose file and start the stack

Everything runs as one Compose project with one bridge network, so the apps reach each other by container name (http://radarr:7878, http://transmission:9091) and none of the wiring depends on the board IP address. The time zone and host IP come from a .env file next to the Compose file:

printf 'TZ=%s\nHOST_IP=%s\n' "${TZ_NAME}" "${HOST_IP}" | sudo tee /opt/media/compose/.env

Open the Compose file:

sudo vim /opt/media/compose/docker-compose.yml

Add the full stack. This is the exact file our lab ran:

x-lsio: &lsio
  PUID: "568"
  PGID: "3000"
  TZ: "${TZ}"
  UMASK: "002"

name: media
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    network_mode: host
    environment:
      <<: *lsio
      JELLYFIN_PublishedServerUrl: "http://${HOST_IP}:8096"
    volumes:
      - /opt/media/jellyfin/config:/config
      - /srv/data/media:/data/media
      - /var/cache/jellyfin-transcode:/transcode
    # devices:
    #   - /dev/dri:/dev/dri
    mem_limit: 1024m
    restart: unless-stopped

  transmission:
    image: lscr.io/linuxserver/transmission:latest
    container_name: transmission
    environment:
      <<: *lsio
      WHITELIST: "127.0.0.1,192.168.*.*,172.16.*.*,172.17.*.*,172.18.*.*,172.19.*.*,172.20.*.*"
    volumes:
      - /opt/media/transmission/config:/config
      - /srv/data:/data
    ports:
      - "9091:9091"
      - "51413:51413"
      - "51413:51413/udp"
    mem_limit: 300m
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    environment:
      <<: *lsio
      DOTNET_gcServer: "0"
    volumes:
      - /opt/media/prowlarr/config:/config
    ports:
      - "9696:9696"
    mem_limit: 320m
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      <<: *lsio
      DOTNET_gcServer: "0"
    volumes:
      - /opt/media/radarr/config:/config
      - /srv/data:/data
    ports:
      - "7878:7878"
    mem_limit: 400m
    restart: unless-stopped

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      <<: *lsio
      DOTNET_gcServer: "0"
    volumes:
      - /opt/media/sonarr/config:/config
      - /srv/data:/data
    ports:
      - "8989:8989"
    mem_limit: 400m
    restart: unless-stopped

  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    environment:
      <<: *lsio
    volumes:
      - /opt/media/bazarr/config:/config
      - /srv/data:/data
    ports:
      - "6767:6767"
    mem_limit: 400m
    restart: unless-stopped

  seerr:
    image: ghcr.io/seerr-team/seerr:latest
    container_name: seerr
    init: true
    environment:
      TZ: "${TZ}"
      LOG_LEVEL: "info"
      PORT: "5055"
      NODE_OPTIONS: "--max-old-space-size=256"
    volumes:
      - /opt/media/seerr/config:/app/config
    ports:
      - "5055:5055"
    mem_limit: 512m
    restart: unless-stopped

  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr:latest
    container_name: flaresolverr
    environment:
      LOG_LEVEL: "info"
      TZ: "${TZ}"
    ports:
      - "8191:8191"
    mem_limit: 640m
    restart: unless-stopped

  droppedneedle:
    image: droppedneedle/droppedneedle:latest
    container_name: droppedneedle
    environment:
      <<: *lsio
      PORT: "8688"
      SLSKD_DOWNLOADS_PATH: "/data/downloads/slskd/complete"
    volumes:
      - /opt/media/droppedneedle/config:/app/config
      - /opt/media/droppedneedle/cache:/app/cache
      - /opt/media/droppedneedle/plugins:/app/plugins
      - /opt/media/droppedneedle/imports:/app/imports
      - /srv/data:/data
    ports:
      - "8688:8688"
    mem_limit: 768m
    restart: unless-stopped

  slskd:
    image: slskd/slskd:0.25.1
    container_name: slskd
    user: "568:3000"
    environment:
      SLSKD_REMOTE_CONFIGURATION: "false"
      DOTNET_gcServer: "0"
      TZ: "${TZ}"
    volumes:
      - /opt/media/slskd:/app
      - /srv/data:/data
    ports:
      - "5030:5030"
      - "50300:50300"
    mem_limit: 300m
    restart: unless-stopped

  navidrome:
    image: deluan/navidrome:latest
    container_name: navidrome
    user: "568:3000"
    environment:
      TZ: "${TZ}"
      ND_SCANNER_SCHEDULE: "@every 15m"
      ND_LOGLEVEL: "info"
      ND_ENABLEINSIGHTSCOLLECTOR: "false"
    volumes:
      - /opt/media/navidrome:/data
      - /srv/data/media/music:/music:ro
    ports:
      - "4533:4533"
    mem_limit: 200m
    restart: unless-stopped

A few lines in that file carry more weight than they appear to. Every service has a mem_limit, which turns a memory leak in one app into a restart of that app instead of the kernel OOM killer picking a victim across the whole board. DOTNET_gcServer: "0" switches the .NET apps (Radarr, Sonarr, Prowlarr, slskd) to the workstation garbage collector, which holds far less memory on a four-core machine. NODE_OPTIONS=--max-old-space-size=256 does the same job for the Node.js heap in Seerr. slskd is pinned to 0.25.1 because that is the version the DroppedNeedle README lists as verified. The commented devices lines under Jellyfin are for the real board: uncomment them to pass the N150 iGPU into the container for Quick Sync, which our VM could not test because it has no GPU. Following the Jellyfin Intel guide, first confirm /dev/dri/renderD128 exists on the host and install firmware-intel-graphics so the GuC and HuC firmware load, then after recreating the container check that sudo docker exec jellyfin /usr/lib/jellyfin-ffmpeg/vainfo lists the decode profiles before you enable QSV.

Validate the file, then start the stack:

cd /opt/media/compose
sudo docker compose config --quiet && echo "compose config OK"
sudo docker compose up -d
sudo docker compose ps --format 'table {{.Name}}\t{{.Status}}'

After about a minute all eleven containers report as up, and the two with health checks report healthy:

NAME            STATUS
bazarr          Up About a minute
droppedneedle   Up About a minute (healthy)
flaresolverr    Up About a minute
jellyfin        Up About a minute
navidrome       Up About a minute
prowlarr        Up About a minute
radarr          Up About a minute
seerr           Up About a minute
slskd           Up About a minute (healthy)
sonarr          Up About a minute
transmission    Up About a minute

How the 8 GB of RAM is shared

The caps add up to about 5.0 GiB for the containers. With the 1.5 GiB ARC ceiling and roughly 0.5 GiB for Debian itself, the theoretical worst case is just over 7 GiB, which still fits in the 7.8 GiB of usable memory before swap is touched. Actual use is far lower, as the snapshot below (taken after the whole guide was complete) shows:

Containermem_limitMeasured, settled
Jellyfin1 GiB339 MiB
Seerr512 MiB312 MiB
FlareSolverr640 MiB191 MiB
DroppedNeedle768 MiB232 MiB
Bazarr400 MiB246 MiB
Radarr400 MiB204 MiB
slskd300 MiB216 MiB
Prowlarr320 MiB189 MiB
Sonarr400 MiB160 MiB
Navidrome200 MiB54 MiB
Transmission300 MiB29 MiB
docker stats memory use of the Jellyfin, arr and Navidrome containers on 8 GB RAM

The caps did not start at these values. On our first build FlareSolverr sat at 398 MiB against a 400 MiB cap straight after boot, Seerr at 295 against 350, and Prowlarr at 201 against 256, so all three were raised. If a container restarts in a loop, check docker stats for one pinned at its limit and raise that cap rather than removing it.

Step 10: Connect Jellyfin, Prowlarr, Radarr and Sonarr

With the containers running, the rest of the video pipeline is configured in the web interface of each app. The order matters a little: Jellyfin first, then Radarr and Sonarr, then Prowlarr (which needs their API keys), then Seerr (which needs all of them).

Jellyfin

Open http://HOST_IP:8096 (with the address of your board) and work through the setup wizard: a server name, an administrator account, and two libraries. Add a Movies library pointing at /data/media/movies and a Shows library pointing at /data/media/tv. Those are container paths, because Jellyfin sees /srv/data/media as /data/media. Then open Dashboard, then Playback, then Transcoding, and set the transcode path to /transcode, so temporary transcode files land in the NVMe folder mounted for them instead of inside the config directory. On the real board, select Intel QuickSync (QSV) on the same page once the devices lines are uncommented and vainfo works, and untick any codec the Jellyfin guide marks unsupported for this GPU; the Jellyfin Intel guide lists which codecs the Xe-LP graphics in the N150 can decode. If you have used Jellyfin before on a plain VM, the Jellyfin install guide for Ubuntu and Debian covers the same wizard screen by screen.

Radarr and Sonarr

Open Radarr at http://HOST_IP:7878. Current Radarr, Sonarr and Prowlarr releases refuse to run without an authentication choice, so the first thing you see is an “Authentication Required” dialog. Set Authentication Method to Forms (Login Page), Authentication Required to “Disabled for Local Addresses”, and pick a username and password. The dialog also has an Allowed Hosts field, and with “Disabled for Local Addresses” selected it must not be empty, even though its help text says empty allows all hosts. Enter the board IP, its hostname and the container name, for example 192.168.1.171,media-node,radarr. The container name matters, because Prowlarr and Seerr call Radarr by that name (localhost is always allowed). The dialog warns that this field needs a restart, so run sudo docker restart radarr once you have saved it.

Still in Radarr, open Settings, then Media Management, click Add Root Folder and choose /data/media/movies. On the same page (with Show Advanced on), confirm “Use Hardlinks instead of Copy” is ticked; it is on by default. Then Settings, then Download Clients, click the plus sign, choose Transmission, set Host to transmission, Port to 9091 and Category to radarr, and click Test. The test passes without a username or password because the Transmission RPC whitelist in the Compose file admits the Docker bridge range. That whitelist covers 172.16 to 172.20; ours landed on 172.18, and docker network inspect media_default shows yours if the test fails on a host that already runs many Docker networks.

Radarr Download Clients page with Transmission enabled

Repeat the same three things in Sonarr at http://HOST_IP:8989: the authentication dialog (with sonarr in Allowed Hosts), the root folder /data/media/tv, and Transmission with Category sonarr. Both apps show their API key under Settings, then General; copy both for the next step.

Transmission itself still points at the LinuxServer default /downloads folder, which doesn’t exist in this layout. Point it at the shared tree. Note the explicit 127.0.0.1:9091: plain localhost inside the container resolves to the IPv6 loopback first, which is not in the whitelist, and the command fails with 403: Forbidden:

sudo docker exec transmission transmission-remote 127.0.0.1:9091 -w /data/downloads --incomplete-dir /data/downloads/.incomplete

The Radarr category then puts its grabs in /data/downloads/radarr, Sonarr grabs in /data/downloads/sonarr, and anything you add by hand lands in /data/downloads. The setting survives a container restart because Transmission writes it into settings.json.

Prowlarr and FlareSolverr

Open Prowlarr at http://HOST_IP:9696 and answer the same authentication dialog, with prowlarr in Allowed Hosts. Some public indexers sit behind a Cloudflare check that only a real browser passes, which is the job of FlareSolverr. Add it under Settings, then Indexers, as an Indexer Proxy of type FlareSolverr with Host http://flaresolverr:8191/ and a tag such as flaresolverr; any indexer that carries the same tag is then fetched through it. Under Settings, then Apps, add Radarr with Prowlarr Server http://prowlarr:9696, Radarr Server http://radarr:7878 and the Radarr API key, then do the same for Sonarr with http://sonarr:8989. Both connection tests passed on the first try in our lab, which confirms that container-name addressing works across the whole stack.

Prowlarr Settings Apps page showing Radarr and Sonarr connected with Full Sync

Finally add your indexers under Indexers, then Add Indexer. Prowlarr pushes every indexer to both apps automatically through the Full Sync setting, so you never configure an indexer twice.

Step 11: Connect Seerr to Jellyfin

Seerr is the request page the rest of the household uses, and it is the merged successor of Overseerr and Jellyseerr. Open http://HOST_IP:5055, choose Configure Jellyfin, and sign in with your Jellyfin administrator. The Jellyfin URL is the board IP address with port 8096, not a container name, because Jellyfin runs on the host network rather than on the Compose bridge. This is the one connection in the stack that goes through the host IP, and it is exactly what the 172.16.0.0/12 UFW rule in Step 7 exists for. Without that rule, packets from Seerr to port 8096 are dropped on the INPUT chain of the host, which is the same failure described for Transmission in the errors section near the end.

Seerr setup wizard Sign In step pointed at the Jellyfin server on port 8096

On the next screen, sync and enable both Jellyfin libraries. On Configure Services, add a Radarr server with hostname radarr, port 7878 and the Radarr API key, click Test, then pick a quality profile and the root folder /data/media/movies. Add Sonarr the same way with hostname sonarr, port 8989 and the root folder /data/media/tv, then finish the wizard. A film requested in Seerr now flows through Radarr, Prowlarr and Transmission into Jellyfin, and Seerr marks it Available with a Play on Jellyfin button once Jellyfin has scanned it in.

Seerr movie page showing Big Buck Bunny available with a Play on Jellyfin button

Bazarr completes the video side, but it does nothing until four things are set at http://HOST_IP:6767. Under Settings, then Sonarr and Settings, then Radarr, enable each with host sonarr or radarr, its port and its API key. Under Settings, then Languages, create a language profile and set it as the default for both series and movies; without a default profile Bazarr tracks nothing and never downloads a subtitle. Under Settings, then Providers, add at least one provider, such as OpenSubtitles with your account. We left Bazarr unconfigured in this lab, so these steps are the standard Bazarr setup rather than something this guide tested.

To test the import path without touching anyone else’s copyright, we used Big Buck Bunny, the Creative Commons film from the Blender Foundation, which is widely seeded as a public torrent. We added it to Radarr, downloaded it through Transmission into the radarr category, and imported it with the Hardlink/Copy Files import mode. Then we asked the filesystem what it had actually done:

find /srv/data -name "*.mp4" -printf "%n links  inode %i  %p\n"
zfs list -o name,used,refer tank/data

This is what both commands printed once Radarr had imported the film:

Radarr import and seeding copy sharing one inode on a single ZFS dataset

Both paths report the same inode and a link count of 2, so the 276 MB file exists on disk once while it seeds from downloads/ and plays from media/. Before the music test, tank/data held 264 MB in total for the film and its seeding copy together. The 1.14G in the screenshot is after the FLAC album below was added. If the link count reads 1 on your system, downloads and media are on different filesystems or behind different bind mounts, and the section on the single dataset above is where to look.

One trap here: Grabs that Radarr makes itself hardlink automatically, but its Manual Import button (in the toolbar of Wanted, then Missing) asks you to choose an import mode before it does anything, and remembers that choice in your browser. Move Files pulls the file out from under Transmission, which then reports missing data and stops seeding. Pick Hardlink/Copy Files and Radarr creates the hardlink instead.

Step 12: Add the music side with DroppedNeedle, slskd and Navidrome

The music services are already running from the Compose file, but slskd needs credentials and a few rules before DroppedNeedle can use it. Start with a fresh set of values for this section: a Soulseek username nobody has taken yet, its password, a password for the slskd web UI, and an API key that DroppedNeedle will use to drive slskd:

export SOULSEEK_USER="medianode$(openssl rand -hex 3)"
export SOULSEEK_PASS="$(openssl rand -hex 12)"
export SLSKD_WEB_USER="admin"
export SLSKD_WEB_PASS="$(openssl rand -hex 12)"
export SLSKD_API_KEY="$(openssl rand -hex 32)"
echo "Soulseek user: ${SOULSEEK_USER}  slskd web password: ${SLSKD_WEB_PASS}  API key: ${SLSKD_API_KEY}"

Save the printed values in your password manager; the API key goes into DroppedNeedle in a moment. Now open the slskd configuration file:

sudo vim /opt/media/slskd/slskd.yml

Add the following. The placeholders are filled in by the next command:

soulseek:
  username: SOULSEEK_USER_HERE
  password: SOULSEEK_PASS_HERE
  listen_port: 50300

directories:
  downloads: /data/downloads/slskd/complete
  incomplete: /data/downloads/slskd/incomplete

shares:
  directories:
    - /data/media/music

web:
  port: 5030
  authentication:
    username: SLSKD_WEB_USER_HERE
    password: SLSKD_WEB_PASS_HERE
    api_keys:
      droppedneedle:
        key: SLSKD_API_KEY_HERE
        role: readwrite
        cidr: 172.16.0.0/12

The shares block deserves a sentence. Soulseek is a sharing network, and while the server does not force anyone to share, many peers ban or deprioritise users who share nothing, and slskd itself puts non-sharing users into a slow leecher group by default, so other slskd users treat you the same way. Sharing the music library read-only keeps your downloads moving. The API key is restricted to the Docker bridge range, so only containers on the board can use it. Fill in the placeholders, lock the file down and restart slskd:

sudo sed -i "s|SOULSEEK_USER_HERE|${SOULSEEK_USER}|; s|SOULSEEK_PASS_HERE|${SOULSEEK_PASS}|; s|SLSKD_WEB_USER_HERE|${SLSKD_WEB_USER}|; s|SLSKD_WEB_PASS_HERE|${SLSKD_WEB_PASS}|; s|SLSKD_API_KEY_HERE|${SLSKD_API_KEY}|" /opt/media/slskd/slskd.yml
sudo chown apps:media /opt/media/slskd/slskd.yml
sudo chmod 600 /opt/media/slskd/slskd.yml
sudo docker compose -f /opt/media/compose/docker-compose.yml restart slskd

The log confirms the login, and the first login is also the registration, and no separate sign-up step exists:

sudo docker logs slskd 2>&1 | grep -E "Connected|Logged in"

Our new account logged straight in:

[19:11:47 INF] Connected to the Soulseek server
[19:11:47 INF] Logged in to the Soulseek server as medianodefa3631

Open Navidrome at http://HOST_IP:4533. On the very first visit it asks you to create the administrator account; do that and leave it, since the library is empty for now. Then open DroppedNeedle at http://HOST_IP:8688, which also starts with an account setup screen. Three settings connect it to the rest of the stack, all under Settings:

  • Library: click Add root and enter /data/media/music with the Automatic identification policy. The default /music root points at a folder that does not exist in this layout, so remove it.
  • Download Client: enable slskd with the URL http://slskd:5030, the API key from the export block, and /data/downloads/slskd/incomplete as the slskd incomplete folder.
  • Navidrome: enter http://navidrome:4533 and the Navidrome administrator you just created, enable the integration, click Test Connection, then Save Settings.

Search for an album, open it and click Acquire this edition. For the test we picked Nine Inch Nails’ The Slip, which the band released under a Creative Commons licence:

DroppedNeedle album page with The Slip queued for acquisition through slskd

DroppedNeedle found a FLAC source on Soulseek, downloaded the ten tracks through slskd, tagged them and moved them into /data/media/music/Nine Inch Nails/The Slip (2008)/. The slskd complete and incomplete folders were empty again afterwards, because the move was a rename within the same dataset. One track crawled at around 100 KB/s while our library was still empty, which is consistent with the uploading peer throttling a user who shared nothing. Navidrome needed no help. Its log, read with sudo docker logs navidrome, shows the watcher picking the album up:

time="2026-10-05T16:32:02Z" level=info msg="Scanner: Completed processing folder" audioCount=10 elapsed=21.1ms imageCount=0 library="Music Library" plsCount=0 tracksImported=1 tracksMissing=0 folder="Nine Inch Nails/The Slip (2008)"
time="2026-10-05T16:32:02Z" level=info msg="Scanner: Finished scanning selected folders" duration=32.6ms numTargets=1
time="2026-10-05T16:32:02Z" level=info msg="Watcher: Scan completed"

The Navidrome file watcher saw the new folder and scanned it in 33 milliseconds, without waiting for the ND_SCANNER_SCHEDULE fallback scan in the Compose file. Inotify events cross container boundaries on a shared ZFS dataset, so the album was playable in any Subsonic client (Feishin, Symfonium, Substreamer) a few seconds after DroppedNeedle finished:

Navidrome albums view showing The Slip after DroppedNeedle imported it

Add the second disk as a mirror later

The board has two SATA ports, and nothing forces you to fill both on day one. A single-disk pool has no redundancy, but ZFS can turn it into a mirror at any time without downtime: zpool attach adds the new disk alongside the existing one and copies the data across while everything keeps running. Find the ID of the new disk the same way as in Step 4. You are probably in a new shell by now, so set both disks again; DISK1 is the name zpool status tank shows today:

export DISK1="/dev/disk/by-id/ata-QEMU_HARDDISK_BAY1"
export DISK2="/dev/disk/by-id/ata-QEMU_HARDDISK_BAY2"
sudo zpool attach tank "${DISK1}" "${DISK2}"
zpool status tank

Note that the command is attach, not add. zpool add would stripe the second disk next to the first, doubling capacity but making the loss of either disk fatal. Undoing that with zpool remove is possible, but it copies all the data off the disk and leaves a permanent remapping table in the pool, so get the verb right the first time. Our resilver copied 1.14 GB in one second; on real disks, expect roughly the time it takes to read the used space sequentially.

ZFS mirror pool from by-id disk names on a ZimaBoard media server

The Debian zfsutils-linux package already ships /etc/cron.d/zfsutils-linux, which scrubs every pool on the second Sunday of each month, so the mirror is checked for silent corruption without any extra timer.

What happens on a power cut

A board like this usually sits on a shelf without a UPS, so we pulled the virtual power cord (qm stop, which is a hard power-off, not a shutdown) and timed the recovery. SSH answered 14 seconds after power-on. zfs-mount.service became active at 16:33:39 and docker.service at 16:33:46, so the drop-in from Step 6 did its job and Docker waited for the dataset. All eleven containers came back on their own through restart: unless-stopped, the pool reported healthy, the DOCKER-USER rules were still in place, and zram came back at 2 GB. Nothing needed a manual fix.

Errors we hit while building this

Each of these came up during the lab build, with the exact message and the fix.

modprobe: FATAL: Module zfs not found in directory /lib/modules/6.12.74+deb13+1-amd64

The zfs-dkms build succeeded, but for the newer kernel that linux-headers-amd64 pulled in, not for the kernel that was running. dkms status shows the giveaway: the module is listed as installed for a different kernel version than uname -r. Run sudo apt full-upgrade -y, reboot into the new kernel, and modprobe zfs works. Step 3 puts the upgrade first so you never see this.

Allowed Hosts is required when ‘Authentication Required’ is not ‘Enabled’

Radarr, Sonarr and Prowlarr reject the authentication dialog with this message when Allowed Hosts is empty and Authentication Required is set to “Disabled for Local Addresses”. The field is a list of hostnames and IPs the app accepts requests for. Fill it with the board IP, its hostname and the container name, as in Step 10.

Unexpected response: 403: Forbidden from transmission-remote

transmission-remote with no host argument connects to localhost, which resolves to ::1 inside the container. The RPC whitelist only lists IPv4 ranges, so Transmission refuses the connection. Pass 127.0.0.1:9091 explicitly.

Radarr: Unable to connect to Transmission

We saw this on an earlier build that addressed Transmission by the host IP (192.168.1.171:9091) instead of its container name. A container connecting to the IP of its own host is not translated by Docker, so the packets arrive on the INPUT chain of the host from a 172.x address and the UFW default deny drops them. Using container names for every app-to-app connection avoids the problem entirely; the single 172.16.0.0/12 rule on port 8096 covers the one exception, Jellyfin on the host network.

Seerr returns 403 on every settings call before setup

Seerr refuses all settings API calls until its setup wizard has been completed once, so any script that tries to pre-configure Radarr and Sonarr in Seerr fails on a fresh install. Finish the wizard in the browser first; scripted changes work afterwards.

Jellyfin returns 401 to a script that worked before

Jellyfin 12 turned off legacy authorization by default, which covers the X-Emby-Token header and the api_key query parameter that many older scripts and integrations still send. A collection-sync script that had worked for months returned 401 on every call after the upgrade. Send Authorization: MediaBrowser Token="your-api-key" instead, which every current Jellyfin release accepts.

Where this design breaks down

The layout above is tuned for one household on one small board, and it is worth knowing where its assumptions stop holding. Memory is the first limit. With everything settled the board still had 5.9 GiB available, but the caps already commit about 7 GiB in the worst case, so adding Immich with its machine-learning container, a Home Assistant instance and a Nextcloud on top would push the board into swap. The 16 GB ZimaBoard 2 1664 model is the answer to that, since nothing on this board can be upgraded.

Transcoding is the second limit, and the one our rehearsal could not measure. Jellyfin uses no CPU at all for direct play, but a remote phone on a slow connection forces a transcode, and the N150’s four cores cannot transcode a 4K HEVC file in software in real time. Quick Sync handles that in hardware, so pass /dev/dri through before you rely on remote viewing, and check transcode activity in the Jellyfin dashboard the first time someone watches over the internet through your VPN. For real numbers before you rely on it, our transcoding GPU guide measured how many concurrent 4K HDR streams two Intel iGPUs sustain in a Proxmox LXC, with the script we used.

Redundancy is the third. A single-disk pool tells you about corruption through its checksums, but it can’t repair it, and it does nothing for a dead drive. Until the second bay is filled and attached as a mirror, back up whatever in /srv/data/media you could not download again. The application state on the NVMe deserves a backup too, because /opt/media holds every library database, request history and API key; copying it to the pool with a nightly rsync turns an NVMe failure into a reinstall plus a restore. If you are still weighing this board against a ready-made NAS, the home NAS comparison covers the boxes that solve those three limits with more bays and more memory, at a higher price.

Keep reading

Configure Samba File Share on Debian 13 / 12 Debian Configure Samba File Share on Debian 13 / 12 Backup and Restore Linux Systems with Timeshift Debian Backup and Restore Linux Systems with Timeshift Install KVM on Debian 13 / Debian 12: Complete Guide KVM Install KVM on Debian 13 / Debian 12: Complete Guide Best Synology NAS for Home, Plex and Backups: DS225+ vs DS425+ vs DS925+ vs DS1525+ Storage Best Synology NAS for Home, Plex and Backups: DS225+ vs DS425+ vs DS925+ vs DS1525+ Install Coolify on Ubuntu 26.04 LTS (Self-Hosted PaaS) Cloud Install Coolify on Ubuntu 26.04 LTS (Self-Hosted PaaS) Install Kubernetes (kubeadm) on Ubuntu 26.04 LTS Containers Install Kubernetes (kubeadm) on Ubuntu 26.04 LTS

Leave a Comment

Press ESC to close