feat: add media server

This commit is contained in:
Yuris Cakranegara
2025-08-21 17:42:48 +10:00
parent 60e3a41ac5
commit bce43c4a71
15 changed files with 1239 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
# Example secrets for the *arr stack (used via terraform-provider-dotenv)
# Copy to .env in this same directory and fill in values.
# Required keys for helpers
SONARR_API_KEY=
RADARR_API_KEY=
LIDARR_API_KEY=
QBITTORRENT_USERNAME=
QBITTORRENT_PASSWORD=
# Optional keys for Flaresolverr
LOG_LEVEL=info
LOG_HTML=false
CAPTCHA_SOLVER=none
# Optional keys for Decluttarr
# Uncomment and adjust as needed. Defaults shown here align with the README.
#DECLUTTARR_LOG_LEVEL=INFO
#DECLUTTARR_TEST_RUN=False
#DECLUTTARR_REMOVE_TIMER=10
#DECLUTTARR_REMOVE_FAILED=True
#DECLUTTARR_REMOVE_FAILED_IMPORTS=True
#DECLUTTARR_REMOVE_METADATA_MISSING=True
#DECLUTTARR_REMOVE_MISSING_FILES=True
#DECLUTTARR_REMOVE_ORPHANS=True
#DECLUTTARR_REMOVE_SLOW=True
#DECLUTTARR_REMOVE_STALLED=True
#DECLUTTARR_REMOVE_UNMONITORED=True
#DECLUTTARR_RUN_PERIODIC_RESCANS=
#DECLUTTARR_PERMITTED_ATTEMPTS=3
#DECLUTTARR_REMOVAL_QBIT_TAG=stalled
#DECLUTTARR_MIN_DOWNLOAD_SPEED=100
#DECLUTTARR_FAILED_IMPORT_MESSAGE_PATTERNS=
#DECLUTTARR_IGNORED_DOWNLOAD_CLIENTS=

View File

@@ -0,0 +1,140 @@
# *arr Stack Module
This module deploys the *arr media stack components as Docker containers and provides a service definition for Jellyseerr to be published via your reverse proxy.
## Overview
The module includes the following containers:
- Sonarr (TV)
- Radarr (Movies)
- Lidarr (Music)
- Bazarr (Subtitles)
- Prowlarr (Indexers)
- Jellyseerr (Requests) — published via reverse proxy
- Flaresolverr (optional helper)
- Unpackerr (post-processing)
- Cleanuparr (cleanup helpers)
- Decluttarr (torrent queue cleanup helper)
All containers share a common `/data` mount (media root) and are attached to the media Docker network. Only Jellyseerr is also attached to the proxy network for reachability by Caddy.
## Usage
```hcl
module "arr" {
source = "./modules/20-services-apps/arr"
volume_path = "/srv/appdata/arr" # host path for config directories
data_path = "/srv/data" # host media root, mounted as /data in containers
downloads_path = "/srv/data/torrents" # host downloads dir, mounted for unpackerr
networks = [module.media_docker_network.name]
proxy_networks = [module.homelab_docker_network.name] # so Jellyseerr is reachable by Caddy
}
```
## Variables
| Variable | Description | Type | Default |
| ------------------ | ------------------------------------------------------------------------------- | -------------- | --------------- |
| `volume_path` | Base directory for config volumes for the *arr stack | `string` | - |
| `data_path` | Base directory for media/data mounted at `/data` | `string` | - |
| `downloads_path` | Directory for downloads mounted at `/data/torrents` (Unpackerr) | `string` | - |
| `networks` | Networks to attach all containers to | `list(string)` | `[]` |
| `proxy_networks` | Extra networks attached only to published services (Jellyseerr) | `list(string)` | `[]` |
| `qbittorrent_host` | Hostname to reach qBittorrent (use `gluetun` when qBittorrent shares Gluetun) | `string` | `"qbittorrent"` |
## Outputs
| Output | Description |
| -------------------- | --------------------------------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules to expose the service.
```hcl
{
name = "jellyseerr"
primary_port = 5055
endpoint = "http://jellyseerr:5055"
subdomains = ["req"]
publish_via = "reverse_proxy"
proxied = false
}
```
## Environment Variables (.env)
This module reads secrets via the `dotenv` provider from a `.env` file located in this module directory. Use `.env.example` as a template.
Required/used keys:
- `SONARR_API_KEY` — for Unpackerr, Cleanuparr, Decluttarr
- `RADARR_API_KEY` — for Unpackerr, Cleanuparr, Decluttarr
- `LIDARR_API_KEY` — for Decluttarr
- `QBITTORRENT_USERNAME` — for Decluttarr
- `QBITTORRENT_PASSWORD` — for Cleanuparr and Decluttarr
- `LOG_LEVEL` — for Flaresolverr (optional)
- `LOG_HTML` — for Flaresolverr (optional)
- `CAPTCHA_SOLVER` — for Flaresolverr (optional)
Optional Decluttarr keys (override defaults as needed):
- `DECLUTTARR_LOG_LEVEL` (default: `INFO`)
- `DECLUTTARR_TEST_RUN` (default: `False`)
- `DECLUTTARR_REMOVE_TIMER` (default: `10`)
- `DECLUTTARR_REMOVE_FAILED` (default: `True`)
- `DECLUTTARR_REMOVE_FAILED_IMPORTS` (default: `True`)
- `DECLUTTARR_REMOVE_METADATA_MISSING` (default: `True`)
- `DECLUTTARR_REMOVE_MISSING_FILES` (default: `True`)
- `DECLUTTARR_REMOVE_ORPHANS` (default: `True`)
- `DECLUTTARR_REMOVE_SLOW` (default: `True`)
- `DECLUTTARR_REMOVE_STALLED` (default: `True`)
- `DECLUTTARR_REMOVE_UNMONITORED` (default: `True`)
- `DECLUTTARR_RUN_PERIODIC_RESCANS` (default: empty)
- `DECLUTTARR_PERMITTED_ATTEMPTS` (default: `3`)
- `DECLUTTARR_REMOVAL_QBIT_TAG` (default: `stalled`)
- `DECLUTTARR_MIN_DOWNLOAD_SPEED` (default: `100`)
- `DECLUTTARR_FAILED_IMPORT_MESSAGE_PATTERNS` (default: empty)
- `DECLUTTARR_IGNORED_DOWNLOAD_CLIENTS` (default: empty)
Note: `TZ`, `PUID`, `PGID` are injected automatically by the generic docker-service module from `modules/00-globals/system` and do not need to be in this `.env`.
## Data Persistence
- Each app stores configuration under `${volume_path}/<app>/...` mounted to `/config` (or as noted by the specific app).
- Media library and downloads are accessed under `/data` inside containers, pointing to `data_path` on the host.
- Unpackerr mounts `downloads_path` to `/data/torrents`.
## Networking
- All containers join `networks` (media network).
- Jellyseerr additionally joins `proxy_networks` for reverse proxy reachability.
## Dependencies
- No explicit inter-container dependencies are defined. Healthchecks are provided for stable orchestration.
- Decluttarr expects to reach `sonarr`, `radarr`, and `lidarr` via internal DNS and qBittorrent at `http://<qbittorrent_host>:8080`. When qBittorrent is routed via Gluetun using `network_mode=container:gluetun`, set `qbittorrent_host = "gluetun"`. Ensure they share the same Docker network.
## Integration with Networking Modules
This service is configured to be exposed through the Caddy reverse proxy, set by `publish_via = "reverse_proxy"`.
## Example Integration in Main Configuration
```hcl
# In services/main.tf
module "arr" {
source = "${local.module_dir}/20-services-apps/arr"
volume_path = "${local.volume_host}/arr"
data_path = local.data_host
downloads_path = "${local.data_host}/torrents"
networks = [module.media_docker_network.name]
proxy_networks = [module.homelab_docker_network.name]
# If qBittorrent shares Gluetun's network namespace, arr should reach it via 'gluetun'
qbittorrent_host = "gluetun"
}
```
The service definition is exported by the `services` module as `module.services.service_definitions` and consumed by networking modules in the root `main.tf`.

View File

@@ -0,0 +1,280 @@
terraform {
required_providers {
dotenv = { source = "germanbrew/dotenv" }
}
}
variable "volume_path" {
description = "Base directory for config volumes for *arr stack"
type = string
}
variable "data_path" {
description = "Base directory for media/data mounted at /data"
type = string
}
variable "downloads_path" {
description = "Directory for downloads mounted at /data/torrents"
type = string
}
variable "networks" {
description = "Networks to attach all containers to"
type = list(string)
default = []
}
variable "proxy_networks" {
description = "Extra networks to attach only to published services (e.g., Jellyseerr)"
type = list(string)
default = []
}
variable "qbittorrent_host" {
description = "Hostname to reach qBittorrent (use 'qbittorrent' normally, 'gluetun' when qBittorrent shares Gluetun network)"
type = string
default = "qbittorrent"
}
locals {
env_file = "${path.module}/.env"
monitoring = true
sonarr_name = "sonarr"
radarr_name = "radarr"
lidarr_name = "lidarr"
bazarr_name = "bazarr"
prowlarr_name = "prowlarr"
jellyseerr_name = "jellyseerr"
flaresolverr_name = "flaresolverr"
unpackerr_name = "unpackerr"
cleanuparr_name = "cleanuparr"
decluttarr_name = "decluttarr"
sonarr_image = "lscr.io/linuxserver/sonarr"
radarr_image = "lscr.io/linuxserver/radarr"
lidarr_image = "lscr.io/linuxserver/lidarr"
bazarr_image = "lscr.io/linuxserver/bazarr"
prowlarr_image = "lscr.io/linuxserver/prowlarr"
jellyseerr_image = "ghcr.io/fallenbagel/jellyseerr"
flaresolverr_image = "21hsmw/flaresolverr"
unpackerr_image = "ghcr.io/unpackerr/unpackerr"
cleanuparr_image = "ghcr.io/cleanuparr/cleanuparr"
decluttarr_image = "ghcr.io/manimatter/decluttarr"
sonarr_port = 8989
radarr_port = 7878
lidarr_port = 8686
bazarr_port = 6767
prowlarr_port = 9696
jellyseerr_port = 5055
flaresolverr_port = 8191
lidarr_healthcheck = { test = ["CMD", "curl", "--fail", "http://127.0.0.1:${local.lidarr_port}/lidarr/ping"], interval = "60s", timeout = "5s", retries = 10 }
bazarr_healthcheck = { test = ["CMD", "curl", "--fail", "http://127.0.0.1:${local.bazarr_port}/bazarr/ping"], interval = "60s", timeout = "5s", retries = 10 }
jellyseerr_healthcheck = { test = ["CMD", "wget", "http://127.0.0.1:${local.jellyseerr_port}/api/v1/status", "-qO", "/dev/null"], interval = "60s", timeout = "5s", retries = 10 }
jellyseerr_env = { LOG_LEVEL = "debug" }
flaresolverr_env = {
LOG_LEVEL = try(provider::dotenv::get_by_key("LOG_LEVEL", local.env_file), "")
LOG_HTML = try(provider::dotenv::get_by_key("LOG_HTML", local.env_file), "")
CAPTCHA_SOLVER = try(provider::dotenv::get_by_key("CAPTCHA_SOLVER", local.env_file), "")
}
unpackerr_env = {
UN_SONARR_0_URL = "http://${local.sonarr_name}:${local.sonarr_port}/sonarr"
UN_SONARR_0_API_KEY = provider::dotenv::get_by_key("SONARR_API_KEY", local.env_file)
UN_RADARR_0_URL = "http://${local.radarr_name}:${local.radarr_port}/radarr"
UN_RADARR_0_API_KEY = provider::dotenv::get_by_key("RADARR_API_KEY", local.env_file)
}
cleanuparr_env = {
QUEUECLEANER__ENABLED = true
QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES = 3
QUEUECLEANER__STALLED_MAX_STRIKES = 3
QUEUECLEANER__DOWNLOADING_METADATA_MAX_STRIKES = 3
QUEUECLEANER__STALLED_RESET_STRIKES_ON_PROGRESS = true
TRIGGERS__QUEUECLEANER = "0 0 0/1 * * ?"
CONTENTBLOCKER__ENABLED = true
CONTENTBLOCKER__IGNORED_DOWNLOADS_PATH = "/usr/ignored.txt"
TRIGGERS__CONTENTBLOCKER = "0 0 0/1 * * ?"
DOWNLOAD_CLIENT = "qBittorrent"
QBITTORRENT__URL = "http://${var.qbittorrent_host}:8080"
QBITTORRENT__PASSWORD = provider::dotenv::get_by_key("QBITTORRENT_PASSWORD", local.env_file)
SONARR__ENABLED = true
SONARR__BLOCK__PATH = "/usr/blacklist.json"
SONARR__INSTANCES__0__URL = "http://${local.sonarr_name}:${local.sonarr_port}/sonarr"
SONARR__INSTANCES__0__APIKEY = provider::dotenv::get_by_key("SONARR_API_KEY", local.env_file)
RADARR__ENABLED = true
RADARR__BLOCK__PATH = "/usr/blacklist.json"
RADARR__INSTANCES__0__URL = "http://${local.radarr_name}:${local.radarr_port}/radarr"
RADARR__INSTANCES__0__APIKEY = provider::dotenv::get_by_key("RADARR_API_KEY", local.env_file)
}
decluttarr_env = {
RADARR_URL = "http://${local.radarr_name}:${local.radarr_port}/radarr"
RADARR_KEY = provider::dotenv::get_by_key("RADARR_API_KEY", local.env_file)
SONARR_URL = "http://${local.sonarr_name}:${local.sonarr_port}/sonarr"
SONARR_KEY = provider::dotenv::get_by_key("SONARR_API_KEY", local.env_file)
LIDARR_URL = "http://${local.lidarr_name}:${local.lidarr_port}/lidarr"
LIDARR_KEY = provider::dotenv::get_by_key("LIDARR_API_KEY", local.env_file)
QBITTORRENT_URL = "http://${var.qbittorrent_host}:8080"
QBITTORRENT_USERNAME = provider::dotenv::get_by_key("QBITTORRENT_USERNAME", local.env_file)
QBITTORRENT_PASSWORD = provider::dotenv::get_by_key("QBITTORRENT_PASSWORD", local.env_file)
LOG_LEVEL = try(provider::dotenv::get_by_key("DECLUTTARR_LOG_LEVEL", local.env_file), "")
TEST_RUN = try(provider::dotenv::get_by_key("DECLUTTARR_TEST_RUN", local.env_file), "")
REMOVE_TIMER = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_TIMER", local.env_file), "")
REMOVE_FAILED = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_FAILED", local.env_file), "")
REMOVE_FAILED_IMPORTS = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_FAILED_IMPORTS", local.env_file), "")
REMOVE_METADATA_MISSING = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_METADATA_MISSING", local.env_file), "")
REMOVE_MISSING_FILES = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_MISSING_FILES", local.env_file), "")
REMOVE_ORPHANS = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_ORPHANS", local.env_file), "")
REMOVE_SLOW = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_SLOW", local.env_file), "")
REMOVE_STALLED = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_STALLED", local.env_file), "")
REMOVE_UNMONITORED = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVE_UNMONITORED", local.env_file), "")
RUN_PERIODIC_RESCANS = try(provider::dotenv::get_by_key("DECLUTTARR_RUN_PERIODIC_RESCANS", local.env_file), "")
PERMITTED_ATTEMPTS = try(provider::dotenv::get_by_key("DECLUTTARR_PERMITTED_ATTEMPTS", local.env_file), "")
NO_STALLED_REMOVAL_QBIT_TAG = try(provider::dotenv::get_by_key("DECLUTTARR_REMOVAL_QBIT_TAG", local.env_file), "")
MIN_DOWNLOAD_SPEED = try(provider::dotenv::get_by_key("DECLUTTARR_MIN_DOWNLOAD_SPEED", local.env_file), "")
FAILED_IMPORT_MESSAGE_PATTERNS = try(provider::dotenv::get_by_key("DECLUTTARR_FAILED_IMPORT_MESSAGE_PATTERNS", local.env_file), "")
IGNORED_DOWNLOAD_CLIENTS = try(provider::dotenv::get_by_key("DECLUTTARR_IGNORED_DOWNLOAD_CLIENTS", local.env_file), "")
}
}
# Sonarr
module "sonarr" {
source = "../../10-services-generic/docker-service"
container_name = local.sonarr_name
image = local.sonarr_image
volumes = [
{ host_path = "${var.volume_path}/sonarr", container_path = "/config", read_only = false },
{ host_path = var.data_path, container_path = "/data", read_only = false }
]
networks = var.networks
monitoring = local.monitoring
ports = [{ internal = local.sonarr_port, external = local.sonarr_port, protocol = "tcp" }]
}
# Radarr
module "radarr" {
source = "../../10-services-generic/docker-service"
container_name = local.radarr_name
image = local.radarr_image
volumes = [
{ host_path = "${var.volume_path}/radarr", container_path = "/config", read_only = false },
{ host_path = var.data_path, container_path = "/data", read_only = false }
]
networks = var.networks
monitoring = local.monitoring
ports = [{ internal = local.radarr_port, external = local.radarr_port, protocol = "tcp" }]
}
# Lidarr
module "lidarr" {
source = "../../10-services-generic/docker-service"
container_name = local.lidarr_name
image = local.lidarr_image
volumes = [
{ host_path = "${var.volume_path}/lidarr", container_path = "/config", read_only = false },
{ host_path = var.data_path, container_path = "/data", read_only = false }
]
networks = var.networks
monitoring = local.monitoring
healthcheck = local.lidarr_healthcheck
ports = [{ internal = local.lidarr_port, external = local.lidarr_port, protocol = "tcp" }]
}
# Bazarr
module "bazarr" {
source = "../../10-services-generic/docker-service"
container_name = local.bazarr_name
image = local.bazarr_image
volumes = [
{ host_path = "${var.volume_path}/bazarr/config", container_path = "/config", read_only = false },
{ host_path = var.data_path, container_path = "/data", read_only = false }
]
networks = var.networks
monitoring = local.monitoring
healthcheck = local.bazarr_healthcheck
ports = [{ internal = local.bazarr_port, external = local.bazarr_port, protocol = "tcp" }]
}
# Prowlarr
module "prowlarr" {
source = "../../10-services-generic/docker-service"
container_name = local.prowlarr_name
image = local.prowlarr_image
volumes = [
{ host_path = "${var.volume_path}/prowlarr", container_path = "/config", read_only = false }
]
networks = var.networks
monitoring = local.monitoring
ports = [{ internal = local.prowlarr_port, external = local.prowlarr_port, protocol = "tcp" }]
}
# Jellyseerr (published via reverse proxy)
module "jellyseerr" {
source = "../../10-services-generic/docker-service"
container_name = local.jellyseerr_name
image = local.jellyseerr_image
volumes = [{ host_path = "${var.volume_path}/jellyseerr", container_path = "/app/config", read_only = false }]
env_vars = local.jellyseerr_env
networks = concat(var.networks, var.proxy_networks)
monitoring = local.monitoring
healthcheck = local.jellyseerr_healthcheck
ports = [{ internal = local.jellyseerr_port, external = local.jellyseerr_port, protocol = "tcp" }]
}
# Flaresolverr
module "flaresolverr" {
source = "../../10-services-generic/docker-service"
container_name = local.flaresolverr_name
image = local.flaresolverr_image
tag = "nodriver"
env_vars = local.flaresolverr_env
networks = var.networks
monitoring = local.monitoring
ports = [{ internal = local.flaresolverr_port, external = local.flaresolverr_port, protocol = "tcp" }]
}
# Unpackerr
module "unpackerr" {
source = "../../10-services-generic/docker-service"
container_name = local.unpackerr_name
image = local.unpackerr_image
env_vars = local.unpackerr_env
volumes = [{ host_path = var.downloads_path, container_path = "/data/torrents", read_only = false }]
networks = var.networks
monitoring = local.monitoring
}
# Cleanuparr
module "cleanuparr" {
source = "../../10-services-generic/docker-service"
container_name = local.cleanuparr_name
image = local.cleanuparr_image
env_vars = local.cleanuparr_env
volumes = [
{ host_path = "${var.volume_path}/cleanuparr/logs", container_path = "/var/logs", read_only = false },
{ host_path = "${var.volume_path}/cleanuparr/ignored.txt", container_path = "/usr/ignored.txt", read_only = false },
{ host_path = "${var.volume_path}/cleanuparr/blacklist.json", container_path = "/usr/blacklist.json", read_only = false }
]
networks = var.networks
monitoring = local.monitoring
}
module "decluttarr" {
source = "../../10-services-generic/docker-service"
container_name = local.decluttarr_name
image = local.decluttarr_image
env_vars = local.decluttarr_env
networks = var.networks
monitoring = local.monitoring
}
output "service_definition" {
description = "Service definition for Jellyseerr (reverse proxy)"
value = {
name = local.jellyseerr_name
primary_port = local.jellyseerr_port
endpoint = "http://${local.jellyseerr_name}:${local.jellyseerr_port}"
subdomains = ["req"]
publish_via = "reverse_proxy"
proxied = true
}
}

View File

@@ -0,0 +1,33 @@
# Gluetun VPN (.env example)
# Copy to modules/20-services-apps/gluetun/.env and fill in values
# Provider and VPN type
VPN_SERVICE_PROVIDER=mullvad
VPN_TYPE=wireguard
# Wireguard credentials (required)
# Generate from Mullvad account: private key and tunnel IP address
WIREGUARD_PRIVATE_KEY=
# Example: 10.64.0.2/32
WIREGUARD_ADDRESSES=
# Server selection (one of the following is recommended)
#SERVER_CITIES="Los Angeles"
#SERVER_COUNTRIES="United States"
# Exact server pinning (optional; supports comma-separated list)
# For Jakarta example:
#SERVER_HOSTNAMES=id-jpu-wg-001
#SERVER_HOSTNAME=id-jpu-wg-001
# Updater period (optional)
#UPDATER_PERIOD=24h
# Firewall rules (optional)
# Allow outbound traffic to RFC1918 subnets so LAN services can be reached
# Example: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
#FIREWALL_OUTBOUND_SUBNETS=
# If you need to accept inbound connections through Gluetun (e.g., expose qBittorrent UI),
# publish the port in Terraform and optionally set FIREWALL_INPUT_PORTS as well
#FIREWALL_INPUT_PORTS=8080

View File

@@ -0,0 +1,65 @@
# Gluetun (Mullvad Wireguard)
This module runs Gluetun to provide a VPN network stack for other containers.
You can route qBittorrent through Gluetun by setting its `network_mode` to `container:gluetun` using the provided toggle in the qBittorrent module.
- Image: `qmcgaw/gluetun:v3.39.0`
- Requires: NET_ADMIN capability and `/dev/net/tun` device
- Default: No ports exposed on host. Publish only if you need host access.
- Attach Gluetun to the same Docker network as services that should reach apps running through it (e.g., `media-network`).
## Usage
Example in `services/main.tf`:
```hcl
module "gluetun" {
source = "${local.module_dir}/20-services-apps/gluetun"
volume_path = "${local.volume_host}/gluetun"
networks = [module.media_docker_network.name]
# Optionally expose qBittorrent's Web UI to the host via Gluetun:
# ports = [{ internal = 8080, external = 8080, protocol = "tcp" }]
}
module "qbittorrent" {
source = "${local.module_dir}/20-services-apps/qbittorrent"
volume_path = "${local.volume_host}/qbittorrent"
downloads_path = "${local.data_host}/torrents"
networks = [module.media_docker_network.name]
connect_via_gluetun = true
gluetun_container_name = "gluetun"
}
module "arr" {
source = "${local.module_dir}/20-services-apps/arr"
volume_path = "${local.volume_host}/arr"
data_path = local.data_host
downloads_path = "${local.data_host}/torrents"
networks = [module.media_docker_network.name]
proxy_networks = [module.homelab_docker_network.name]
qbittorrent_host = "gluetun" # arr containers will reach qBt at http://gluetun:8080
}
```
## Environment variables
Place a `.env` file in this module directory (`modules/20-services-apps/gluetun/.env`). See `.env.example` for all options. Key variables:
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=... (required)
- WIREGUARD_ADDRESSES=10.64.0.2/32 (example)
- SERVER_CITIES=... or SERVER_COUNTRIES=...
- SERVER_HOSTNAMES=id-jpu-wg-001 (optional exact server pin; supports comma-separated list)
- UPDATER_PERIOD=24h (optional)
- FIREWALL_OUTBOUND_SUBNETS=10.0.0.0/8,192.168.0.0/16 (optional; allow containers to reach LAN subnets)
- Optional: `FIREWALL_INPUT_PORTS=8080` if you need other containers/LAN to initiate connections to services through Gluetun.
Notes:
- When qBittorrent shares Gluetun's network, other containers should use `http://gluetun:8080`.
- To access qBittorrent UI from the host, publish `8080/tcp` on Gluetun via this module's `ports` input or set `FIREWALL_INPUT_PORTS` accordingly.
- Do not publish ports on qBittorrent when using Gluetun network mode; publish on Gluetun instead.
Pinning a specific server:
- Set `SERVER_HOSTNAMES=id-jpu-wg-001` to pin to Mullvad Jakarta `id-jpu-wg-001`.
- The module also accepts `SERVER_HOSTNAME` for compatibility (falls back to it if `SERVER_HOSTNAMES` is not set).

View File

@@ -0,0 +1,88 @@
terraform {
required_providers {
dotenv = { source = "germanbrew/dotenv" }
}
}
variable "volume_path" {
description = "Base directory for Gluetun state/config mounted at /gluetun"
type = string
}
variable "networks" {
description = "Networks to attach Gluetun to"
type = list(string)
default = []
}
variable "ports" {
description = "Ports to publish on the Gluetun container (used to reach services connected via network_mode: container:gluetun)"
type = list(object({
internal = number
external = number
protocol = string
}))
// Default to no published ports. Publish only if you need host access.
default = []
}
variable "image_tag" {
description = "Gluetun image tag"
type = string
default = "v3.39.0"
}
locals {
env_file = "${path.module}/.env"
container_name = "gluetun"
image = "qmcgaw/gluetun"
tag = var.image_tag
monitoring = true
// Gluetun environment
env_vars = {
VPN_SERVICE_PROVIDER = try(provider::dotenv::get_by_key("VPN_SERVICE_PROVIDER", local.env_file), "mullvad")
VPN_TYPE = try(provider::dotenv::get_by_key("VPN_TYPE", local.env_file), "wireguard")
WIREGUARD_PRIVATE_KEY = provider::dotenv::get_by_key("WIREGUARD_PRIVATE_KEY", local.env_file)
WIREGUARD_ADDRESSES = provider::dotenv::get_by_key("WIREGUARD_ADDRESSES", local.env_file)
SERVER_CITIES = try(provider::dotenv::get_by_key("SERVER_CITIES", local.env_file), "")
SERVER_COUNTRIES = try(provider::dotenv::get_by_key("SERVER_COUNTRIES", local.env_file), "")
SERVER_HOSTNAMES = try(
provider::dotenv::get_by_key("SERVER_HOSTNAMES", local.env_file),
try(provider::dotenv::get_by_key("SERVER_HOSTNAME", local.env_file), "")
)
UPDATER_PERIOD = try(provider::dotenv::get_by_key("UPDATER_PERIOD", local.env_file), "")
FIREWALL_OUTBOUND_SUBNETS = try(provider::dotenv::get_by_key("FIREWALL_OUTBOUND_SUBNETS", local.env_file), "")
}
volumes = [
{
host_path = var.volume_path,
container_path = "/gluetun",
read_only = false
}
]
}
module "gluetun" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.tag
env_vars = local.env_vars
volumes = local.volumes
networks = var.networks
monitoring = local.monitoring
// Grant minimal privileges required by Gluetun
capabilities_add = ["NET_ADMIN"]
devices = [
{
host_path = "/dev/net/tun"
container_path = "/dev/net/tun"
permissions = "rwm"
}
]
ports = var.ports
}

View File

@@ -0,0 +1,4 @@
# Optional values for Jellyfin module
# Only needed if you enable JELLYFIN_PublishedServerUrl in main.tf
HOSTNAME=example.com

View File

@@ -0,0 +1,87 @@
# Jellyfin Module
This module deploys Jellyfin as a Docker container and outputs a service definition to be published via your reverse proxy.
## Overview
- Container: `jellyfin` (LinuxServer.io)
- TCP 8096 for HTTP UI; UDP 7359/1900 for discovery/DLNA
- Config and media volumes mapped via variables
## Usage
```hcl
module "jellyfin" {
source = "./modules/20-services-apps/jellyfin"
volume_path = "/srv/appdata/jellyfin" # host path for Jellyfin config
data_path = "/srv/data" # host media root, mounted as /data
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
}
```
## Variables
| Variable | Description | Type | Default |
| ------------- | --------------------------------------------- | -------------- | ------- |
| `volume_path` | Base directory for Jellyfin config | `string` | - |
| `data_path` | Base directory for media data mounted at /data | `string` | - |
| `networks` | List of networks to attach | `list(string)` | `[]` |
## Outputs
| Output | Description |
| -------------------- | -------------------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules to expose the service.
```hcl
{
name = "jellyfin"
primary_port = 8096
endpoint = "http://jellyfin:8096"
subdomains = ["stream"]
publish_via = "reverse_proxy"
proxied = false
}
```
## Environment Variables (.env)
This module optionally reads `HOSTNAME` from `.env` if you choose to publish a fixed external URL (see commented example in `main.tf`).
- `HOSTNAME` — your public domain (e.g., example.com). Used only if you enable `JELLYFIN_PublishedServerUrl`.
Note: `TZ`, `PUID`, and `PGID` are provided automatically by the generic docker-service module via system globals.
## Data Persistence
- `/config` -> `${volume_path}`
- `/data` -> `${data_path}`
Ensure the host paths exist and are writable by the container user.
## Dependencies
- No explicit inter-container dependencies. Healthcheck ensures readiness.
- UDP ports are exposed for discovery/DLNA.
## Integration with Networking Modules
This service is configured to be exposed through the Caddy reverse proxy, set by `publish_via = "reverse_proxy"`.
## Example Integration in Main Configuration
```hcl
# In services/main.tf
module "jellyfin" {
source = "${local.module_dir}/20-services-apps/jellyfin"
volume_path = "${local.volume_host}/jellyfin"
data_path = "${local.data_host}/media"
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
}
```
The service definition is exported by the `services` module as `module.services.service_definitions` and consumed by networking modules in the root `main.tf`.

View File

@@ -0,0 +1,96 @@
terraform {
required_providers {
dotenv = { source = "germanbrew/dotenv" }
}
}
variable "volume_path" {
description = "Base directory for Jellyfin config"
type = string
}
variable "data_path" {
description = "Base directory for media data mounted at /data"
type = string
}
variable "networks" {
description = "List of networks to attach"
type = list(string)
default = []
}
locals {
env_file = "${path.module}/.env"
monitoring = true
container_name = "jellyfin"
image = "lscr.io/linuxserver/jellyfin"
tag = "latest"
internal_port = 8096
# UDP ports for DLNA/auto-discovery
udp_ports = [
{ internal = 7359, external = 7359, protocol = "udp" },
{ internal = 1900, external = 1900, protocol = "udp" }
]
volumes = [
{
host_path = var.volume_path,
container_path = "/config",
read_only = false
},
{
host_path = var.volume_path,
container_path = "/cache",
read_only = false
},
{
host_path = var.data_path,
container_path = "/data",
read_only = false
}
]
env_vars = {
# If you want to publish external URL, uncomment the following and set HOSTNAME in .env
JELLYFIN_PublishedServerUrl = "${provider::dotenv::get_by_key("HOSTNAME", local.env_file)}/jellyfin"
}
# Intel VAAPI/QSV: map the entire /dev/dri directory (per linuxserver/jellyfin docs)
devices = [
{
host_path = "/dev/dri/renderD128",
container_path = "/dev/dri/renderD128",
permissions = "rwm"
},
{
host_path = "/dev/dri/card0",
container_path = "/dev/dri/card0",
permissions = "rwm"
}
]
}
module "jellyfin" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.tag
volumes = local.volumes
env_vars = local.env_vars
networks = var.networks
monitoring = local.monitoring
ports = local.udp_ports
devices = local.devices
}
output "service_definition" {
description = "Service definition for Jellyfin (reverse proxy)"
value = {
name = local.container_name
primary_port = local.internal_port
endpoint = "http://${local.container_name}:${local.internal_port}"
subdomains = ["stream"]
publish_via = "reverse_proxy"
proxied = true
}
}

View File

@@ -0,0 +1,117 @@
# qBittorrent Module
This module deploys qBittorrent as a Docker container with the Vuetorrent UI mod.
## Overview
- Container: `qbittorrent` (LinuxServer.io) with `vuetorrent` mod
- Web UI on TCP 8080
- Mounts `/config` and `/data/torrents`
## Usage
Without Gluetun:
```hcl
module "qbittorrent" {
source = "./modules/20-services-apps/qbittorrent"
volume_path = "/srv/appdata/qbittorrent"
downloads_path = "/srv/data/torrents"
networks = [module.media_docker_network.name]
}
```
With Gluetun (recommended for privacy):
```hcl
module "gluetun" {
source = "./modules/20-services-apps/gluetun"
volume_path = "/srv/appdata/gluetun"
networks = [module.media_docker_network.name]
# Optional: expose qBittorrent UI to the host via Gluetun
# ports = [{ internal = 8080, external = 8080, protocol = "tcp" }]
}
module "qbittorrent" {
source = "./modules/20-services-apps/qbittorrent"
volume_path = "/srv/appdata/qbittorrent"
downloads_path = "/srv/data/torrents"
networks = [module.media_docker_network.name]
connect_via_gluetun = true
gluetun_container_name = "gluetun"
}
```
## Variables
| Variable | Description | Type | Default |
| ----------------------- | --------------------------------------------------------------- | -------------- | ----------- |
| `volume_path` | Base directory for qBittorrent config | `string` | - |
| `downloads_path` | Directory for downloads mounted at /data/torrents | `string` | - |
| `networks` | Networks to attach (ignored when `connect_via_gluetun` is true) | `list(string)` | `[]` |
| `connect_via_gluetun` | Route qBittorrent through Gluetun (network_mode=container:gluetun) | `bool` | `false` |
| `gluetun_container_name`| Container name of the Gluetun instance | `string` | `"gluetun"` |
## Outputs
| Output | Description |
| -------------------- | ----------------------------------- |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules. qBittorrent is not published externally.
```hcl
{
name = "qbittorrent"
primary_port = 8080
endpoint = "http://qbittorrent:8080"
}
```
## Environment Variables
- Defaults:
- `WEBUI_PORT=8080`
- `DOCKER_MODS=ghcr.io/gabe565/linuxserver-mod-vuetorrent`
- `TZ`, `PUID`, and `PGID` are injected by the generic docker-service module from system globals.
## Data Persistence
- `/config` -> `${volume_path}`
- `/data/torrents` -> `${downloads_path}`
- Ensure host paths exist and permissions align with the container user.
## Networking
- When `connect_via_gluetun = false`:
- Container attaches to `networks` and exposes its Web UI internally at `http://qbittorrent:8080`.
- When `connect_via_gluetun = true`:
- Container runs with `network_mode = container:<gluetun_container_name>`.
- Do not publish ports on qBittorrent. If you need host access, publish `8080/tcp` on the Gluetun module instead.
- Other containers should reach the Web UI at `http://gluetun:8080` when on the same Docker network as Gluetun.
## Dependencies
- No explicit inter-container dependencies. Healthcheck ensures readiness.
## Integration with Networking Modules
This service is not published externally. Its service definition is included in the aggregated `module.services.service_definitions` for internal discovery and potential future use by networking modules.
## Example Integration in Main Configuration
```hcl
# In services/main.tf
module "qbittorrent" {
source = "${local.module_dir}/20-services-apps/qbittorrent"
volume_path = "${local.volume_host}/qbittorrent"
downloads_path = "${local.data_host}/torrents"
networks = [module.media_docker_network.name]
connect_via_gluetun = true
gluetun_container_name = "gluetun"
}
```
The service definition is exported by the `services` module as `module.services.service_definitions` and consumed by networking modules in the root `main.tf`.

View File

@@ -0,0 +1,86 @@
variable "volume_path" {
description = "Base directory for qBittorrent config"
type = string
}
variable "downloads_path" {
description = "Directory for downloads mounted at /data/torrents"
type = string
}
variable "networks" {
description = "List of networks to attach"
type = list(string)
default = []
}
// When true, run qBittorrent through Gluetun by sharing its network namespace
variable "connect_via_gluetun" {
description = "Route qBittorrent through Gluetun (network_mode=container:gluetun)"
type = bool
default = false
}
variable "gluetun_container_name" {
description = "Container name of the Gluetun instance to share network with"
type = string
default = "gluetun"
}
locals {
container_name = "qbittorrent"
image = "lscr.io/linuxserver/qbittorrent"
tag = "libtorrentv1"
monitoring = true
internal_port = 8080
use_gluetun = var.connect_via_gluetun
gluetun_name = var.gluetun_container_name
network_mode = local.use_gluetun ? "container:${local.gluetun_name}" : "bridge"
env_vars = {
WEBUI_PORT = "8080"
DOCKER_MODS = "ghcr.io/gabe565/linuxserver-mod-vuetorrent"
}
volumes = [
{
host_path = var.volume_path,
container_path = "/config",
read_only = false
},
{
host_path = var.downloads_path,
container_path = "/data/torrents",
read_only = false
}
]
healthcheck = {
test = ["CMD", "curl", "--fail", "http://127.0.0.1:8080", "https://google.com"]
interval = "60s"
timeout = "5s"
retries = 10
}
}
module "qbittorrent" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.tag
env_vars = local.env_vars
volumes = local.volumes
network_mode = local.network_mode
networks = local.use_gluetun ? [] : var.networks
monitoring = local.monitoring
healthcheck = local.healthcheck
ports = local.use_gluetun ? [] : [{ internal = local.internal_port, external = local.internal_port, protocol = "tcp" }]
}
output "service_definition" {
description = "Service definition for qBittorrent (not published)"
value = {
name = local.container_name
primary_port = local.internal_port
endpoint = "http://${local.container_name}:${local.internal_port}"
}
}

View File

@@ -0,0 +1,78 @@
# Sabnzbd Module
This module deploys Sabnzbd as a Docker container and outputs a non-published service definition.
## Overview
- Container: `sabnzbd` (LinuxServer.io)
- Web UI on TCP 8080
- Mounts `/config` and `/downloads`
## Usage
```hcl
module "sabnzbd" {
source = "./modules/20-services-apps/sabnzbd"
volume_path = "/srv/appdata/sabnzbd" # host path for app config
downloads_path = "/srv/data/usenet" # host path for usenet downloads
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
}
```
## Variables
| Variable | Description | Type | Default |
| ---------------- | ------------------------------------------- | -------------- | ------- |
| `volume_path` | Base directory for Sabnzbd config | `string` | - |
| `downloads_path` | Directory for downloads mounted at /downloads | `string` | - |
| `networks` | List of networks to attach | `list(string)` | `[]` |
## Outputs
| Output | Description |
| -------------------- | ------------------------------ |
| `service_definition` | Service definition for integration with networking modules |
## Service Definition
This module outputs a service definition that is used by the networking modules. Sabnzbd is not published externally.
```hcl
{
name = "sabnzbd"
primary_port = 8080
endpoint = "http://sabnzbd:8080"
}
```
## Environment Variables
- `TZ`, `PUID`, and `PGID` are injected automatically via system globals in the generic docker-service module.
## Data Persistence
- `/config` -> `${volume_path}`
- `/downloads` -> `${downloads_path}`
- Ensure host paths exist and permissions align with the container user.
## Networking
- Attaches to `networks` (typically media and homelab). Not published externally; accessible internally.
## Dependencies
- No explicit inter-container dependencies. Healthcheck ensures readiness.
## Example Integration in Main Configuration
```hcl
# In services/main.tf
module "sabnzbd" {
source = "${local.module_dir}/20-services-apps/sabnzbd"
volume_path = "${local.volume_host}/sabnzbd"
downloads_path = "${local.data_host}/usenet"
networks = [module.media_docker_network.name, module.homelab_docker_network.name]
}
```
The service definition is exported by the `services` module as `module.services.service_definitions` and consumed by networking modules in the root `main.tf`.

View File

@@ -0,0 +1,69 @@
variable "volume_path" {
description = "Base directory for Sabnzbd config"
type = string
}
variable "downloads_path" {
description = "Directory for downloads mounted at /downloads"
type = string
}
variable "networks" {
description = "List of networks to attach"
type = list(string)
default = []
}
locals {
container_name = "sabnzbd"
image = "lscr.io/linuxserver/sabnzbd"
tag = "latest"
monitoring = true
internal_port = 8080
env_vars = {
# Add typical env like PUID/PGID/TZ if desired via the generic module interface
}
volumes = [
{
host_path = var.volume_path,
container_path = "/config",
read_only = false
},
{
host_path = var.downloads_path,
container_path = "/data/usenet/downloads",
read_only = false
}
]
healthcheck = {
test = ["CMD", "curl", "--fail", "http://127.0.0.1:8080"]
interval = "60s"
timeout = "5s"
retries = 10
}
}
module "sabnzbd" {
source = "../../10-services-generic/docker-service"
container_name = local.container_name
image = local.image
tag = local.tag
env_vars = local.env_vars
volumes = local.volumes
networks = var.networks
monitoring = local.monitoring
healthcheck = local.healthcheck
}
output "service_definition" {
description = "Service definition for Sabnzbd (not published)"
value = {
name = local.container_name
primary_port = local.internal_port
endpoint = "http://${local.container_name}:${local.internal_port}"
subdomains = ["sabnzbd"]
publish_via = "tunnel"
proxied = true
}
}