Back
13 min read

My homelab: one $300 NAS, a Raspberry Pi, and zero waste

Everything I self-host runs on a single ~$300 N100 NAS that sips about 10 watts: roughly 20 Docker containers, a full media pipeline, DNS-level ad-blocking, and remote access with zero open ports. Here's the full tour — and why every piece is where it is.

The one-paragraph version

Everything I self-host runs on a single UGREEN NAS with an Intel N100, 8 GB of RAM, four SATA bays and two NVMe slots, sitting next to a Raspberry Pi that owns the smart home. The NAS runs ~20 Docker containers — a full media-acquisition-and-streaming pipeline, DNS-level ad-blocking, a reverse proxy, remote access, and disk health monitoring — and it does it on a chip that sips ~10 W. The Pi runs Home Assistant. The two talk over the LAN. The entire thing is reachable from my phone anywhere in the world, ad-free, behind friendly name.nas URLs, with no ports forwarded to the internet. This post is the full tour and, more importantly, why every piece is where it is.

The hardware, and why this hardware

BoxSpecJob
UGREEN NAS "Home-NAS"Intel N100 (4c/4t), 8 GB RAM, Debian 12 (UGOS Pro)Everything except the smart home
Raspberry Pi (192.168.0.78)HAOSHome Assistant, Zigbee, MQTT

Why the N100? It's the sweet spot of the moment. Four modern Alder-Lake-E cores are enough to run twenty containers, and — critically — it has Intel Quick Sync Video baked in. That iGPU is the single most important feature in the whole build, and I'll come back to it under Jellyfin. Idle power is in the ~10 W range, which matters when the thing runs 24/7/365. As I write this it's been up 15 days with a load average around 10 — and that "high" load is almost entirely one SABnzbd download unpacking, not the box struggling.

Why a separate Pi for the smart home? This is the most deliberate architectural decision in the build, so it gets its own section.

Decision #1: the smart home does NOT live on the NAS

It used to. On 2026-06-06 I migrated Home Assistant, the Mosquitto MQTT broker, Zigbee2MQTT, and the physical Zigbee USB coordinator off the NAS and onto a dedicated Raspberry Pi. Reasons:

But I didn't want two separate worlds. So Home Assistant is still reachable at http://home.nas like every other service — the NAS's Caddy reverse-proxies that hostname across the LAN to the Pi (reverse_proxy 192.168.0.78:8123), and my dashboard has a Smart Home tile that health-checks the Pi. Verified live just now: HA answers HTTP 200 both directly and through the proxy.

The gotcha that cost me an afternoon, recorded so I never repeat it: HA returns 400 Bad Request behind a reverse proxy unless you tell it to trust the proxy. The fix is use_x_forwarded_for: true plus Caddy's macvlan IP 192.168.0.93/32 in trusted_proxies in the Pi's configuration.yaml. There's a second subtle one: the Pi's SSH add-on rebuilds its authorized_keys on every restart, so the only durable place for my NAS's access key is the add-on's Configuration screen — not the usual ~/.ssh/authorized_keys.

Decision #2: storage is tiered by speed, not lumped together

The NAS has six drives, and they are emphatically not one big pool. They're split by the job each storage type is good at:

3 × 4 TB SATA (Seagate)  → mdadm RAID5  → btrfs /volume2 (~7.3 TB)  → BULK MEDIA
1 × NVMe  WD SN770       →                btrfs /volume3 (~960 GB)   → ALL app configs/appdata
1 × NVMe  Samsung 980    →                btrfs /volume4 (~960 GB)   → SCRATCH (in-progress, transcodes)
1 × ~931 GB SATA Toshiba →  RAID1         → ext4  /volume1            → OLD DATA, kept as safety net

The reasoning:

One key subtlety: SAB's completed downloads land on /volume2 (the same filesystem as the media libraries) so the *arr apps can hardlink imports instead of copying them. A hardlink is instant and uses zero extra space — the file exists in both Downloads/ and Movies/ simultaneously. The incomplete dir is the one on scratch NVMe. That split is intentional.

The Toshiba on /volume1 is the pre-migration original data, deliberately kept online as a baking-period safety net before I wipe it and swap in a 3 TB disk for extra bulk. Belt and braces.

Decision #3: everything is Docker, one project per concern

Every service is a Docker Compose project under /volume3/docker/<name>/. Mostly LinuxServer.io images, all standardized on PUID=1000 PGID=10 TZ=Europe/London. This is boring on purpose — boring is reproducible.

Here's everything running right now (verified live). The media pipeline — `arr-stack` project, one compose file:

ServicePortRole
Sonarr8989TV: what to get, quality, renaming
Radarr7878Movies: same
Lidarr8686Music
Prowlarr9696Indexer hub — feeds the above
Bazarr6767Subtitles
SABnzbd8080The actual Usenet downloader
LazyLibrarian5299Ebooks/audiobooks (Readarr's dead)
Jellyseerr5055The "request a movie" front door
Navidrome4533Music streaming
Kavita5000Ebook reader
Audiobookshelf13378Audiobook player

Standalone infrastructure projects:

ServiceRole
Jellyfin (8899)The media server itself
CaddyReverse proxy → *.nas URLs
AdGuard HomeNetwork DNS + ad-blocking
TailscaleZero-config remote access + subnet router
ScrutinySMART disk-health monitoring
Homepage (dash.nas)The single dashboard tying it together
WatchtowerNightly auto-updates
session-managerDrive Claude Code / Copilot from my phone
macvlan-shimA networking band-aid (see below)

Parked until needed: Immich (photos — RAM-hungry) and Duplicati. They're stopped, not deleted, because RAM is precious.

How the media pipeline actually flows

I (or my wife) ask for a movie in Jellyseerr → it tells Radarr → Radarr asks Prowlarr's indexers where to find it → hands the NZB to SABnzbd → SAB downloads to scratch NVMe, unpacks, drops the finished file on the bulk array → Radarr hardlinks it into the library and pings Jellyfin to scan → it appears on the TV. Bazarr quietly fetches subtitles. Nobody touches a terminal.

Decision #4: friendly names and no open ports

Three pieces make the network feel like a polished product instead of a pile of IP:port combos:

The clever bit: because Tailscale uses split-DNS to point .nas at AdGuard, the friendly names and the ad-blocking follow me off the LAN. On cellular data, jelly.nas still resolves and ads are still blocked.

The one wart, honestly recorded: macvlan network isolation means the NAS host can't reach Caddy's .93 address directly. The fix is the macvlan-shim container — a tiny Alpine container that creates a 192.168.0.94 shim interface and routes .93 back to the host. It's a hack, but it's a documented, restart-surviving hack, and it's the reason remote .nas access works end-to-end. (Note to self that's now in the runbook: .93 ignores ICMP, so test it with curl, never ping.)

Squeezing every drop out of an 8 GB / 10 W box

This is the part I'm proudest of. Running this much on an N100 with 8 GB isn't an accident — it's a series of deliberate trades.

1. Quick Sync is the whole ballgame for Jellyfin. The N100's iGPU is passed into the Jellyfin container (/dev/dri/renderD128, verified live). That means video transcoding — re-encoding a 4K HDR file on the fly so it plays on a phone — happens on fixed-function silicon, not the CPU. A software transcode would peg all four cores and melt; QSV does the same job at a few watts and leaves the CPU free for everything else. With HDR tone-mapping handled in hardware too, one cheap chip serves the whole house. Without Quick Sync, this build does not exist — you'd need a much bigger, hotter, pricier machine.

2. Memory discipline, treated as a real constraint. 8 GB with swap genuinely in use (3 GB swapped right now) forces honesty. Here's where the RAM actually goes, from a live snapshot:

sabnzbd        380 MB   (and 152% CPU — actively downloading as I write this)
jellyfin       158 MB   (idle; QSV means streaming barely touches RAM)
jellyseerr     138 MB
radarr         107 MB
adguardhome    101 MB
tailscale       84 MB
sonarr          75 MB
...
audiobookshelf   5 MB
macvlan-shim   316 KB

The whole media pipeline at rest fits in a couple of gigs. The discipline: RAM-hungry services (Immich, Duplicati) stay parked and are started on demand, SAB's article cache is kept small, and anything that idles cheap (Audiobookshelf at 5 MB!) is left running. Nothing is running "just in case."

3. Tiered storage means each disk only does what it's fast at. Covered above, but it's a juice-squeezing move as much as a reliability one: the bulk array never gets thrashed by transcodes or unpacks, the config NVMe never competes with media streaming, and the scratch NVMe absorbs all the disposable write-heavy churn — extending the life of the drives that hold data I actually care about.

4. Hardlinks, not copies. The same-filesystem hardlink trick means a 40 GB movie doesn't briefly become 80 GB during import. Across a library this saves enormous space and wear.

5. Auto-pilot maintenance. Watchtower updates a safe allowlist of containers nightly at 04:00 and prunes old images, so disk doesn't slowly fill with stale layers. Scrutiny watches SMART on all six disks — important here because the 4 TB media drives are used drives with 3.6–5.2 years of power-on hours, so a RAID5 rebuild is a genuine risk I monitor for rather than discover.

6. The meta-move: the box manages itself. There's a session-manager web app and a host-native session daemon that let me launch Claude Code sessions from my phone. The runbooks for this entire homelab are written as structured "skills" the agent reads — so fixing a stuck container or adding a service is a phone-from-the-pub operation, not a sit-at-the-desk one. The homelab documents and operates itself.

The architecture in one diagram

The honest summary

This build is a thesis: you don't need a rack, you need the right ~$300 chip and a tiered design. One N100 with Quick Sync replaces a power-hungry transcoding server. Splitting the smart home onto a Pi keeps the lights on while I tinker. Tiering storage by speed makes spinning rust and NVMe each do what they're best at. Tailscale gives me the whole thing in my pocket without exposing a single port. And ruthless memory discipline keeps twenty services humming in 8 GB. Every watt and every gigabyte is accounted for — which is the entire point.