← Docs

Anatomy of an 8 GB Media Server

A full-stack review of a Mac Mini running nineteen containers and a native Plex install — and what a memory-starved box teaches about virtualisation, least privilege, and bugs that never log an error.

The constraint

The machine is a Mac Mini with 8 GB of soldered, non-upgradable RAM. It runs a complete media-automation stack — reverse proxy, single sign-on, five *arr apps, two download clients, monitoring, dashboards, and a handful of operational sidecars — inside an OrbStack Linux VM capped at 3 GiB, alongside a natively-installed Plex Media Server. Every design decision below is downstream of that 8 GB figure.

The most consequential decision is the split-brain host. Docker on macOS means a Linux VM, and a Linux VM means no GPU passthrough: a containerised Plex would software-transcode and promptly exhaust the machine. Run natively, Plex gets VideoToolbox hardware transcoding — decode and encode — at near-zero RAM cost. Everything that doesn’t need the GPU lives in the VM; the one thing that does, doesn’t.

Architecture diagram: Cloudflare edge connects via an outbound-only tunnel to cloudflared inside an OrbStack VM, which routes through Traefik and Authelia to the application containers; Plex runs natively on macOS with VideoToolbox, sharing an external 3.6 TB drive with the containers.
One host, two worlds: containers in the VM, Plex on the metal

Ingress without open ports

Nothing listens on the WAN. A Cloudflare Tunnel makes an outbound-only connection from a cloudflared container to Cloudflare’s edge, which terminates TLS; inside the Docker network, plain HTTP flows to Traefik, and every application route passes through an Authelia forward-auth middleware enforcing two-factor authentication (WebAuthn or TOTP) by default, with argon2id password hashing and aggressive brute-force lockouts behind it. Services with strong native authentication bypass the SSO layer deliberately — a standard pattern, applied sparingly.

Sequence diagram of the forward-auth flow: browser to Cloudflare edge to Traefik, which consults Authelia; unauthenticated requests are redirected to the auth portal for two-factor login, authenticated ones are proxied to the service with identity headers.
The forward-auth handshake on every request

The bug that never logged an error

The acquisition pipeline is conventional: a request portal feeds the *arr apps, an indexer hub feeds them releases, downloads land in a staging directory, and imports move files into the library, where Plex picks them up via filesystem events. The import step is supposed to use hardlinks — same physical file, two directory entries, zero copy cost — and the apps were correctly configured to do so. The downloads and the library even share one physical filesystem.

And yet an audit found zero hardlinked files in the entire library. The proof took one command inside a container:

ln /downloads/file /data/file → Cross-device link

The staging directory and the library were mounted as two separate bind mounts — and the virtualisation layer surfaces each bind mount as a distinct device. Linux refuses link() across devices, the apps silently fall back to copying, and nothing anywhere reports a problem.

Every import had been a full copy across a USB drive: double the write I/O, double the disk space while torrents seed. The fix is the layout the TRaSH guides have always recommended — a single parent mount shared by download clients and library apps, so staging and library are one device again. The lesson generalises: a correct setting is not a working feature. Hardlink support survives every config review and dies quietly at the mount table; the only way to know is to test the actual operation.

Pipeline diagram from request portal through the arr apps and download clients to the import step, which is highlighted: EXDEV cross-device link errors cause a silent fallback from hardlinking to full copies before files reach the library and Plex.
The import step: configured to hardlink, silently copying

Living inside 8 GB

At steady state the box is fully committed: macOS and its ~50 ambient daemons hold roughly 2.7 GB, the VM’s host-side footprint runs about 1.9 GB, and the memory compressor squeezes over 6 GB of logical pages into under 3 GB of physical RAM. It works — but there is no slack, so the engineering is about preventing spikes, not finding savings.

Pie chart of the approximate steady-state RAM split across macOS daemons, the OrbStack VM, compressed memory, and Plex plus everything else.
Where 8 GB goes — proportions, not a precise ledger

Three findings from this corner of the review were the least intuitive:

Least privilege for the Docker socket

Five containers needed the Docker API: the proxy reads routing labels, a dashboard reads container status, an auto-updater pulls and recreates, a healer and a scheduled restarter bounce containers. The naive pattern — mount /var/run/docker.sock everywhere, add :ro, move on — is a trap: read-only protects the socket file, while the API behind it remains root-equivalent for the whole VM.

The reviewed design landed on a tiered model: a read-only socket proxy with a GET-only allowlist for anything that merely observes, a second proxy instance that permits restarts and nothing else for the remediation sidecars, and a raw mount only for the one component whose job genuinely is creating and destroying containers.

Diagram of tiered Docker socket access: a read-only proxy serves Traefik and the dashboard, a restart-only proxy serves autoheal and the scheduled restarter, and only the updater gets a raw socket mount.
Three tiers of socket access instead of five raw mounts

Field notes

A point-in-time review (June 2026) of a personal setup, lightly genericised. Diagrams are pre-rendered Mermaid; no scripts were harmed in the serving of this page.