Published on

Self-Hosting and My Home Network Setup

1731 words9 min read
Authors
  • avatar
    Name
    Kevin Morales
    Twitter

Running your own infrastructure at home sounds straightforward until you're debugging why files are landing with the wrong group ID at 11pm. This is a rundown of my current setup, the decisions behind it, and the things that actually went wrong.


Hardware

The setup is two machines: a Beelink GTi13 mini PC running Ubuntu 24.04 as the compute node, and a UGREEN DXP4800 Plus as the NAS for persistent storage.

The GTi13 is an i9-13900HK, 32GB DDR5, with an Intel Iris Xe GPU. The Iris Xe matters because it supports Intel Quick Sync Video, which handles hardware transcoding for Plex at around 7.5% CPU versus roughly 142% for software transcoding of the same stream. For a machine doing multiple other things simultaneously, that difference is significant.

The NAS sits at around 4TB usable on BTRFS, exporting storage over NFS to the GTi13. Keeping compute and storage on separate machines means I can upgrade either independently, and the NAS stays online if I need to reboot the media server.

One problem I ran into on the GTi13 was Ubuntu's default LVM install only claiming 100GB of the 928GB NVMe. The fix is straightforward but not obvious if you haven't hit it before:

sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv
sudo resize2fs /dev/ubuntu-vg/ubuntu-lv

Network

Six VLANs on a Firewalla Gold, all going through a Netgear MS308E as the core switch with two Netgear GS308EP access switches in the office and basement.

VLANPurpose
Main (untagged)Primary LAN, Orbi mesh
Gaming (20)Gaming consoles and PCs
Work (30)Work devices
Streaming (40)Roku, Apple TV, streaming clients
IoT (50)Smart home devices
Media (60)Media server, NAS, Pi-hole

The Media VLAN is isolated from everything except explicit cross-VLAN rules that allow Streaming, Gaming, and Main LAN to reach the media server on port 32400. IoT gets no cross-VLAN access at all. Smart home devices have terrible update track records, and there's no reason a thermostat needs to be able to initiate connections to anything on the LAN.

The Streaming VLAN specifically exists because Roku and similar devices don't support local DNS servers cleanly and tend to hard-code DNS servers. Pi-hole handles DNS for all other VLANs at the network level, so the streaming devices get their own isolated segment where I can apply different rules.


DNS

Pi-hole runs on the NAS at a static IP in the Media VLAN and serves as primary DNS for every other VLAN. Upstream DNS goes through DNSCrypt-proxy co-located on the same machine, so DNS queries leave the house encrypted.

The Pi-hole's most useful function for a multi-VLAN setup is that you can apply different block lists per client group. IoT devices get stricter blocking than work devices. The Firewalla Gold pushes the Pi-hole address as DHCP DNS for all VLANs, so devices pick it up without manual configuration.

One thing to get right from the beginning: set a static IP for the Pi-hole before pointing DHCP at it. If Pi-hole goes down and you haven't thought about a fallback upstream DNS, every device on the network stops resolving names. I set Firewalla's own IP as the secondary DNS specifically to avoid this.


NFS and the Permissions Problem

This is the part of the setup that took the most debugging time.

NFS exports from the NAS use root_squash with anonuid=1000 and anongid=100. Root squash means root on the client gets squashed to the anonymous user identity on the NAS side. The anonuid and anongid values determine what UID and GID those squashed writes land as.

The Docker containers on the GTi13 all run with PUID=1000 and PGID=100. The GID 100 corresponds to the users group, which is a common convention on NAS systems like Synology and QNAP. Files written by containers with PGID=100 need to match what the NAS expects.

The bug was inconsistency. Some containers were configured with PGID=1000 (the default for the user's primary group on Ubuntu) instead of PGID=100. Files those containers wrote landed on the NAS as GID 1000. Other containers with the correct PGID=100 couldn't read those files. The fix is a bulk permission correction on the NAS:

sudo find /mnt/nas/media -not -group 100 -exec chown :100 {} \;
sudo find /mnt/nas/media -type f -exec chmod 664 {} \;
sudo find /mnt/nas/media -type d -exec chmod 775 {} \;

The UMASK setting matters too. UMASK=002 produces 664 for files and 775 for directories, which lets the group write. Without it, files land as 644 and services that need to rename or move files start throwing permission errors.

The NFS export for the /etc/exports on the NAS:

/volume1/media  10.0.60.10(rw,async,no_subtree_check,root_squash,anonuid=1000,anongid=100,sec=sys)

And the corresponding fstab entry on the GTi13:

10.0.60.2:/volume1/media  /mnt/nas/media  nfs  rw,hard,intr,nfsvers=3,rsize=1048576,wsize=1048576,timeo=600,retrans=2,noatime,nodiratime,tcp  0  0

The hard mount option means the client keeps retrying indefinitely if the NAS is unreachable rather than returning errors. For a media server, that's the right behavior. A soft mount would fail faster, but partial failures in the middle of a file operation tend to cause more problems than waiting for the NAS to come back.


Docker Compose Organization

Everything runs in Docker, split into three separate compose projects: a media server stack, a books stack, and a supporting tools stack. Keeping them separate makes restarts and updates easier since a change to one stack doesn't touch the others.

Cross-stack networking was the main design challenge. Docker compose projects each create their own bridge network by default, and containers in different projects can't reference each other by name. The solution is to define a named network in one project and reference it as external in the others:

In the primary stack:

networks:
  arr:
    driver: bridge

In a secondary stack that needs to reach containers in the primary:

networks:
  arr:
    external: true
    name: media-stack_arr

Any service in the secondary stack that needs cross-stack access gets networks: [default, arr]. Services that don't need it stay on default only.

When cross-stack networking doesn't work or isn't worth setting up for a one-off connection, using the host IP directly (10.0.60.10:PORT) is a valid fallback. It's less elegant but completely reliable.


Plex and Hardware Transcoding

Plex runs with network_mode: host rather than mapped ports. This is necessary for Plex's own network discovery to work correctly and for it to reliably detect which connections are local versus remote.

Hardware transcoding via Intel QSV requires passing the render device into the container and adding the container user to the correct groups:

devices:
  - /dev/dri:/dev/dri
group_add:
  - 'render'
  - 'video'

The render group GID on Ubuntu 24.04 is 993 by default. Verify it with getent group render before assuming it matches.

A recurring issue with Plex in a multi-VLAN setup: devices on VLANs other than the one Plex is on show as WAN connections in Tautulli. Plex doesn't automatically know which subnets are local. Fix it by going to Settings, Network, and adding all VLAN subnets to the LAN Networks field:

10.0.60.0/24,10.0.1.0/24,10.0.20.0/24,10.0.40.0/24,10.0.50.0/24

Also set the preferred network interface to the NIC on the Media VLAN. Otherwise Plex may bind to the wrong interface and cross-VLAN traffic gets routed inefficiently.

Transcode triggers worth knowing about for Roku specifically: Dolby Vision and HDR10 content requires tone-mapping to SDR, which forces a transcode regardless of bitrate settings. PGS image-based subtitles also require a transcode because they have to be burned in. SRT subtitles from an external source direct-play without any transcode overhead. Getting SRT subtitles automatically attached to everything that doesn't have them eliminates most of the transcode load from clients that would otherwise trigger it.


External Access

All external access goes through a Cloudflare Tunnel. No ports are open on the firewall.

The tunnel runs as a Docker container in the media stack, authenticated with a token from the Cloudflare Zero Trust dashboard:

cloudflared:
  image: cloudflare/cloudflared:latest
  container_name: cloudflared
  restart: unless-stopped
  command: tunnel run
  environment:
    - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}

Cloudflare routes specific hostnames to specific internal ports. media.example.com goes to the Plex port, books.example.com goes to Calibre-Web. Services that don't need external access don't get a hostname, and they stay completely inaccessible from outside.

The main limitation of this approach is latency. Traffic routes through Cloudflare's edge before reaching the server. For a media server, this matters for Plex specifically because Plex's relay detection can get confused. Setting the custom server access URL in Plex to the Cloudflare hostname and configuring a remote streaming bitrate cap handles this reasonably well. Plex on the LAN still direct plays over the local network; remote access goes through the tunnel at a capped bitrate.


Monitoring

Uptime Kuma runs on a separate machine on the Media VLAN and monitors 28 endpoints across both stacks. Alerts go to a Discord webhook.

Running the monitoring stack on the same machine as the things being monitored is a mistake. If the media server goes down, the monitoring goes with it, and you don't get alerted. Putting Uptime Kuma on the NAS or another always-on device means it's genuinely independent.


What I'd Do Differently

The permissions model should have been locked down before standing up any services. Getting the UID, GID, and NFS settings consistent from the start would have avoided a few hours of debugging files with wrong ownership spread across the NAS.

The Docker network topology also needs more thought upfront. Adding an external network to a compose project after the fact requires taking services down to reconfigure them. Knowing which stacks would need to communicate before splitting them would have saved some downtime.

The Firewalla Gold cross-VLAN rules are configured correctly now, but the initial assumption that Plex would work for all VLANs without explicit rules was wrong. Firewalla blocks cross-VLAN traffic by default. That's the correct default. Don't assume it will just work.