Add external arr compose stack

This commit is contained in:
juvdiaz 2026-06-27 09:33:43 -06:00
parent f1c405aab4
commit dfda7fccfb
9 changed files with 543 additions and 1 deletions

4
.gitignore vendored
View File

@ -21,3 +21,7 @@ sops-age.key
# Ignore older source iterations
*.old
# Ignore local Python bytecode from validation helpers
__pycache__/
*.py[cod]

View File

@ -544,7 +544,9 @@ pipeline. It is better suited to a Docker Compose stack on the Debian host with
persistent volumes under `/data`, where large media paths, qBittorrent writes,
and service config directories can survive cluster rebuilds without OpenEBS
node-affinity or PVC migration concerns. The legacy `apps/arr-stack` manifests
are retained only as migration reference.
are retained only as migration reference. The active host-level Compose
definition lives under `infra/arr-stack` and stores persistent data under
`/data/arr`.
## Storage

View File

@ -0,0 +1,28 @@
ARR_ROOT=/data/arr
PUID=1000
PGID=1000
TZ=America/Mexico_City
PROWLARR_PORT=9696
RADARR_PORT=7878
SONARR_PORT=8989
QBITTORRENT_PORT=8080
QBITTORRENT_TORRENTING_PORT=6881
FLARESOLVERR_PORT=8191
KAPOWARR_PORT=5656
SUWAYOMI_PORT=4567
MAINTAINERR_PORT=6246
# Optional bootstrap values. Leave blank until the apps have created API keys.
PROWLARR_API_KEY=
RADARR_API_KEY=
SONARR_API_KEY=
QBITTORRENT_USERNAME=admin
QBITTORRENT_PASSWORD=
RADARR_URL=http://127.0.0.1:7878
SONARR_URL=http://127.0.0.1:8989
RADARR_ROOT_FOLDER=/data/media/movies
SONARR_ROOT_FOLDER=/data/media/tv
RADARR_IMPORT_LISTS_JSON=
SONARR_IMPORT_LISTS_JSON=

49
infra/arr-stack/README.md Normal file
View File

@ -0,0 +1,49 @@
# External ARR Stack
This Docker Compose stack runs the media-management apps outside Kubernetes on
the Debian host. It is not called by `lab.sh` and is not part of the homelab
OpenTofu/Argo pipeline.
Services:
- Prowlarr: `9696`
- Radarr: `7878`
- Sonarr: `8989`
- qBittorrent: `8080`
- FlareSolverr: `8191`
- Kapowarr: `5656`
- Suwayomi: `4567`
- Maintainerr: `6246`
Persistent storage defaults to `/data/arr`.
## Start
```bash
cd infra/arr-stack
cp .env.example .env
./bootstrap.sh
```
The helper creates `/data/arr`, fixes ownership for `PUID`/`PGID`, and starts
the Compose stack.
## Manage
```bash
docker compose --env-file .env -f docker-compose.yml ps
docker compose --env-file .env -f docker-compose.yml logs -f
docker compose --env-file .env -f docker-compose.yml pull
docker compose --env-file .env -f docker-compose.yml up -d
docker compose --env-file .env -f docker-compose.yml down
```
`down` stops containers but keeps `/data/arr` intact.
## Bootstrap
See `docs/bootstrap.md` for path wiring and safe import-list guidance.
After the first boot and API-key discovery, `./configure-arr.py` can configure
Radarr/Sonarr root folders and qBittorrent download clients without putting
credentials in Git.

60
infra/arr-stack/bootstrap.sh Executable file
View File

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ARR_ENV_FILE:-${SCRIPT_DIR}/.env}"
if [[ ! -f "${ENV_FILE}" ]]; then
cp "${SCRIPT_DIR}/.env.example" "${ENV_FILE}"
fi
set -a
# shellcheck disable=SC1090
. "${ENV_FILE}"
set +a
ARR_ROOT="${ARR_ROOT:-/data/arr}"
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
sudo mkdir -p \
"${ARR_ROOT}/config/flaresolverr" \
"${ARR_ROOT}/config/prowlarr" \
"${ARR_ROOT}/config/radarr" \
"${ARR_ROOT}/config/sonarr" \
"${ARR_ROOT}/config/qbittorrent" \
"${ARR_ROOT}/config/kapowarr" \
"${ARR_ROOT}/config/suwayomi" \
"${ARR_ROOT}/config/maintainerr" \
"${ARR_ROOT}/downloads/complete" \
"${ARR_ROOT}/downloads/incomplete" \
"${ARR_ROOT}/downloads/torrents" \
"${ARR_ROOT}/media/movies" \
"${ARR_ROOT}/media/tv" \
"${ARR_ROOT}/media/comics" \
"${ARR_ROOT}/media/manga"
sudo chown -R "${PUID}:${PGID}" "${ARR_ROOT}"
docker compose --env-file "${ENV_FILE}" -f "${SCRIPT_DIR}/docker-compose.yml" up -d
cat <<EOF
ARR stack requested.
Storage root:
${ARR_ROOT}
Web UIs:
Prowlarr: http://127.0.0.1:${PROWLARR_PORT:-9696}
Radarr: http://127.0.0.1:${RADARR_PORT:-7878}
Sonarr: http://127.0.0.1:${SONARR_PORT:-8989}
qBittorrent: http://127.0.0.1:${QBITTORRENT_PORT:-8080}
FlareSolverr: http://127.0.0.1:${FLARESOLVERR_PORT:-8191}
Kapowarr: http://127.0.0.1:${KAPOWARR_PORT:-5656}
Suwayomi: http://127.0.0.1:${SUWAYOMI_PORT:-4567}
Maintainerr: http://127.0.0.1:${MAINTAINERR_PORT:-6246}
Next:
Update ${ENV_FILE} with generated API keys, then use docs/bootstrap.md for
safe app wiring and import-list guidance.
EOF

190
infra/arr-stack/configure-arr.py Executable file
View File

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Configure safe baseline ARR app settings from local environment values."""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
ENV_FILE = Path(os.environ.get("ARR_ENV_FILE", SCRIPT_DIR / ".env"))
def load_env(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def request_json(method: str, url: str, api_key: str, payload: dict | None = None) -> object:
data = None
headers = {"X-Api-Key": api_key, "Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(request, timeout=15) as response:
body = response.read()
if not body:
return {}
return json.loads(body.decode("utf-8"))
def wait_for_app(name: str, base_url: str, api_key: str) -> None:
for attempt in range(1, 31):
try:
request_json("GET", f"{base_url}/api/v3/system/status", api_key)
return
except Exception as exc: # noqa: BLE001
if attempt == 30:
raise RuntimeError(f"{name} did not become reachable at {base_url}") from exc
time.sleep(2)
def ensure_root_folder(name: str, base_url: str, api_key: str, path: str) -> None:
existing = request_json("GET", f"{base_url}/api/v3/rootfolder", api_key)
if isinstance(existing, list) and any(item.get("path") == path for item in existing if isinstance(item, dict)):
print(f"{name}: root folder already exists: {path}")
return
request_json("POST", f"{base_url}/api/v3/rootfolder", api_key, {"path": path})
print(f"{name}: added root folder: {path}")
def schema_fields(base_url: str, api_key: str, endpoint: str, implementation: str) -> list[dict]:
schemas = request_json("GET", f"{base_url}/api/v3/{endpoint}/schema", api_key)
if not isinstance(schemas, list):
return []
for schema in schemas:
if isinstance(schema, dict) and schema.get("implementation") == implementation:
fields = schema.get("fields")
return fields if isinstance(fields, list) else []
return []
def fill_fields(fields: list[dict], values: dict[str, object]) -> list[dict]:
hydrated = []
for field in fields:
if not isinstance(field, dict):
continue
item = dict(field)
name = item.get("name")
if name in values:
item["value"] = values[name]
hydrated.append(item)
return hydrated
def ensure_qbittorrent(name: str, base_url: str, api_key: str, category: str) -> None:
clients = request_json("GET", f"{base_url}/api/v3/downloadclient", api_key)
if isinstance(clients, list) and any(
isinstance(item, dict) and item.get("implementation") == "QBittorrent" for item in clients
):
print(f"{name}: qBittorrent download client already exists")
return
fields = schema_fields(base_url, api_key, "downloadclient", "QBittorrent")
if not fields:
raise RuntimeError(f"{name}: could not find qBittorrent download client schema")
port = int(os.environ.get("QBITTORRENT_PORT", "8080"))
values = {
"host": "qbittorrent",
"port": port,
"urlBase": "",
"username": os.environ.get("QBITTORRENT_USERNAME", "admin"),
"password": os.environ.get("QBITTORRENT_PASSWORD", ""),
"category": category,
"movieCategory": category,
"tvCategory": category,
"recentMoviePriority": 0,
"olderMoviePriority": 0,
"recentTvPriority": 0,
"olderTvPriority": 0,
"addPaused": False,
"useSsl": False,
}
payload = {
"enable": True,
"protocol": "torrent",
"priority": 1,
"removeCompletedDownloads": True,
"removeFailedDownloads": True,
"name": "qBittorrent",
"implementation": "QBittorrent",
"configContract": "QBittorrentSettings",
"fields": fill_fields(fields, values),
"tags": [],
}
request_json("POST", f"{base_url}/api/v3/downloadclient", api_key, payload)
print(f"{name}: added qBittorrent download client")
def apply_import_lists(name: str, base_url: str, api_key: str, path_env: str) -> None:
list_path = os.environ.get(path_env, "")
if not list_path:
return
path = Path(list_path)
if not path.exists():
raise RuntimeError(f"{name}: {path_env} points to missing file {path}")
desired = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(desired, list):
raise RuntimeError(f"{name}: {path} must contain a JSON array")
existing = request_json("GET", f"{base_url}/api/v3/importlist", api_key)
existing_names = set()
if isinstance(existing, list):
existing_names = {item.get("name") for item in existing if isinstance(item, dict)}
for item in desired:
if not isinstance(item, dict) or not item.get("name"):
raise RuntimeError(f"{name}: every import-list entry must be an object with a name")
if item["name"] in existing_names:
print(f"{name}: import list already exists: {item['name']}")
continue
request_json("POST", f"{base_url}/api/v3/importlist", api_key, item)
print(f"{name}: added import list: {item['name']}")
def required_env(name: str) -> str:
value = os.environ.get(name, "")
if not value:
raise RuntimeError(f"Set {name} in {ENV_FILE} before running this script")
return value
def main() -> int:
load_env(ENV_FILE)
radarr_key = required_env("RADARR_API_KEY")
sonarr_key = required_env("SONARR_API_KEY")
radarr_url = os.environ.get("RADARR_URL", "http://127.0.0.1:7878")
sonarr_url = os.environ.get("SONARR_URL", "http://127.0.0.1:8989")
wait_for_app("Radarr", radarr_url, radarr_key)
wait_for_app("Sonarr", sonarr_url, sonarr_key)
ensure_root_folder("Radarr", radarr_url, radarr_key, os.environ.get("RADARR_ROOT_FOLDER", "/data/media/movies"))
ensure_root_folder("Sonarr", sonarr_url, sonarr_key, os.environ.get("SONARR_ROOT_FOLDER", "/data/media/tv"))
ensure_qbittorrent("Radarr", radarr_url, radarr_key, "radarr")
ensure_qbittorrent("Sonarr", sonarr_url, sonarr_key, "sonarr")
apply_import_lists("Radarr", radarr_url, radarr_key, "RADARR_IMPORT_LISTS_JSON")
apply_import_lists("Sonarr", sonarr_url, sonarr_key, "SONARR_IMPORT_LISTS_JSON")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (RuntimeError, urllib.error.URLError, urllib.error.HTTPError) as error:
print(f"error: {error}", file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,122 @@
name: homelab-arr
x-linuxserver-common: &linuxserver-common
restart: unless-stopped
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
TZ: ${TZ:-America/Mexico_City}
networks:
- arr
services:
flaresolverr:
image: ${FLARESOLVERR_IMAGE:-ghcr.io/flaresolverr/flaresolverr:latest}
container_name: ${FLARESOLVERR_CONTAINER_NAME:-arr-flaresolverr}
restart: unless-stopped
environment:
LOG_LEVEL: ${FLARESOLVERR_LOG_LEVEL:-info}
TZ: ${TZ:-America/Mexico_City}
ports:
- "${FLARESOLVERR_PORT:-8191}:8191"
networks:
- arr
prowlarr:
<<: *linuxserver-common
image: ${PROWLARR_IMAGE:-lscr.io/linuxserver/prowlarr:latest}
container_name: ${PROWLARR_CONTAINER_NAME:-arr-prowlarr}
ports:
- "${PROWLARR_PORT:-9696}:9696"
volumes:
- ${ARR_ROOT:-/data/arr}/config/prowlarr:/config
radarr:
<<: *linuxserver-common
image: ${RADARR_IMAGE:-lscr.io/linuxserver/radarr:latest}
container_name: ${RADARR_CONTAINER_NAME:-arr-radarr}
ports:
- "${RADARR_PORT:-7878}:7878"
volumes:
- ${ARR_ROOT:-/data/arr}/config/radarr:/config
- ${ARR_ROOT:-/data/arr}:/data
sonarr:
<<: *linuxserver-common
image: ${SONARR_IMAGE:-lscr.io/linuxserver/sonarr:latest}
container_name: ${SONARR_CONTAINER_NAME:-arr-sonarr}
ports:
- "${SONARR_PORT:-8989}:8989"
volumes:
- ${ARR_ROOT:-/data/arr}/config/sonarr:/config
- ${ARR_ROOT:-/data/arr}:/data
qbittorrent:
<<: *linuxserver-common
image: ${QBITTORRENT_IMAGE:-lscr.io/linuxserver/qbittorrent:latest}
container_name: ${QBITTORRENT_CONTAINER_NAME:-arr-qbittorrent}
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
TZ: ${TZ:-America/Mexico_City}
WEBUI_PORT: ${QBITTORRENT_PORT:-8080}
TORRENTING_PORT: ${QBITTORRENT_TORRENTING_PORT:-6881}
ports:
- "${QBITTORRENT_PORT:-8080}:${QBITTORRENT_PORT:-8080}"
- "${QBITTORRENT_TORRENTING_PORT:-6881}:${QBITTORRENT_TORRENTING_PORT:-6881}"
- "${QBITTORRENT_TORRENTING_PORT:-6881}:${QBITTORRENT_TORRENTING_PORT:-6881}/udp"
volumes:
- ${ARR_ROOT:-/data/arr}/config/qbittorrent:/config
- ${ARR_ROOT:-/data/arr}:/data
kapowarr:
image: ${KAPOWARR_IMAGE:-mrcas/kapowarr:latest}
container_name: ${KAPOWARR_CONTAINER_NAME:-arr-kapowarr}
restart: unless-stopped
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
TZ: ${TZ:-America/Mexico_City}
ports:
- "${KAPOWARR_PORT:-5656}:5656"
volumes:
- ${ARR_ROOT:-/data/arr}/config/kapowarr:/app/db
- ${ARR_ROOT:-/data/arr}/downloads/torrents:/app/temp_downloads
- ${ARR_ROOT:-/data/arr}/media/comics:/comics
networks:
- arr
suwayomi:
image: ${SUWAYOMI_IMAGE:-ghcr.io/suwayomi/suwayomi-server:preview}
container_name: ${SUWAYOMI_CONTAINER_NAME:-arr-suwayomi}
restart: unless-stopped
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
TZ: ${TZ:-America/Mexico_City}
ports:
- "${SUWAYOMI_PORT:-4567}:4567"
volumes:
- ${ARR_ROOT:-/data/arr}/config/suwayomi:/home/suwayomi/.local/share/Suwayomi-Server
- ${ARR_ROOT:-/data/arr}/media/manga:/data/manga
networks:
- arr
maintainerr:
image: ${MAINTAINERR_IMAGE:-ghcr.io/jorenn92/maintainerr:latest}
container_name: ${MAINTAINERR_CONTAINER_NAME:-arr-maintainerr}
restart: unless-stopped
environment:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
TZ: ${TZ:-America/Mexico_City}
ports:
- "${MAINTAINERR_PORT:-6246}:6246"
volumes:
- ${ARR_ROOT:-/data/arr}/config/maintainerr:/opt/data
networks:
- arr
networks:
arr:
name: homelab-arr

View File

@ -0,0 +1,77 @@
# ARR Stack Bootstrap Notes
This stack is intentionally separate from `lab.sh`, OpenTofu, Argo CD, and the
Kubernetes app pipeline.
## Storage Layout
All persistent data lives under `/data/arr` by default:
- `/data/arr/config/<app>` for app databases and settings
- `/data/arr/downloads/complete`
- `/data/arr/downloads/incomplete`
- `/data/arr/downloads/torrents`
- `/data/arr/media/movies`
- `/data/arr/media/tv`
- `/data/arr/media/comics`
- `/data/arr/media/manga`
Use the same in-container paths when wiring apps:
- Radarr root folder: `/data/media/movies`
- Sonarr root folder: `/data/media/tv`
- qBittorrent completed downloads: `/data/downloads/complete`
- qBittorrent incomplete downloads: `/data/downloads/incomplete`
## Initial Run
```bash
cd infra/arr-stack
cp .env.example .env
./bootstrap.sh
```
After first boot, each app writes its own API key into its config. Retrieve the
keys from:
```bash
grep -h '<ApiKey>' /data/arr/config/radarr/config.xml /data/arr/config/sonarr/config.xml /data/arr/config/prowlarr/config.xml
```
Then copy the keys into `.env`.
Run the optional safe baseline configurator:
```bash
./configure-arr.py
```
It configures Radarr and Sonarr root folders and adds qBittorrent as a download
client by using the apps' own schemas. It does not configure indexers.
## Public Lists
Radarr and Sonarr can use import lists, but most useful providers require user
authorization or provider API keys. Do not hard-code third-party credentials or
piracy-oriented sources in this repo.
Safe examples to configure from the UI:
- Radarr: TMDb popular, trending, or collection lists with your TMDb API access
- Radarr/Sonarr: your own Trakt public lists after authorizing Trakt
- Sonarr: your own Trakt watched/collection/list sources after authorizing Trakt
Recommended settings for any import list:
- Set a tag such as `public-list` or `watchlist`.
- Disable automatic search until quality profiles and root folders are correct.
- Use `/data/media/movies` for Radarr and `/data/media/tv` for Sonarr.
- Keep monitoring conservative until you confirm disk usage and download rules.
Prowlarr indexers and download-client credentials should be configured in the UI
or from a local, uncommitted automation file. Keep that material out of Git.
If you want to automate import lists, export `RADARR_IMPORT_LISTS_JSON` or
`SONARR_IMPORT_LISTS_JSON` to a local JSON file that contains the exact payloads
for your authorized providers. `examples/import-lists.example.json` is only a
shape placeholder; do not use it as-is.

View File

@ -0,0 +1,10 @@
[
{
"name": "Example import list placeholder",
"enable": false,
"implementation": "ReplaceWithProviderImplementation",
"configContract": "ReplaceWithProviderSettings",
"fields": [],
"tags": []
}
]