Proxmox Unprivelliged LXC with shared Intel ARC GPU for Plex or Jellyfin transcoding

Status: CURRENT
Last reviewed: 31 August 2026
Applies to: Proxmox VE 8.2+ / 9.x, unprivileged LXC, Intel Arc GPU
Example LXC: Debian Trixie

This guide originally used manual cgroup, UID/GID, subuid and subgid mappings. Modern Proxmox has native LXC Device Passthrough, so the current method is much simpler.

This guide is for running Plex and/or Jellyfin in Docker inside an unprivileged Proxmox LXC, while sharing an Intel Arc GPU from the Proxmox host for hardware transcoding.

I’m using a Debian Trixie LXC for this example. This assumes you have:

  • Created an unprivileged LXC (Proxmox instructions)
  • Installed Docker inside the LXC (Docker instructions)
  • An Intel Arc GPU detected and working on the Proxmox host
  • An active Plex Pass subscription if you want to use Plex hardware transcoding

Find the Intel Arc render device

On the Proxmox host, check the available render devices:

ls -l /dev/dri/render*


Example output from my host:

crw-rw---- 1 root render 226, 128 Nov 16 21:02 /dev/dri/renderD128
crw-rw---- 1 root render 226, 129 Nov 16 21:02 /dev/dri/renderD129


In my system, renderD128 is the Intel iGPU and renderD129 is the Intel Arc A310. Your device numbers may be different, so don’t blindly copy mine.

If you’re not sure which render device belongs to which GPU, check the DRI device links:

ls -l /dev/dri/by-path/


You can compare the PCI addresses shown there with:

lspci | grep -Ei 'VGA|Display'


Pass the Arc GPU into the LXC

This is the bit that became much easier in newer Proxmox versions.

In the Proxmox web interface, select the LXC and go to:

Resources → Add → Device Passthrough

  • Device Path: select your Intel Arc render device. Mine is /dev/dri/renderD129
  • Mode: 0666
  • UID: leave blank
  • GID: leave blank

For my system, Proxmox creates an LXC config entry equivalent to:

dev0: /dev/dri/renderD129,mode=0666


That’s it. No manual lxc.idmap entries, no editing /etc/subuid or /etc/subgid, and no manually-created cgroup or bind-mount rules.

Why 0666?
This gives processes inside the LXC read/write access to the passed-through render device, which avoids the UID/GID mapping mess used by older methods. For a dedicated unprivileged Plex/Jellyfin LXC this is the simple option. If you want tighter permissions, Proxmox also supports assigning a specific UID/GID to the device instead.

Restart the LXC after adding the device, then check from inside the LXC:

ls -l /dev/dri/


You should see the render device you passed through. In my case that is renderD129.

Docker Compose

The Arc render device also needs to be passed from the LXC into the Plex or Jellyfin Docker container.

Add the following to the Plex or Jellyfin service in your Compose file, changing the render device if yours is different:

devices:
  - /dev/dri/renderD129:/dev/dri/renderD129


If your Arc GPU is renderD128, use /dev/dri/renderD128:/dev/dri/renderD128 instead.

After recreating the Docker container, you can confirm the device is visible inside it:

docker exec -it <container-name> ls -l /dev/dri/


Plex

In Plex, go to Settings → Server → Transcoder and enable Show Advanced if required.

  • Enable Use hardware acceleration when available
  • Enable Use hardware-accelerated video encoding
  • Under Hardware transcoding device, select the Intel Arc GPU

Mine appears as Intel DG2 [Arc A310].

To test it, play a video and force a transcode by changing the quality to something lower. Then go to Activity → Dashboard. The video transcode should show the hardware (hw) tag.

If the Arc GPU doesn’t appear in Plex, check the device in this order:

  1. Proxmox host: ls -l /dev/dri/
  2. Inside the LXC: ls -l /dev/dri/
  3. Inside the Docker container: docker exec -it <container-name> ls -l /dev/dri/

Note: Plex hardware-accelerated transcoding requires an active Plex Pass subscription.

Plex hardware transcoding using Intel Arc

Jellyfin

For Jellyfin, open the Administration Dashboard, then go to Playback → Transcoding.

  • Under Hardware Acceleration, select Intel Quick Sync (QSV)
  • Set the QSV device to the Intel Arc render device. Mine is /dev/dri/renderD129

If the Arc GPU is the only Intel GPU in your system it will often be /dev/dri/renderD128. Again, use the device you identified earlier rather than assuming the number.

To test Jellyfin, play a video, lower the playback quality so it has to transcode, then open Playback Info. Look for Play method: Transcoding.

If transcoding fails completely, first confirm that the render device exists inside both the LXC and the Jellyfin Docker container. Jellyfin’s Intel hardware acceleration documentation also includes additional QSV/VA-API and driver checks if required:

Jellyfin Intel GPU hardware acceleration documentation

Jellyfin Intel Quick Sync transcoding settings

Verify the GPU is actually being used

If you want to confirm the GPU itself is doing the work, install intel-gpu-tools on the Proxmox host and run intel_gpu_top while a transcode is active. You should see activity on the Intel GPU’s video engines.

Archived method: manual UID/GID mapping

ARCHIVED – November 2025 method

The original version of this guide manually added lxc.cgroup2.devices.allow, bind-mounted the DRI devices, created custom lxc.idmap rules and edited /etc/subuid and /etc/subgid.

That method worked, but it is no longer the recommended path for a current Proxmox install. Native LXC Device Passthrough creates the device node for the container and can assign its mode, UID and GID directly, so the old mapping instructions have been removed from the active guide to avoid unnecessary complexity.

If you’re deliberately running an older Proxmox release, use documentation appropriate for that version rather than copying the old mappings from this article.

Credit: Thanks to gnd-7000 in the comments for pointing out that Proxmox 9 no longer needs the old GID mapping method and that the GPU can be added directly through Resources → Device Passthrough. Much cleaner 🙂

References: Proxmox pct documentation · Jellyfin Intel GPU documentation · Plex Transcoder documentation

Bypassing CGNAT for Plex with Tailscale funnels

Status: CURRENT — WITH CAVEAT
Last reviewed: 31 August 2026
Applies to: Tailscale Funnel, Plex Media Server, Proxmox VM/LXC

This method still works and I could not find anything in Tailscale’s current Terms of Service or Acceptable Use Policy that explicitly prohibits Plex or media streaming through Funnel. However, Funnel traffic has non-configurable bandwidth limits, and Tailscale’s AUP prohibits use that creates an undue burden on its service. So yes, it’s still a handy CGNAT workaround — just don’t treat Tailscale like you’ve accidentally discovered a free unlimited Plex CDN and absolutely send it 😅.

This is not legal advice. Terms and product limits can change, so check Tailscale’s current documentation and policies before relying on Funnel for heavy traffic.

I’m currently on a static public IP and run Plex with normal remote access now, so I don’t actually need this workaround anymore. I did spend plenty of time fighting CGNAT before that though, and I remember the pain well enough to keep this guide alive.

With IPv4 addresses becoming scarce, more ISPs are putting customers behind CGNAT. The annoying bit for self-hosting is that you no longer have your own publicly reachable IPv4 address, so normal port forwarding for Plex may not work. Your router can forward the port perfectly and the internet still basically says, “yeah nah”.

Why Tailscale Funnel?

Tailscale Funnel can expose a service running on a Tailscale device to the public internet using a .ts.net HTTPS address. Traffic reaches Tailscale’s Funnel relay servers and is then proxied back to the service on your machine.

For Plex, that means you can expose local port 32400 through a public HTTPS address even if your ISP has you trapped behind CGNAT. That’s the neat bit we’re here for.

A better option when possible: If every device that needs Plex can run Tailscale, don’t use Funnel at all. Install Tailscale on the Plex server and client devices and access Plex privately across your tailnet. No point making something public just for the hell of it. Funnel is mainly useful when you need a normal public HTTPS endpoint — for example, a Plex client that cannot run Tailscale itself.

Tailscale and Cloudflare terms

As of 31 August 2026, I could not find an explicit rule in Tailscale’s Terms of Service or Acceptable Use Policy banning Plex, video streaming or media servers through Funnel.

There are still two important catches:

  • Tailscale documents that Funnel traffic is subject to non-configurable bandwidth limits.
  • Tailscale’s Acceptable Use Policy prohibits use that interferes with, disrupts or creates an undue burden on the Tailscale service or connected infrastructure.

So while personal Plex streaming through Funnel does not appear to be explicitly prohibited, I wouldn’t take the piss and treat it as a free unlimited CDN or relay. Heavy or continuous streaming may hit bandwidth limits and could potentially become an acceptable-use issue.

Cloudflare Tunnel is different. Cloudflare’s current documentation states that public-hostname Tunnel traffic on Free, Pro and Business plans is subject to service-specific terms requiring an appropriate paid service to serve video and other large files. So I would not use a standard public Cloudflare Tunnel as a Plex streaming workaround on those plans.

Set up the Tailscale domain

Create a Tailscale account if you don’t already have one, then open the Tailscale Admin Console and head to DNS.

Tailscale DNS settings showing the tailnet domain

You can hit Rename tailnet until you get something cool 😉

For this example I’ll use:

cool-kid.ts.net


Make sure MagicDNS and HTTPS Certificates are enabled. The Funnel command can also prompt you to enable the requirements if they aren’t already configured.

Tailscale MagicDNS and HTTPS certificate settings

Install Tailscale on the Plex VM or LXC

Install Tailscale using the current Linux installation instructions, or use Tailscale’s install script:

curl -fsSL https://tailscale.com/install.sh | sh


If Plex is running inside an unprivileged Proxmox LXC

Tailscale needs access to /dev/net/tun. Older Proxmox guides manually edited the LXC config with cgroup and bind-mount entries, but current Proxmox versions can do this directly from the web interface. Thankfully, no cgroup archaeology required anymore.

Shut down the LXC, then in Proxmox go to:

LXC → Resources → Add → Device Passthrough

Enter the following Device Path:

/dev/net/tun


Start the LXC again. The equivalent configuration entry is:

dev0: /dev/net/tun


You can confirm the TUN device exists from inside the LXC:

ls -l /dev/net/tun


Tailscale also supports userspace networking if you don’t want to pass through /dev/net/tun, but for a normal Proxmox LXC the native device passthrough method is straightforward and has a lot less weird shit hiding underneath it.

Connect the server to Tailscale

For both a VM and LXC, bring Tailscale up:

tailscale up


Follow the authentication link it gives you. Once complete, the Plex server should appear in your Tailscale Admin Console.

Create the Plex Funnel

Now for the bit we actually came here for. Plex listens on port 32400, so run:

tailscale funnel --bg http://127.0.0.1:32400


The first time you use Funnel, Tailscale may give you a link to approve the required Funnel permissions for the tailnet. One little hoop to jump through, then you’re off.

Once enabled, the output should look similar to:

Available on the internet:

https://plex.cool-kid.ts.net/
|-- proxy http://127.0.0.1:32400

Funnel started and running in the background.


The --bg flag keeps the Funnel configuration running in the background and Tailscale documents that it will resume after a reboot or a tailscale down/tailscale up cycle.

You can check the current Funnel configuration at any time with:

tailscale funnel status


To remove the Funnel later:

tailscale funnel --https=443 off


Tell Plex about the Funnel address

Open your local Plex server:

http://<YourIP>:32400/web


Go to Settings → Server → Network, enable Show Advanced, then find Custom server access URLs.

Enter the HTTPS address created by Funnel. I explicitly include port 443 so Plex publishes the correct external HTTPS endpoint rather than inheriting the normal Plex remote-access port:

https://plex.cool-kid.ts.net:443


Save the changes and restart Plex for good measure. Probably not strictly necessary every time, but Plex has taught me not to argue with a cheap reboot 🙂

About Plex Remote Access: You do not need a normal router port-forward for this Funnel method. If native Plex Remote Access cannot work because you’re behind CGNAT, the custom Funnel URL gives Plex another address to advertise. I no longer recommend explicitly disabling Plex Remote Access as a required step: leaving it enabled can preserve Plex Relay as a fallback if direct access fails. Basically, don’t fight Plex more than you need to — the Funnel URL is the important part of this setup.

Plex custom server access URL configured with Tailscale Funnel

Test it

Test from a device that is genuinely outside your home network — mobile data is an easy option. Don’t leave your phone on Wi-Fi, see Plex working and congratulate yourself too early 😅. Open Plex through the normal Plex app or hosted web app and start a stream.

If it isn’t working, check the chain in order before changing six things at once and creating an entirely new problem:

  1. tailscale status — confirm the Plex server is connected to your tailnet.
  2. tailscale funnel status — confirm the Funnel is active.
  3. Open the https://...ts.net Funnel address directly in a browser and confirm it reaches Plex.
  4. Check Plex Settings → Server → Network → Custom server access URLs contains the correct Funnel URL with :443.

One quirk I found with this method is that remote connections may not appear in the Plex dashboard graph the same way a normal direct remote connection does. Plex being Plex — if the stream works, don’t let one slightly weird graph send you down a three-hour rabbit hole.

Keep the rest of your homelab private

If you also use Tailscale for private access to the rest of your self-hosted services, you don’t need to Funnel everything publicly. Expose the thing that needs exposing and leave the rest of the homelab tucked away where random internet goblins can’t poke it.

Tailscale’s Split DNS can send requests for your own domain — for example richay.au — to your Pi-hole or AdGuard DNS server while you’re connected to the tailnet. Your internal DNS can then point those names at your private reverse proxy.

That gives you public access only where you deliberately want it, while the rest of the homelab stays private. My guide is here: Tailscale Split DNS by Domain for Secure Home Server Access.

For normal public websites like this blog, Cloudflare Tunnel is still a great CGNAT workaround. The video/large-file restriction discussed above applies to public-hostname traffic serving those types of content; it doesn’t mean Cloudflare Tunnel itself is unsuitable for ordinary websites.

Archived notes from the original guide

ARCHIVED — older Proxmox LXC method

The original version of this article manually added:

lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file

Those lines are still relevant to older Proxmox releases, but current Proxmox versions support /dev/net/tun through native LXC Device Passthrough and Tailscale’s own current Proxmox/LXC documentation recommends the GUI method.

References

And that’s it. CGNAT can remain cactus, Plex can still get out, and you haven’t had to beg your ISP for a public IPv4 address. Not a bad little workaround 🙂

Ansible – updating proxmox host kernel with LXC shared GPU

Status: ARCHIVED — HISTORICAL NVIDIA WORKAROUND
Last reviewed: 31 August 2026
Originally applied to: Proxmox + shared NVIDIA GPU into LXC using manually installed NVIDIA drivers

Do not copy the old playbook as a current NVIDIA update procedure. It hard-codes an old NVIDIA driver and treats manual driver reinstallation after a kernel update as normal. With a properly configured DKMS installation, the NVIDIA kernel module should normally rebuild for the new kernel automatically.

Is this still relevant?

Mostly no. I’m keeping the page because the problem it was trying to solve still exists, but the automation itself belongs to an older version of my setup.

The original idea was simple: after upgrading the Proxmox kernel, check whether the NVIDIA module loaded. If it didn’t, reinstall the NVIDIA .run driver on the host, reboot, then reinstall the matching NVIDIA userspace driver inside the Plex LXC.

The old playbook was built around a fixed NVIDIA driver:

NVIDIA-Linux-x86_64-535.154.05.run


That’s the first reason not to use it today. More importantly, a kernel update by itself should not normally require reinstalling the whole NVIDIA driver.

What should happen now?

For an NVIDIA driver installed with DKMS:

  1. A new Proxmox kernel is installed.
  2. The matching Proxmox kernel headers are available.
  3. DKMS builds the NVIDIA kernel module for that new kernel.
  4. You reboot into the new kernel.
  5. nvidia-smi continues to work.

NVIDIA’s current documentation says that kernel updates can occasionally leave DKMS without the correct headers or fail to rebuild the module. When that happens, the modern fix is normally to diagnose/rebuild DKMS rather than blindly reinstalling an old driver package.

Useful checks after a Proxmox kernel update

uname -r
nvidia-smi
dkms status


Make sure the headers for the running kernel are installed:

apt update
apt install proxmox-headers-$(uname -r)


Depending on the driver installation method and package naming, you can then rebuild the NVIDIA DKMS module rather than reinstalling the entire driver.

Proxmox 9 note: NVIDIA kernel/module compatibility can still genuinely break on newer Proxmox kernels. There have been current Proxmox 9.2 reports involving kernel 7.x, DKMS build failures and conflicts with newer kernel drivers. So if nvidia-smi dies after an upgrade, don’t assume your LXC passthrough configuration is the problem — check the host NVIDIA module first.

What about the LXC?

For shared NVIDIA GPU access, the LXC uses the NVIDIA kernel driver loaded by the Proxmox host. The container does not build its own kernel module.

The old playbook installed the same NVIDIA package inside the LXC using:

./NVIDIA-Linux-x86_64-535.154.05.run --no-kernel-module


That installs the userspace side without attempting to build a kernel module inside the container.

The important distinction is that a kernel update does not automatically mean those userspace libraries changed. You normally only need to revisit the LXC-side NVIDIA libraries when the actual NVIDIA driver version changes or the userspace/kernel versions no longer match.

Proxmox device passthrough is cleaner now too

Current Proxmox supports native LXC device passthrough using dev[n] entries, including configurable path, UID, GID and mode. That has replaced a lot of the older hand-written cgroup/bind-mount hacks used in historical GPU-sharing guides.

The NVIDIA-specific wrinkle remains: the host still needs a functioning NVIDIA kernel driver before any /dev/nvidia* devices can be shared into the LXC.

If I automated this today

I would automate verification, not automatic driver reinstallation.

For example, an Ansible playbook can check the host after updates and deliberately fail if the NVIDIA stack is broken:

- name: Verify NVIDIA after Proxmox update
  hosts: proxmox
  become: true

  tasks:
    - name: Check running kernel
      ansible.builtin.command: uname -r
      register: running_kernel
      changed_when: false

    - name: Check NVIDIA
      ansible.builtin.command: nvidia-smi
      register: nvidia_smi
      changed_when: false
      failed_when: false

    - name: Check DKMS state
      ansible.builtin.command: dkms status
      register: dkms_status
      changed_when: false
      failed_when: false

    - name: Stop if NVIDIA is broken
      ansible.builtin.fail:
        msg: |
          NVIDIA did not initialise after the kernel update.
          Kernel: {{ running_kernel.stdout }}
          nvidia-smi:
          {{ nvidia_smi.stdout }}
          {{ nvidia_smi.stderr }}

          DKMS:
          {{ dkms_status.stdout }}
      when: nvidia_smi.rc != 0


That gives you the automation benefit without having Ansible silently run an obsolete .run installer against whatever kernel happened to arrive that day.

Original playbook — archived for reference

ARCHIVED CODE — DO NOT USE AS-IS. This is the original playbook from this article. It is preserved so old links and search results still make sense, not because I recommend deploying it in 2026.

########
- hosts: nvidia
  become: true
  become_user: root
  tasks:
    - name: Wait for 10.77.69.2 to become available
      wait_for_connection:
        delay: 5
        timeout: 300

    - name: Check if NVIDIA kernel module is loaded
      shell: lsmod | grep -q '^nvidia'
      register: nvidia_module_check
      ignore_errors: true

    - name: Set NVIDIA module check result as fact
      set_fact:
        nvidia_module_rc: "{{ nvidia_module_check.rc }}"

    - name: Reinstall NVIDIA driver if module is not loaded
      shell: sh /root/NVIDIA-Linux-x86_64-535.154.05.run --silent
      args:
        executable: /bin/bash
      when: nvidia_module_check.rc != 0

    - name: Set fact if NVIDIA driver was installed
      set_fact:
        driver_installed: true
      when: nvidia_module_check.rc != 0

    - name: Reboot system if NVIDIA driver was reinstalled
      reboot:
      when: nvidia_module_check.rc != 0

    - name: Wait for 10.77.69.2 to become available after reboot
      wait_for_connection:
        delay: 10
        timeout: 600
      when: nvidia_module_check.rc != 0

########
- hosts: plex
  become: true
  become_user: root
  tasks:
    - name: Install NVIDIA driver in LXC
      shell: sh /root/NVIDIA-Linux-x86_64-535.154.05.run --no-kernel-module --silent
      args:
        executable: /bin/bash
      when: hostvars['10.77.69.2'].driver_installed | default(false)

    - name: Reboot 10.77.69.103
      reboot:
      when: hostvars['10.77.69.2'].driver_installed | default(false)

    - name: Wait for 10.77.69.103 to become available
      wait_for_connection:
        delay: 10
        timeout: 300
      when: hostvars['10.77.69.2'].driver_installed | default(false)


Why I’m archiving it instead of deleting it

The original problem was real and NVIDIA + new Proxmox kernels can still occasionally go cactus. But the correct troubleshooting path has changed.

Today I’d check:

  • Is the running Proxmox kernel supported by the installed NVIDIA driver?
  • Are the matching Proxmox kernel headers installed?
  • Did DKMS actually build/install the NVIDIA module for the running kernel?
  • Does nvidia-smi work on the host?
  • Do the /dev/nvidia* devices exist?
  • Only then: does the LXC have the right devices and compatible NVIDIA userspace libraries?

That’s a better model than treating every kernel update as “reinstall NVIDIA everywhere and reboot twice”.

References

Docker Stack with ExpressVPN

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Docker Compose, ExpressVPN, qBittorrent, Prowlarr, Sonarr, Radarr and Lidarr

The original idea is still solid: make selected containers share a VPN container’s network namespace so their traffic cannot casually wander out through your normal WAN connection. The old stack still mostly makes sense, but I’ve rebuilt the recommended version around Gluetun because it handles the firewall/killswitch and DNS side much more cleanly.

This article started life as a giant entertainment stack using polkaned/expressvpn, with qBittorrent and all the *arr containers set to:

network_mode: service:expressvpn


That Docker trick is still completely valid. Docker Compose still supports network_mode: service:<name>, which makes one service use another service’s network stack.

The result is basically:

qBittorrent ─┐
Prowlarr    ├── shares VPN network namespace ── ExpressVPN ── Internet
Sonarr      │
Radarr      │
Lidarr     ─┘


If the VPN container’s firewall is doing its job, those applications don’t get an independent route straight out through your home connection.

What was wrong with my old stack?

Nothing catastrophically wrong, but a few bits have aged.

  • polkaned/expressvpn is actually still maintained, so that part didn’t die. Nice.
  • The current polkaned documentation warns that containers sharing its network namespace need the VPN container’s DNS configuration copied/shared as well to avoid DNS leakage. My old Compose didn’t do that.
  • The old VPN container ran privileged: true. Gluetun can do the job with NET_ADMIN and /dev/net/tun instead.
  • LinuxServer now documents its images using lscr.io/linuxserver/....
  • The old qBittorrent variable UMASK_SET has been replaced by UMASK.
  • I was using the Prowlarr develop tag for no particularly good reason. Stable latest is the sensible default now.
  • The old stack had separate /tv, /movies and /downloads mounts. A single common /data mount makes hardlinks and atomic moves far less cactus.

Recommended 2026 method: Gluetun + ExpressVPN

Gluetun has native ExpressVPN support and can act as the network container for everything else. Its firewall stays active and effectively acts as the VPN killswitch.

One limitation: Gluetun connects to ExpressVPN using ExpressVPN’s manual OpenVPN credentials. ExpressVPN’s own Linux app can use newer protocols such as Lightway, but their manual configuration currently supports OpenVPN.

1. Get the ExpressVPN manual credentials

These are not your normal ExpressVPN account email/password.

In your ExpressVPN account go to the manual configuration area and grab the generated OpenVPN username and password.

I keep mine in an .env file next to the Compose file instead of hard-coding them into the stack:

EXPRESSVPN_USER=replace_me
EXPRESSVPN_PASSWORD=replace_me
EXPRESSVPN_COUNTRY=Australia

PUID=1000
PGID=1000
TZ=Australia/Perth


Then lock the file down:

chmod 600 .env


2. Folder layout

My current Docker layout is:

/home/richay/docker-compose/   # Compose files + small hand-managed config
/home/richay/docker/           # Persistent container data


For this stack:

mkdir -p /home/richay/docker-compose/media-vpn
mkdir -p /home/richay/docker/{gluetun,qbittorrent,prowlarr,sonarr,radarr,lidarr}
mkdir -p /mnt/media/{downloads,tv,movies,music}


Change /mnt/media to wherever your real media/download storage is mounted.

3. Current Compose stack

services:
  gluetun:
    image: qmcgaw/gluetun:latest
    container_name: gluetun
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    environment:
      - VPN_SERVICE_PROVIDER=expressvpn
      - VPN_TYPE=openvpn
      - OPENVPN_USER=${EXPRESSVPN_USER}
      - OPENVPN_PASSWORD=${EXPRESSVPN_PASSWORD}
      - SERVER_COUNTRIES=${EXPRESSVPN_COUNTRY:-Australia}
      - TZ=${TZ:-Australia/Perth}
    volumes:
      - /home/richay/docker/gluetun:/gluetun
    ports:
      - "8080:8080" # qBittorrent WebUI
      - "9696:9696" # Prowlarr
      - "8989:8989" # Sonarr
      - "7878:7878" # Radarr
      - "8686:8686" # Lidarr
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    network_mode: service:gluetun
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - TZ=${TZ:-Australia/Perth}
      - WEBUI_PORT=8080
      - TORRENTING_PORT=6881
      - UMASK=022
    volumes:
      - /home/richay/docker/qbittorrent:/config
      - /mnt/media:/data
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    network_mode: service:gluetun
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - TZ=${TZ:-Australia/Perth}
    volumes:
      - /home/richay/docker/prowlarr:/config
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    network_mode: service:gluetun
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - TZ=${TZ:-Australia/Perth}
    volumes:
      - /home/richay/docker/sonarr:/config
      - /mnt/media:/data
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    network_mode: service:gluetun
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - TZ=${TZ:-Australia/Perth}
    volumes:
      - /home/richay/docker/radarr:/config
      - /mnt/media:/data
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped

  lidarr:
    image: lscr.io/linuxserver/lidarr:latest
    container_name: lidarr
    network_mode: service:gluetun
    environment:
      - PUID=${PUID:-1000}
      - PGID=${PGID:-1000}
      - TZ=${TZ:-Australia/Perth}
    volumes:
      - /home/richay/docker/lidarr:/config
      - /mnt/media:/data
    depends_on:
      gluetun:
        condition: service_healthy
    restart: unless-stopped


All five application containers share Gluetun’s network namespace. Because they are effectively living in the same network stack, they can also talk to each other over localhost.

For example:

qBittorrent from Sonarr/Radarr: http://127.0.0.1:8080
Prowlarr:                       http://127.0.0.1:9696
Sonarr:                         http://127.0.0.1:8989
Radarr:                         http://127.0.0.1:7878
Lidarr:                         http://127.0.0.1:8686


The ports are published on the Gluetun service because containers using network_mode: service:gluetun cannot publish their own ports.

Do not router-port-forward those WebUI ports. They’re management interfaces, not things you want flapping around on the public internet. If you want remote access, use a VPN/Tailscale and private DNS rather than raw WAN exposure.

If you want the same private hostname setup I use, see my Tailscale Split DNS guide.

4. Bring it up

cd /home/richay/docker-compose/media-vpn
docker compose up -d


Then check Gluetun first:

docker logs -f gluetun


Once it reports a healthy VPN connection, check the stack:

docker compose ps
docker exec gluetun wget -qO- https://ifconfig.me


The public IP shown from Gluetun should be the VPN exit IP, not your normal home WAN address.

5. qBittorrent login changed too

Current LinuxServer qBittorrent generates a temporary password for the admin user on startup. Grab it from the logs:

docker logs qbittorrent 2>&1 | grep -i password


Then log into:

http://DOCKER-HOST-IP:8080


and change the password.

About the torrent port

The old stack published TCP/UDP 6881 and it was easy to assume that meant qBittorrent had an externally forwarded VPN port.

It doesn’t.

ExpressVPN does not support port forwarding on its VPN servers. Publishing 6881 in Docker only exposes that port on the Docker host. It does not punch an inbound port through the ExpressVPN server.

qBittorrent will still download and seed through outbound connections, but unsolicited inbound peer connections through the VPN aren’t available. That can reduce peer connectivity compared with a VPN provider that offers torrent-friendly port forwarding.

Do Sonarr/Radarr/Lidarr really need the VPN?

Nope.

The stack above keeps everything behind Gluetun because it’s simple and matches the design of my old stack. But the important ones are generally:

  • qBittorrent: definitely the one whose traffic I want through the VPN.
  • Prowlarr: useful behind the VPN if your ISP blocks tracker/indexer sites.
  • Sonarr / Radarr / Lidarr: usually fine on the normal Docker network because they can talk to Prowlarr/qBittorrent locally.

If you want a cleaner split, move Sonarr/Radarr/Lidarr back onto a normal user-defined Docker network and leave only qBittorrent/Prowlarr sharing Gluetun. Slightly more networking to configure, slightly less “everything goes through the magic tunnel”. Pick your poison 🙂

What about polkaned/expressvpn?

It is not dead. I genuinely expected to find a dusty abandoned image here, but Docker Hub shows fresh 2026 builds and the current README supports ExpressVPN 5.x plus protocol selection including Lightway, OpenVPN and WireGuard.

A current minimal container still looks roughly like:

services:
  expressvpn:
    image: polkaned/expressvpn:latest
    container_name: expressvpn
    environment:
      - ACTIVATION_CODE=${EXPRESSVPN_ACTIVATION_CODE}
      - PROTOCOL=wireguard
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun:/dev/net/tun
    privileged: true
    tty: true
    command: /bin/bash
    restart: unless-stopped


If you specifically want the real ExpressVPN client/protocols, that remains an option.

However, if other containers share that ExpressVPN container’s network namespace, follow the maintainer’s current DNS-sharing instructions. Their README explicitly warns that the dependent containers need the VPN-side resolv.conf handling to avoid DNS leakage.

That’s why I’m using Gluetun as the recommended fresh install: less janky plumbing, built-in firewall behaviour, and it was designed specifically for this sidecar/network-container use case.

Why Plex is no longer in this stack

The old article bundled Plex and get_iplayer into the same giant Compose file even though they weren’t using the VPN.

These days I keep unrelated services in separate stacks. It makes upgrades, troubleshooting and the inevitable “why the fuck did restarting my VPN also restart Plex?” moment much easier to avoid.

Plex should generally have its own stack/LXC and normal networking. It has absolutely no need to ride out through ExpressVPN just because qBittorrent does.

Old 2024 stack — historical note

HISTORICAL

The original stack used polkaned/expressvpn with qBittorrent, Prowlarr, Sonarr, Radarr and Lidarr all sharing the ExpressVPN network namespace. The architecture itself was valid, but the old example lacked the DNS-sharing requirement documented by the current image and used several now-stale image/environment conventions.

So I’m not pretending the original idea was wrong. It just needed its plumbing replaced before it flooded the carpet.

References

The nice bit is the original trick still survives: one VPN container, everything else borrows its network. The new version just has fewer opportunities to quietly leak DNS or make me wonder why I gave a VPN container the keys to the entire bloody kingdom 🙂

Docker Compose for Entertainment Stack

Status: CURRENT — MAJOR 2026 UPDATE
Last reviewed: 31 August 2026
Applies to: Docker Compose, Bazarr, Jellyfin, Seerr, Plex, Prowlarr, Radarr, Sonarr and Tautulli

The original idea behind this stack is still good: put related containers on one Docker network so they can find each other by service name instead of hard-coding IP addresses. A few of the actual applications and Docker habits from the old stack are now cactus though, so this is the cleaned-up version.

My entertainment stack has always been built around one simple Docker feature:

http://prowlarr:9696


No memorising container IP addresses. No changing configs when Docker hands a container a different IP. If two containers are on the same user-defined Docker network, Docker’s internal DNS lets them talk to each other using the service/container name.

That part of the original article was spot on and is still how I’d do it today.

What’s changed since the old stack?

  • Overseerr is gone. The original project was archived in February 2026. Overseerr and Jellyseerr have effectively converged into Seerr, which is the current maintained project.
  • Watchtower is gone. The upstream project was archived in December 2025 and now explicitly says it is no longer maintained.
  • The top-level Compose version: "3.6" line is obsolete and ignored by modern Docker Compose.
  • I’ve changed application configs from anonymous/named Docker volumes to obvious bind mounts under /home/richay/docker/. Much easier to back up and inspect when something inevitably goes sideways at 1am.
  • Radarr/Sonarr/Bazarr now see one consistent /data tree instead of three completely different host paths.
  • Prowlarr and Seerr don’t need access to your entire media library, so they no longer get it. Less access, less bullshit.
  • Plex still uses host networking in this example because LinuxServer continues to document that as its standard setup, but its old ports: entry has been removed because Docker ignores published ports in host-network mode.

Overseerr → Seerr

The old stack used:

sctx/overseerr:latest


That repository was archived on 15 February 2026. The current maintained project is Seerr, which supports Plex, Jellyfin and Emby and still integrates with Sonarr and Radarr.

The current official Docker image is:

ghcr.io/seerr-team/seerr:latest


If you’re migrating an existing Overseerr/Jellyseerr database rather than starting fresh, use Seerr’s official migration guide. Don’t just point a random new image at the old database and hope Docker Jesus sorts it out 🙂

Watchtower has been removed

The old stack let Watchtower automatically pull and replace containers. The Watchtower maintainers archived the project on 17 December 2025 and now state that it is no longer maintained.

I don’t think unattended auto-updates are worth handing permanent Docker-socket access to an abandoned container.

Updating this stack manually is hardly an ordeal:

docker compose pull
docker compose up -d
docker image prune


LinuxServer currently recommends Diun if you want notifications that new images are available, rather than automatically updating everything while you’re asleep and waking up to seven broken services and a strong urge to throw the server into the pool.

Folder layout

I keep Compose files and persistent Docker data separate:

/home/richay/docker-compose/   # Compose files and small hand-managed config
/home/richay/docker/           # Persistent container data


A simple media tree might look like:

/mnt/media/
├── downloads/
└── media/
    ├── movies/
    ├── tv/
    └── music/


The important part is that Radarr/Sonarr and your download client see the same filesystem through the same /data container path. That allows hardlinks and atomic moves instead of copying a massive file from one mount to another and then deleting the original.

Create the config folders:

mkdir -p /home/richay/docker-compose/entertainment
mkdir -p /home/richay/docker/{bazarr,jellyfin,seerr,plex,prowlarr,radarr,sonarr,tautulli}
mkdir -p /mnt/media/{downloads,media/movies,media/tv,media/music}


Current entertainment Compose stack

This keeps the spirit of my original stack without carrying all the old baggage:

services:
  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
    volumes:
      - /home/richay/docker/bazarr:/config
      - /mnt/media:/data
    ports:
      - "6767:6767"
    restart: unless-stopped
    networks:
      - entertainment

  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
    volumes:
      - /home/richay/docker/jellyfin:/config
      - /mnt/media/media:/data/media:ro
    ports:
      - "8096:8096"
      # Optional local discovery:
      # - "7359:7359/udp"
      # Optional DLNA/service discovery:
      # - "1900:1900/udp"
    # Intel/AMD hardware acceleration:
    # devices:
    #   - /dev/dri:/dev/dri
    restart: unless-stopped
    networks:
      - entertainment

  seerr:
    image: ghcr.io/seerr-team/seerr:latest
    container_name: seerr
    init: true
    environment:
      - LOG_LEVEL=info
      - TZ=Australia/Perth
      - PORT=5055
    volumes:
      - /home/richay/docker/seerr:/app/config
    ports:
      - "5055:5055"
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/settings/public || exit 1
      start_period: 20s
      timeout: 3s
      interval: 15s
      retries: 3
    restart: unless-stopped
    networks:
      - entertainment

  plex:
    image: lscr.io/linuxserver/plex:latest
    container_name: plex
    network_mode: host
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
      - VERSION=docker
      - PLEX_CLAIM=
    volumes:
      - /home/richay/docker/plex:/config
      - /mnt/media/media:/data/media:ro
    # Intel/AMD hardware acceleration:
    # devices:
    #   - /dev/dri:/dev/dri
    restart: unless-stopped

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
    volumes:
      - /home/richay/docker/prowlarr:/config
    ports:
      - "9696:9696"
    restart: unless-stopped
    networks:
      - entertainment

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
    volumes:
      - /home/richay/docker/radarr:/config
      - /mnt/media:/data
    ports:
      - "7878:7878"
    restart: unless-stopped
    networks:
      - entertainment

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
    volumes:
      - /home/richay/docker/sonarr:/config
      - /mnt/media:/data
    ports:
      - "8989:8989"
    restart: unless-stopped
    networks:
      - entertainment

  tautulli:
    image: lscr.io/linuxserver/tautulli:latest
    container_name: tautulli
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Australia/Perth
    volumes:
      - /home/richay/docker/tautulli:/config
    ports:
      - "8181:8181"
    restart: unless-stopped
    networks:
      - entertainment

networks:
  entertainment:
    name: entertainment


Why keep an explicit entertainment network?

Docker Compose automatically creates a default network and provides service-name DNS anyway, so technically the explicit network isn’t required if everything lives in this one Compose project.

I still like naming it entertainment because it gives me a stable network name that another stack can deliberately join later.

For example, Radarr can talk to Sonarr/Prowlarr-style services using names such as:

http://prowlarr:9696
http://radarr:7878
http://sonarr:8989
http://seerr:5055
http://jellyfin:8096


No container IPs required. Docker handles the internal DNS. Fucking lovely.

Plex is the odd bastard

Plex uses network_mode: host in the LinuxServer recommended configuration, so it does not join the entertainment bridge network in this example.

That also means this old combination was pointless:

network_mode: host
ports:
  - 32400:32400


Docker explicitly ignores published ports when host networking is enabled because Plex is already binding directly to the host’s network stack.

So Tautulli/Seerr should reach Plex using the Docker host’s LAN address, for example:

http://192.168.1.10:32400


Replace that with your actual Docker/Plex host address.

Jellyfin ports

The only Jellyfin port I expose by default in the stack is:

8096/tcp  # normal Jellyfin HTTP web/API


LinuxServer still documents 8920/tcp, 7359/udp and 1900/udp as optional ports. I don’t bother with Jellyfin’s own HTTPS port 8920 when Traefik is handling TLS in front of it.

7359/udp is useful for local client discovery and 1900/udp is used for service/DLNA discovery. Uncomment them only if you actually use those features.

Hardware transcoding

For Intel/AMD hardware acceleration, LinuxServer’s Jellyfin image still supports mapping the host’s DRI device:

devices:
  - /dev/dri:/dev/dri


The same mapping can be used for Plex when the Docker host has the GPU available.

If you’re running Docker inside a Proxmox LXC, get the GPU working in the LXC first. Then pass it to Docker. Debugging Proxmox → LXC → Docker → Jellyfin all at once is how hobbies turn into drinking problems 🙂

Where’s qBittorrent?

I deliberately left the download client out of this stack.

If qBittorrent needs to live behind a VPN, I prefer it in a dedicated VPN/download stack rather than mixing VPN networking into Plex, Jellyfin and everything else.

See my updated Docker Stack with ExpressVPN article for the VPN side.

Radarr and Sonarr can still use that qBittorrent instance as their download client — it does not need to be in the same Compose file.

Don’t mount everything into everything

My old stack gave Prowlarr and Overseerr access to media, storage and downloads even though they had no bloody reason to touch those files.

The new split is:

  • Radarr / Sonarr / Bazarr: access to the shared /data tree.
  • Plex / Jellyfin: read-only access to /data/media.
  • Prowlarr / Seerr / Tautulli: config only; no media filesystem access required.

It’s cleaner and reduces the blast radius if one container ever gets compromised.

Starting and checking the stack

cd /home/richay/docker-compose/entertainment
docker compose up -d
docker compose ps


Then confirm the internal Docker DNS works from one of the containers:

docker exec sonarr getent hosts prowlarr
docker exec sonarr getent hosts radarr


If those return container IPs, the service-name networking is doing exactly what we wanted.

Old stack — what I’d keep and what I’d bin

  • Keep: one shared Docker network and service-name communication.
  • Keep: LinuxServer images for the *arr apps, Jellyfin, Plex and Tautulli.
  • Keep: Perth timezone and sensible PUID/PGID ownership.
  • 🛠️ Change: Overseerr → Seerr.
  • 🛠️ Change: named volumes → obvious bind-mounted config folders.
  • 🛠️ Change: multiple inconsistent media paths → one /data tree.
  • 🗑️ Bin: obsolete Compose version: "3.6".
  • 🗑️ Bin: Watchtower.
  • 🗑️ Bin: pointless Plex ports: while using host networking.

References

The original stack wasn’t bad at all — the networking idea aged really well. It just accumulated a couple of dead projects, redundant mounts and Docker archaeology around it. A bit of a clean-out and she’s good again 🙂