--- # playbooks/docker/deploy_plex_standalone.yml # # Purpose: # Deploy the full Plex media stack on a standalone Docker host (statler). # Includes: Plex, Radarr, Sonarr, SABnzbd, Overseerr, Wizarr, and their # Authentik proxy outposts. # # Architecture: # All containers share the proxy-net bridge network. Traefik-kop on statler # reads container labels and publishes routes to Heimdall's Redis, where # the external Traefik picks them up. # Plex config is served from the TNAS share at /mnt/homelab/apps/plex/data. # Media (TV/Movies/Downloads) is served from /mnt/media (TNAS Volume2). # Service configs (Radarr, Sonarr, etc.) are served from /mnt/homelab/apps. # # Pre-requisites: # - NFS shares mounted on target host (mount_nfs_shares.yml must have run): # /mnt/homelab (TNAS Volume1/appdata) # /mnt/media (TNAS Volume2/media) # - traefik-kop-agent must be running on the target host. # - vault_plex_claim and vault_authentik_token_* must be present and decrypted. # # Usage: # ansible-playbook -i inventory/hosts.ini playbooks/docker/deploy_plex_standalone.yml \ # -e "target_host=statler" # # Tear down a single service (example): # ansible-playbook ... -e "target_host=statler plex_deploy_state=absent" # # Verification after deploy: # docker ps on statler # curl http://10.0.0.210:32400/identity # redis-cli -h 10.0.0.151 keys 'traefik/*sonarr*' - name: Deploy Plex media stack on standalone Docker host hosts: "{{ target_host | default('statler') }}" become: true gather_facts: false vars_files: - ../../group_vars/all.yml vars: plex_network: "proxy-net" plex_config_dir: "/mnt/homelab/apps/plex/data" plex_tv_dir: "/mnt/media/tvshows" plex_movies_dir: "/mnt/media/movies" media_base: "/mnt/media" sabnzbd_config_dir: "/mnt/homelab/apps/sabnzbd/data" sonarr_config_dir: "/mnt/homelab/apps/sonarr/data" radarr_config_dir: "/mnt/homelab/apps/radarr/data" overseerr_config_dir: "/mnt/homelab/apps/overseerr/data" wizarr_config_dir: "/mnt/homelab/apps/wizarr/data/database" tasks: # -------------------------------------------------- # STEP 0: Safety assertions # -------------------------------------------------- - name: Assert target_host is explicit and safe ansible.builtin.assert: that: - target_host is defined - target_host | length > 0 - target_host not in ['all', '*', 'ubuntu_lab', 'docker_hosts', 'swarm_hosts'] fail_msg: >- Invalid target_host scope. Use an explicit host, e.g.: -e "target_host=statler" run_once: true delegate_to: localhost - name: Assert required secrets are available and decrypted ansible.builtin.assert: that: - vault_plex_claim is defined - vault_plex_claim | trim | length > 0 - vault_plex_claim is not search('^\$ANSIBLE_VAULT;') - vault_authentik_token_sonarr is defined - vault_authentik_token_sonarr | trim | length > 0 - vault_authentik_token_sonarr is not search('^\$ANSIBLE_VAULT;') - vault_authentik_token_radarr is defined - vault_authentik_token_radarr | trim | length > 0 - vault_authentik_token_radarr is not search('^\$ANSIBLE_VAULT;') - vault_authentik_token_sabnzbd is defined - vault_authentik_token_sabnzbd | trim | length > 0 - vault_authentik_token_sabnzbd is not search('^\$ANSIBLE_VAULT;') fail_msg: >- One or more required secrets are unavailable or not decrypted. Required: vault_plex_claim, vault_authentik_token_sonarr, vault_authentik_token_radarr, vault_authentik_token_sabnzbd. - name: Assert TNAS Plex config directory is mounted and accessible ansible.builtin.stat: path: "{{ plex_config_dir }}" register: _plex_config_stat - name: Fail if TNAS Plex config path does not exist ansible.builtin.assert: that: - _plex_config_stat.stat.exists - _plex_config_stat.stat.isdir fail_msg: >- {{ plex_config_dir }} does not exist or is not a directory. Ensure the TNAS NFS share is mounted: run mount_nfs_shares.yml first. - name: Assert media NFS shares are mounted ansible.builtin.stat: path: "{{ item }}" register: _media_stat loop: - "{{ plex_tv_dir }}" - "{{ plex_movies_dir }}" - name: Fail if media paths are not mounted ansible.builtin.assert: that: - item.stat.exists - item.stat.isdir fail_msg: >- Media path {{ item.item }} is not accessible on {{ inventory_hostname }}. Ensure /mnt/media NFS share is mounted: run mount_nfs_shares.yml first. loop: "{{ _media_stat.results }}" # -------------------------------------------------- # STEP 1: Ensure proxy-net bridge network exists # -------------------------------------------------- - name: Ensure proxy-net bridge network exists community.docker.docker_network: name: "{{ plex_network }}" driver: bridge state: present # -------------------------------------------------- # STEP 2: Ensure service config directories exist on appdata mount # WHY these dirs are on /mnt/homelab: shared appdata policy for statler # services while keeping explicit paths in deployment automation. # -------------------------------------------------- - name: Ensure local service config directories exist ansible.builtin.file: path: "{{ item }}" state: directory owner: "1000" group: "1000" mode: '0755' loop: - "{{ sabnzbd_config_dir }}" - "{{ sonarr_config_dir }}" - "{{ radarr_config_dir }}" - "{{ overseerr_config_dir }}" - "{{ wizarr_config_dir }}" # -------------------------------------------------- # STEP 3: Plex # -------------------------------------------------- - name: Deploy Plex Media Server community.docker.docker_container: name: plex image: lscr.io/linuxserver/plex:latest pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "32400:32400" env: PUID: "1000" PGID: "1000" TZ: America/New_York PLEX_CLAIM: "{{ vault_plex_claim }}" VERSION: docker volumes: - "{{ plex_config_dir }}:/config" - "{{ plex_tv_dir }}:/tv" - "{{ plex_movies_dir }}:/movies" networks: - name: "{{ plex_network }}" memory: 4g cpus: 2.0 # -------------------------------------------------- # STEP 4: SABnzbd + outpost # -------------------------------------------------- - name: Deploy SABnzbd community.docker.docker_container: name: sabnzbd image: lscr.io/linuxserver/sabnzbd:4.5.5-ls239 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "8155:8080" env: PUID: "1000" PGID: "1000" TZ: America/New_York volumes: - "{{ sabnzbd_config_dir }}:/config" - "{{ media_base }}/incoming/downloads-sab/complete:/downloads" - "{{ media_base }}/incoming/downloads-sab/incomplete:/incomplete-downloads" - "{{ media_base }}/incoming/downloads-sab/history:/history" networks: - name: "{{ plex_network }}" labels: homepage.name: SABnzbd homepage.icon: si:sabnzbd homepage.url: https://sab.castaldifamily.com homepage.description: Usenet downloader memory: 1g cpus: 0.5 - name: Deploy Authentik outpost for SABnzbd community.docker.docker_container: name: authentik-outpost-sabnzbd image: ghcr.io/goauthentik/proxy:2025.10.3 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "9004:9000" - "9447:9443" env: AUTHENTIK_HOST: https://sso.castaldifamily.com AUTHENTIK_INSECURE: "false" AUTHENTIK_TOKEN: "{{ vault_authentik_token_sabnzbd }}" AUTHENTIK_HOST_BROWSER: https://sso.castaldifamily.com networks: - name: "{{ plex_network }}" labels: traefik.enable: "true" traefik.http.routers.sabnzbd.entrypoints: websecure traefik.http.routers.sabnzbd.rule: "Host(`sab.castaldifamily.com`)" traefik.http.routers.sabnzbd.tls: "true" traefik.http.routers.sabnzbd.tls.certresolver: cloudflare traefik.http.services.sabnzbd.loadbalancer.server.port: "9004" memory: 256m cpus: 0.25 # -------------------------------------------------- # STEP 5: Sonarr + outpost # -------------------------------------------------- - name: Deploy Sonarr community.docker.docker_container: name: sonarr image: lscr.io/linuxserver/sonarr:4.0.16.2944-ls300 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "8989:8989" env: PUID: "1000" PGID: "1000" TZ: America/New_York volumes: - "{{ sonarr_config_dir }}:/config" - "{{ plex_tv_dir }}:/tv" - "{{ media_base }}/incoming/downloads-sab/complete/sonarr:/downloads/sonarr" networks: - name: "{{ plex_network }}" labels: homepage.name: Sonarr homepage.icon: si:sonarr homepage.url: https://sonarr.castaldifamily.com homepage.description: TV Shows memory: 1g cpus: 0.5 - name: Deploy Authentik outpost for Sonarr community.docker.docker_container: name: authentik-outpost-sonarr image: ghcr.io/goauthentik/proxy:2025.10.3 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "9001:9000" - "9444:9443" env: AUTHENTIK_HOST: https://sso.castaldifamily.com AUTHENTIK_INSECURE: "false" AUTHENTIK_TOKEN: "{{ vault_authentik_token_sonarr }}" AUTHENTIK_HOST_BROWSER: https://sso.castaldifamily.com networks: - name: "{{ plex_network }}" labels: traefik.enable: "true" traefik.http.routers.sonarr.entrypoints: websecure traefik.http.routers.sonarr.rule: "Host(`sonarr.castaldifamily.com`)" traefik.http.routers.sonarr.tls: "true" traefik.http.routers.sonarr.tls.certresolver: cloudflare traefik.http.services.sonarr.loadbalancer.server.port: "9001" memory: 256m cpus: 0.25 # -------------------------------------------------- # STEP 6: Radarr + outpost # -------------------------------------------------- - name: Deploy Radarr community.docker.docker_container: name: radarr image: lscr.io/linuxserver/radarr:6.0.4.10291-ls289 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "7878:7878" env: PUID: "1000" PGID: "1000" TZ: America/New_York volumes: - "{{ radarr_config_dir }}:/config" - "{{ plex_movies_dir }}:/movies" - "{{ media_base }}/incoming/downloads-sab/complete/radarr:/downloads/radarr" networks: - name: "{{ plex_network }}" labels: homepage.name: Radarr homepage.icon: si:radarr homepage.url: https://radarr.castaldifamily.com homepage.description: Movies & shows memory: 1g cpus: 0.5 - name: Deploy Authentik outpost for Radarr community.docker.docker_container: name: authentik-outpost-radarr image: ghcr.io/goauthentik/proxy:2025.10.3 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "9002:9000" - "9445:9443" env: AUTHENTIK_HOST: https://sso.castaldifamily.com AUTHENTIK_INSECURE: "false" AUTHENTIK_TOKEN: "{{ vault_authentik_token_radarr }}" AUTHENTIK_HOST_BROWSER: https://sso.castaldifamily.com AUTHENTIK_INSECURE_SKIP_VERIFY: "false" TRUST_PROXY_HEADERS: "true" networks: - name: "{{ plex_network }}" labels: traefik.enable: "true" traefik.http.routers.radarr.entrypoints: websecure traefik.http.routers.radarr.rule: "Host(`radarr.castaldifamily.com`)" traefik.http.routers.radarr.tls: "true" traefik.http.routers.radarr.tls.certresolver: cloudflare traefik.http.services.radarr.loadbalancer.server.port: "9002" memory: 256m cpus: 0.25 # -------------------------------------------------- # STEP 7: Overseerr # -------------------------------------------------- - name: Deploy Overseerr community.docker.docker_container: name: overseerr image: lscr.io/linuxserver/overseerr:1.34.0 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "8150:5055" env: PUID: "1000" PGID: "1000" TZ: America/New_York volumes: - "{{ overseerr_config_dir }}:/config" networks: - name: "{{ plex_network }}" labels: traefik.enable: "true" traefik.http.routers.overseerr.entrypoints: websecure traefik.http.routers.overseerr.rule: "Host(`overseerr.castaldifamily.com`)" traefik.http.routers.overseerr.tls: "true" traefik.http.routers.overseerr.tls.certresolver: cloudflare traefik.http.routers.overseerr.service: overseerr traefik.http.services.overseerr.loadbalancer.server.port: "8150" homepage.name: Overseerr homepage.icon: si:overseerr homepage.url: https://overseerr.castaldifamily.com homepage.description: Media request management memory: 512m cpus: 0.2 # -------------------------------------------------- # STEP 8: Wizarr # NOTE: homelab_status=broken in source-compose. Deploying as-is; SSO # integration requires a dedicated Authentik outpost token (not yet # configured). DISABLE_BUILTIN_AUTH=True means the web UI will be # unprotected until the outpost is wired up. # -------------------------------------------------- - name: Deploy Wizarr community.docker.docker_container: name: wizarr image: ghcr.io/wizarrrr/wizarr:v2025.12.0 pull: always restart_policy: unless-stopped state: "{{ plex_deploy_state | default('started') }}" published_ports: - "8157:5690" env: PUID: "1000" PGID: "1000" TZ: America/New_York DISABLE_BUILTIN_AUTH: "True" volumes: - "{{ wizarr_config_dir }}:/data/database" networks: - name: "{{ plex_network }}" labels: traefik.enable: "true" traefik.http.routers.wizarr.entrypoints: websecure traefik.http.routers.wizarr.rule: "Host(`wizarr.castaldifamily.com`)" traefik.http.routers.wizarr.tls: "true" traefik.http.routers.wizarr.tls.certresolver: cloudflare traefik.http.routers.wizarr.service: wizarr traefik.http.services.wizarr.loadbalancer.server.port: "8157" homepage.name: Wizarr homepage.icon: si:wizarr homepage.url: https://wizarr.castaldifamily.com homepage.description: Media management memory: 512m cpus: 0.2 # -------------------------------------------------- # STEP 9: Summary # -------------------------------------------------- - name: Show deployment summary ansible.builtin.debug: msg: - "Plex media stack deployed to {{ inventory_hostname }}" - "Plex config : {{ plex_config_dir }} (TNAS)" - "Media : {{ media_base }} (TNAS)" - "Network : {{ plex_network }}" - "Services : plex, sabnzbd, sonarr, radarr, overseerr, wizarr" - "Outposts : sabnzbd (9004), sonarr (9001), radarr (9002)"