449 lines
16 KiB
YAML
449 lines
16 KiB
YAML
---
|
|
# 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)"
|