Building an automated Plex stack using Docker Compose

I regularly help people install, configure, and run automated Plex stacks. I have found that unfortunately I spend a lot of time solving the infrastructure stuff before we can even get to the application configs. The various mainstream containers aren’t setup for hot linking, Spaceinvader One has a video that has a dated screenshot in it (not a complaint! he’s awesome!), etc. All things that beginners stumble over. In an attempt to make that first part easy, I created the following.

Assumptions:

  • Torrents and Usenet downloads are wrapped with a VPN, and I use PIA. If you use something else, you’ll want to tweak things as described here. Regardless, even if you use PIA, you need to get the opvn files and add them to the correct directory. The link provided goes into detail. Note that only torrents require port forwarding.

  • You are using a filesystem structure that allows for hot linking. I’ve described a good way to do that here, in the event you need to move your stuff around.

  • This setup is built to run on Windows because that’s where I find most beginners are doing their builds. Adopting it to Mac/Linux is not complicated.

  • Docker’s root directory will be C:\Users<win_username>\Docker. This too is easy to change using the .env file

  • I have not added SWAG/LetsEncrypt because most beginners don’t own domains. I’ve primed the setup though using a custom Docker network that makes adding SWAG easy.

Bonus: put the text below into a decent text editor and collapse the services section

docker-compose.yaml


version: '3.9'
networks:
  docker_internal_network:
    name: ${DOCKER_NETWORK_NAME}
services:
  plex: ######### [http://localhost:32400/web/index.html]  PLEX - media server
    image: plexinc/pms-docker:plexpass
    container_name: ${DOCKER_CONTAINER_PREFIX}_plex
    networks:
      - docker_internal_network
    environment:
      - PLEX_CLAIM=${PLEX_CLAIM}
      - ADVERTISE_IP=${PLEX_ADVERTISE_IP}
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${PLEX_TRANSCODE_PATH}:/transcode
      - ${DOCKER_APPDATA_PATH}\plex:/config
      - ${DATA_PATH}:/data
    ports:
      - '32400:32400'
      - '3005:3005'
      - '8324:8324'
      - '32469:32469'
      - '1900:1900/udp'
      - '32410:32410/udp'
      - '32412:32412/udp'
      - '32413:32413/udp'
      - '32414:32414/udp'
    restart: unless-stopped
  ombi: ######### [http://localhost:3579]  OMBI - user media requests and trouble tickets
    image: linuxserver/ombi:development
    container_name: ${DOCKER_CONTAINER_PREFIX}_ombi
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\ombi:/config
    ports:
      - '3579:3579'
    restart: unless-stopped
  radarr: ####### [http://localhost:7878]  Radarr - movie management
    image: linuxserver/radarr
    container_name: ${DOCKER_CONTAINER_PREFIX}_radarr
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\radarr:/config
      - ${DATA_PATH}:/data
    ports:
      - '7878:7878'
    restart: unless-stopped
  sonarr: ####### [http://localhost:8989]  Sonarr - TV management
    image: linuxserver/sonarr
    container_name: ${DOCKER_CONTAINER_PREFIX}_sonarr
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\sonarr:/config
      - ${DATA_PATH}:/data
    ports:
      - '8989:8989'
    restart: unless-stopped    
  lidarr: ####### [http://localhost:8686]  Lidarr - music management
    image: linuxserver/lidarr
    container_name: ${DOCKER_CONTAINER_PREFIX}_lidarr
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\lidarr:/config
      - ${DATA_PATH}:/data
    ports:
      - '8686:8686'
    restart: unless-stopped
  bazarr: ####### [http://localhost:6767]  Bazarr - subtitle managment
    image: ghcr.io/linuxserver/bazarr
    container_name: ${DOCKER_CONTAINER_PREFIX}_bazarr
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\bazarr:/config
      - ${DATA_PATH}:/data
    ports:
      - '6767:6767'
    restart: unless-stopped
  tautulli: ##### [http://localhost:8181]  Tautulli - stats
    image: linuxserver/tautulli
    container_name: ${DOCKER_CONTAINER_PREFIX}_tautulli
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\tautulli:/config
      - ${DOCKER_APPDATA_PATH}\plex\Library\Application Support\Plex Media Server\Logs:/logs
    ports:
      - '8181:8181'
    restart: unless-stopped
  nzbhydra2: #### [http://localhost:5076]  NZBHydra2 - meta search for newznab indexers and torznab trackers
    image: linuxserver/nzbhydra2
    container_name: ${DOCKER_CONTAINER_PREFIX}_nzbhydra2
    networks:
      - docker_internal_network
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\nzbhydra2:/config
      - ${DATA_PATH}:/data
    ports:
      - '5076:5076'
    restart: unless-stopped
  jackett: ###### [http://localhost:9117]  Jackett - indexer scraping & translation logic
    image: ghcr.io/linuxserver/jackett
    container_name: ${DOCKER_CONTAINER_PREFIX}_jackett
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\jackett:/config
      - ${DATA_PATH}:/data
    ports:
      - '9117:9117'
    restart: unless-stopped
  deluge-vpn: ### [http://localhost:8112]  Delude VPN + Privoxy - torrent downloader, wrapped in a VPN, also running Privoxy [8118]
    image: binhex/arch-delugevpn  
    container_name: ${DOCKER_CONTAINER_PREFIX}_deluge-vpn
    cap_add:
      - NET_ADMIN
    environment:
      - VPN_ENABLED=yes
      - VPN_USER=${VPN_USERNAME}
      - VPN_PASS=${VPN_PASSWORD}
      - VPN_PROV=pia
      - VPN_CLIENT=openvpn # connection files located at https://www.privateinternetaccess.com/openvpn/openvpn.zip
      - STRICT_PORT_FORWARD=yes
      - ENABLE_PRIVOXY=no
      - LAN_NETWORK=${VPN_LAN_SUBNET}
      - NAME_SERVERS=209.222.18.222,84.200.69.80,37.235.1.174,1.1.1.1,209.222.18.218,37.235.1.177,84.200.70.40,1.0.0.1
      - DELUGE_DAEMON_LOG_LEVEL=error
      - DELUGE_WEB_LOG_LEVEL=error
      #- VPN_INPUT_PORTS=1234
      #- VPN_OUTPUT_PORTS=5678
      - DEBUG=false
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - '/etc/localtime:/etc/localtime:ro'
      - ${DOCKER_APPDATA_PATH}\deluge-vpn:/config
      - ${DATA_PATH}:/data
    ports:
      - '8112:8112'
      - '8118:8118'
      - '58846:58846'
      - '58946:58946'
    restart: unless-stopped
  sabnzbd-vpn: ## [http://localhost:8080]  SABnzbd VPN - usenet downloader, wrapped in a VPN
    image: binhex/arch-sabnzbdvpn
    container_name: ${DOCKER_CONTAINER_PREFIX}_sabnzbd-vpn
    cap_add:
      - NET_ADMIN
    environment:
      - VPN_ENABLED=yes
      - VPN_USER=${VPN_USERNAME}
      - VPN_PASS=${VPN_PASSWORD}
      - VPN_PROV=pia
      - VPN_CLIENT=openvpn # connection files located at https://www.privateinternetaccess.com/openvpn/openvpn.zip
      - STRICT_PORT_FORWARD=no
      - ENABLE_PRIVOXY=no
      - LAN_NETWORK=${VPN_LAN_SUBNET}
      - NAME_SERVERS=209.222.18.222,84.200.69.80,37.235.1.174,1.1.1.1,209.222.18.218,37.235.1.177,84.200.70.40,1.0.0.1
      #- VPN_INPUT_PORTS=1234
      #- VPN_OUTPUT_PORTS=5678
      - DEBUG=false
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ${DOCKER_APPDATA_PATH}\sabnzbd-vpn:/config
      - ${DATA_PATH}:/data
    ports:
      - '8080:8080'
      - '8090:8090'
      #- '8118:8118' # disabled because privoxy is running in the torrent container
    restart: unless-stopped
  gaps: ######### [http://localhost:8484]  GAPS - identifies movie collections and missing films
    container_name: ${DOCKER_CONTAINER_PREFIX}_gaps
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=${DOCKER_TIME_ZONE}
    volumes:
      - ${DOCKER_APPDATA_PATH}\gaps:/config
    ports:
      - '8484:8484'
    restart: unless-stopped
  dozzle: ####### [http://localhost:9999]  Dozzle - Docker log reader
    image: amir20/dozzle:latest  
    container_name: ${DOCKER_CONTAINER_PREFIX}_dozzle
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - '9999:8080'
    restart: unless-stopped

.env


# Docker
DOCKER_NETWORK_NAME = swag
DOCKER_CONTAINER_PREFIX = media-server-stack
DOCKER_TIME_ZONE = America/Los_Angeles
DOCKER_APPDATA_PATH = C:\Users\Jesse\Docker\appdata

# Top-Level Location of Your Media and Incoming Data
DATA_PATH = Z:\

# Plex
PLEX_CLAIM = 
PLEX_ADVERTISE_IP = http://localhost:32400/
PLEX_TRANSCODE_PATH = C:\Users\Jesse\Docker\transcode

# VPN
VPN_USERNAME = p#######
VPN_PASSWORD = ????????????????????
VPN_LAN_SUBNET = 10.0.0.0/24

For those new to Docker Compose, using these two files looks like this:


C:\Users\Jesse\Docker>docker ps --format "table {{.Names}}\t|   {{.State}}\t|   {{.Status}}"
NAMES     |   STATE   |   STATUS

C:\Users\Jesse\Docker>docker-compose up -d
Creating network "swag" with the default driver
Creating network "docker_default" with the default driver
Creating media-server-stack_plex        ... done
Creating media-server-stack_dozzle      ... done
Creating media-server-stack_sabnzbd-vpn ... done
Creating media-server-stack_lidarr      ... done
Creating media-server-stack_deluge-vpn  ... done
Creating media-server-stack_radarr      ... done
Creating media-server-stack_ombi        ... done
Creating media-server-stack_gaps        ... done
Creating media-server-stack_jackett     ... done
Creating media-server-stack_sonarr      ... done
Creating media-server-stack_tautulli    ... done
Creating media-server-stack_nzbhydra2   ... done
Creating media-server-stack_bazarr      ... done

C:\Users\Jesse\Docker>docker ps --format "table {{.Names}}\t|   {{.State}}\t|   {{.Status}}"
NAMES                            |   STATE     |   STATUS
media-server-stack_tautulli      |   running   |   Up 7 minutes
media-server-stack_sonarr        |   running   |   Up 7 minutes
media-server-stack_bazarr        |   running   |   Up 7 minutes
media-server-stack_radarr        |   running   |   Up 7 minutes
media-server-stack_nzbhydra2     |   running   |   Up 7 minutes
media-server-stack_deluge-vpn    |   running   |   Up 7 minutes
media-server-stack_jackett       |   running   |   Up 7 minutes
media-server-stack_gaps          |   running   |   Up 7 minutes
media-server-stack_lidarr        |   running   |   Up 7 minutes
media-server-stack_sabnzbd-vpn   |   running   |   Up 7 minutes
media-server-stack_ombi          |   running   |   Up 7 minutes
media-server-stack_dozzle        |   running   |   Up 7 minutes
media-server-stack_plex          |   running   |   Up 7 minutes (healthy)

C:\Users\Jesse\Docker>

This should have all of your apps running and ready for configuration.
3 Likes

I can see that all containers mount “DATA_PATH” from host into a “/data” directory. Nice and simple :slight_smile: On top of this setup, do you recommend any specific way of configuring sonarr / radarr / plex / torrent client to play well together?

I’m wrapping my head around specifically how to make all apps play well together. My requirements so far:

  • new media files are searched & queued for downloading completely using sonarr/radarr,
  • media files, downloaded using torrent client, remain visible to torrent client so that it continues to seed them,
  • media files are visible to Plex,
  • ideally, all configs would be stored in files (so that to ease the reproduction of the setup later when/if needed).

Read this and let me know what questions you have: setting_up_your_plex_stack_for_proper_hard_linking_and_less_confusing_paths [Jesse's a Geek]

Thank you for this write out. Very helpful.
I am planning on using a dedicated HP prodesk 400 for this with Ubuntu 20.04. The storage will be on a dedicated NAS (Unraid). Apologies for the elementary question, but taking it from the beginning is this correct?
HP Prodesk (192.168.10.11)

  1. Install Ubuntu
  2. Install Docker/Docker Compose
  3. Edit docker-compose.yaml with Version: 3.9 write out you provided above.
    NAS (Unraid) 192.168.10.12
  4. Create “data” share
    • Inside the “data” share do I also create the folders inside “data” as shown in your “Jesse’s a Geek” link?* Ex: /mnt/user/data/media/movies (??)

The area where I continue to get tripped up is the path from the NAS (Unraid) to the Docker machine (HP Prodesk)… Can you give an example of how you would point the Volumes in the docker compose file? If NAS (Unraid) is 192.168.10.12 /mnt/user/data(???)
volumes:
- ${DOCKER_APPDATA_PATH}\sonarr:/config
- ${DATA_PATH}:/data

Again, thank you for the detailed write up. I am sure this makes it click for most folks. Unfortunately, I still have the training wheels on! As much as I’ve struggled with all of this it has been a blast and quite humbling endeavor. Wish I would have learned this 20 years ago!

Thanks!

Replying from a phone, please excuse my brevity.

/mnt/user/data/media/movies

In unRAID, when you make a share (in this case, data) it creates the directory /mnt/user/<share_name>/ (which would be /mnt/user/data/).

In Docker, when you mount the external directory /mnt/user/data/ to the internal directory /data, that allows all of your Docker containers to see all of the useful folders and files - media, Usenet, and torrents. All apps running in Docker will see the same paths to the same files, making consistent config between all applications possible and easy while simultaneously allowing for atomic moves / hard links.

1 Like

Hi Hawkins, Happy Sunday; I have been trying to get this to work and have ran into a variety of errors that are probably my errors but I want to be sure. The errors are “service gaps has neither an an image nor a build context specified” I did some scavenging and added build: under the image of each service and now I am getting an “build contains an invalid type, it should be a string, or an object”. I gather I am supposed to have a string after build: but I can’t determine what that might be. Thank you for your time.

I figured it out, the gaps container doesn’t have an image specified in the syntax. I had to enter homewrecker/gaps next to image and it pulled. facepalm