Building a 4K media server on a UGREEN DXP4800 Pro
Jellyfin, the *arr stack, a VPN for torrents only and real HTTPS on the LAN. What I built, why, and everything that broke.
I wanted one button. Press Request on a movie, and a few minutes later it is in Jellyfin: found, downloaded, renamed, filed, subtitled. No SSH, no folder shuffling, no “why is this 720p”.
I got there in September 2026 on a UGREEN DXP4800 Pro. This post is the design and the reasons behind it, plus the parts that fought back.
What I asked for
- Jellyfin with hardware transcoding on the CPU’s Intel Quick Sync.
- Full automation from request to library.
- Torrent traffic behind a VPN, but not the whole NAS.
- Trusted HTTPS on every web UI, with no browser warnings.
- Two libraries: English, and Indian (mostly Malayalam) movies and series.
- Best quality available, 4K HDR when it exists, a graceful fallback when it doesn’t.
The hardware
| Part | Detail | Job |
|---|---|---|
| NAS | UGREEN DXP4800 Pro, 4 bays, UGOS (Debian-based) | Runs everything |
| CPU | Intel Core i3-1315U | Quick Sync decodes H.264, HEVC, VP9 and AV1 and encodes H.264 and HEVC |
| RAM | 8 GB, stock | Idles well under 4 GB |
| HDD | 10 TB Seagate | Media and torrents |
| SSD | 1 TB NVMe | Docker, databases, transcode cache |
| Network | UniFi Express 7 | DHCP, local DNS, firewall |
Is 8 GB enough? Yes. Each *arr app uses 150 to 300 MB, Jellyfin about 500 MB idle, qBittorrent a few hundred. Transcoding happens on the integrated GPU, so it does not eat RAM the way software transcoding would. I would only upgrade for something heavy like Immich with machine learning.
One folder tree, hardlinks everywhere
Almost every serious *arr setup follows the TRaSH Guides. Its most important rule is that downloads and media live in one /data tree on one filesystem, and every container sees the same paths.
/data
├── torrents/{movies,tv} qBittorrent drops finished downloads here
└── media/
├── movies/ tv/ → Jellyfin "Movies", "TV Shows"
└── movies-indian/ tv-indian/ → Jellyfin "Indian Movies", "Indian TV"
Because both folders share a filesystem, importing a download is a hardlink. It is instant and costs zero extra disk space. With separate /downloads and /movies mounts the same import is a full copy: slow, double the space while seeding, and hard on the HDD. After my first import the file showed links=2, the same inode in both places.
Two pools, and no SSD cache
| Pool | Disk | Filesystem | Holds |
|---|---|---|---|
/volume1 |
10 TB HDD | ext4 | torrents and media |
/volume2 |
1 TB NVMe | Btrfs | app configs, databases, caches |
I made the SSD its own pool instead of using it as a cache. A 60 GB movie is read sequentially once, and the HDD manages 200 MB/s, far more than any stream needs. What benefits from flash is the small random I/O of the SQLite databases. With those on the SSD the UIs feel instant and the HDD can spin down when nobody is watching.
ext4 on the HDD because torrents write in random order and copy-on-write fragments them. Btrfs on the SSD because snapshots are the cheapest backup for app configs.
How a request travels
- You request a title in Seerr, which hands it to Radarr (movies) or Sonarr (TV).
- They search your indexers through Prowlarr.
- The best release goes to qBittorrent, which lives inside the VPN.
- It downloads on the SSD, then moves to
/data/torrentson the HDD. - Radarr or Sonarr hardlink it into
/data/mediawith a clean name. - They tell Jellyfin to refresh, Bazarr fetches subtitles, and the torrent is removed.
The whole stack is a single Docker Compose file, so containers reach each other by name (http://sonarr:8989) and there are no IPs to maintain. One UGOS quirk cost me an evening: its Docker “Project” deploys from its own copy of the file, not from the compose.yaml you put beside it.
Real HTTPS without opening a single port
Plain HTTP on a LAN is fine until a mobile client refuses to talk to a self-signed certificate. I wanted a real one for services that only exist inside the house.
The trick is a real domain plus the DNS-01 challenge. Traefik proves it owns the domain by writing a TXT record through the Cloudflare API. Let’s Encrypt checks public DNS and issues a wildcard certificate for *.home.example.com. Nothing is exposed, no ports are forwarded, and Traefik renews it on its own. UniFi answers those names locally, so they only resolve inside the house.
command:
- --providers.file.filename=/traefik/dynamic.yml # static routes, no docker.sock
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.http.tls.certresolver=cf
- --entrypoints.websecure.http.tls.domains[0].sans=*.home.example.com
- --certificatesresolvers.cf.acme.dnschallenge.provider=cloudflare
Most Traefik guides discover services through the Docker socket. I skipped that. Mounting docker.sock hands root on the host to anything that compromises Traefik. A short static routes file does the same job for nine services, and it is boring in the good way.
Only the torrent client goes through the VPN
UniFi can policy-route a device through a VPN, but that sends the whole NAS through it. Jellyfin, metadata lookups and updates would all slow down or break. So I tunnel only what needs it, using gluetun with NordVPN over WireGuard.
gluetun:
image: qmcgaw/gluetun:v3
cap_add: [NET_ADMIN]
devices: [/dev/net/tun:/dev/net/tun]
environment:
VPN_SERVICE_PROVIDER: nordvpn
VPN_TYPE: wireguard
WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY}
SERVER_CATEGORIES: P2P
qbittorrent:
network_mode: service:gluetun # no VPN = no network
depends_on:
gluetun: { condition: service_healthy, restart: true }
qBittorrent, Prowlarr and FlareSolverr share gluetun’s network namespace. If the tunnel drops, they have no network at all. I tested it: stop gluetun and qBittorrent cannot reach anything.
The security detail I like most: I removed the setting that lets gluetun see the home subnet. From inside the tunnel, the NAS admin page and the gateway now time out. The web UIs still work, because Traefik and Docker’s own network reach in from the other side.
Two lessons from the VPN:
- Pick P2P servers. With only a country set, gluetun once landed on a congested server and a test torrent crawled from 8.5 MB/s to 2 MB/s. Restricting it to NordVPN’s P2P category fixed that, and an HTTP test through the tunnel then hit 35.8 MB/s.
- No port forwarding. NordVPN does not offer it, so seeding is weak. I turned seeding off entirely. There is no private-tracker ratio to protect, and it keeps upload bandwidth free.
Best quality, automatically
Hand-tuning quality profiles means maintaining hundreds of release-group rules. Recyclarr syncs TRaSH’s maintained profiles into Sonarr and Radarr every day. I chose UHD Bluray + WEB for movies and WEB-2160p for TV.
I deliberately skipped “Remux”. Remuxes are the untouched disc, often 60 to 80 GB per movie, and some clients choke on them. Good 4K encodes and WEB-DLs are 15 to 30 GB and look nearly identical on a TV.
Then came the trap. A stock 4K profile accepts only 2160p. Older seasons of TV shows and most older or Indian films have no 4K release, so they were silently never downloaded. Nothing errored. The way to see it is Interactive Search, where every result had a red icon reading “WEBDL-1080p is not wanted in profile”. The fix was fallback qualities: take 4K when it exists, otherwise 1080p (and 720p for TV), and upgrade later if something better appears.
Indian titles, filed automatically
I wanted a request for a Malayalam film to land in the Indian library with no extra clicks. It took three attempts.
- Seerr override rules. They skip anyone with the Manage Requests permission, which includes every admin. Dead end.
- A language list. It worked, but I would have to list every Indian language and keep it current.
- Country-based routing. Radarr and Sonarr do not store a title’s country, but Seerr does, because it proxies TMDB. A small script runs on On Movie Added and On Series Add, asks Seerr for the details, and moves the title to the Indian root folder when the country includes India and the original language is not English. That last condition keeps Hollywood films shot in India where they belong.
Because it runs inside Radarr and Sonarr, it works however a title was added, and it runs before the first search starts.
Jellyfin and the integrated GPU
With Quick Sync enabled, a forced 4K HDR HEVC to 1080p SDR H.264 transcode ran at about 150 fps, roughly 6x real time, with the CPU nearly idle. That is enough for three or four simultaneous 4K HDR transcodes. In practice most clients direct-play and nothing transcodes at all.
One setting I left off on purpose: low-power encoders. They need firmware loaded by the host kernel, and a playback error is not worth a small efficiency gain.
On Apple TV, use Infuse, not Swiftfin. My first 4K remux was 66.7 GB with Dolby Vision profile 7 and TrueHD Atmos, and Swiftfin gave up. The Apple TV cannot decode that combination directly, so Jellyfin tried to transcode it. Infuse direct-plays nearly everything, and turning on “Match Dynamic Range” and “Match Frame Rate” on the Apple TV finishes the job.
Keeping the downloads safe
A media server pulls files from strangers, so I treat the download folder as hostile.
- Only video and subtitles reach the disk. Cleanuparr’s malware blocker marks anything else in a torrent as “don’t download”, using an allowlist of mkv, mp4, avi, m4v, srt, ass, ssa and sub. qBittorrent also blocks 34 risky extensions such as
.exe,.msi,.scr,.ps1,.lnkand archives. - The VPN side cannot see the LAN, as above.
- Snapshots. The SSD’s
dockershare is snapshotted daily and the last seven are kept. - File services off. SMB, FTP, NFS, rsync and WebDAV are all disabled. The family uses Jellyfin and nothing else.
The network matters just as much as the NAS. The other half of this story is how I split the house into zones, in Putting my smart home in a box.
What broke
- “Permission denied” everywhere. UGOS layers its own ACLs on shared folders, and a new subfolder copies the share’s ACL at that moment. My app folders existed before the service user had access, so
chownchanged nothing. Recreating each folder fixed it. Grant access to the share before creating subfolders. - Traefik served its default certificate. The UniFi gateway intercepts all outbound DNS on port 53, so Traefik’s “can I see my TXT record yet?” check never saw it. Skipping that local check and waiting 60 seconds let Let’s Encrypt verify from the internet, and the certificate arrived seconds later. The mechanism is drawn in the other post.
- A config file emptied itself. I piped
ssh host 'cat f' | transform | ssh host 'cat > f'. Both ends start at once, so the writer truncated the file before the reader read it. Read into a temp file first, then write. - Movies never appeared in Jellyfin. Its file watcher does not reliably fire for files created by other containers. A Jellyfin connection in Radarr and Sonarr triggers a refresh on every import instead.
- SSH kept turning itself off. UGOS has an auto-shutdown timer on SSH, three hours by default.
What is next
Backups beyond snapshots, with the app folder copied off the box. A second 10 TB disk to turn the data pool into RAID 1. And remote access, which is its own puzzle because my ISP’s router puts the whole network behind a second NAT.
If you build something similar, start with the TRaSH folder layout and put the VPN around the torrent client, not the machine. Those two decisions removed most of my problems before they started.