stack/docker-compose.yml
tim 0ddf1d3699 Add RustDesk server and Caddy HTTPS reverse proxy
Proxy forgejo/jellyfin/rustdesk info under devopshomelab.com with
Let's Encrypt, publish RustDesk hbbs/hbbr ports, and wire Forgejo
ROOT_URL to https://forgejo.devopshomelab.com.
2026-07-22 20:00:05 -07:00

470 lines
17 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: arr-stack
################################################################################
# Arr stack
# gluetun WireGuard VPN client + firewall killswitch
# qbittorrent torrent client, lives INSIDE gluetun's network namespace
# prowlarr indexer manager (torrents + NZB/Usenet indexers)
# sabnzbd Usenet NZB download client (Newshosting, Tweaknews, …)
# sonarr TV radarr movies bazarr subtitles
# jellyfin media server
# landing Caddy — dark LAN homepage (port 80)
#
# AI / coding stack (local, no VPN)
# bonsai Prism Ternary-Bonsai-27B via llama-server (OpenAI-compatible API)
# open-webui Chat UI + RAG + tools + code interpreter
# searxng Private metasearch for web RAG
# forgejo Self-hosted Git (repos for coding + OpenHands)
# openhands Optional autonomous coding agent (profile: coding)
#
# Lockdown model:
# * qbittorrent uses `network_mode: service:gluetun` -> it has NO network
# interface of its own. Every packet it sends/receives goes through
# gluetun, and gluetun's built-in firewall only lets traffic out via the
# WireGuard tunnel. Tunnel down = zero connectivity. No leaks possible.
# * Do NOT publish qBittorrent's BitTorrent listening port on the host -
# inbound peer connections must come through the tunnel (port forwarding),
# never via your real IP.
# * AI services stay on arr-net (LAN only). They do NOT route through gluetun.
################################################################################
networks:
arr-net:
driver: bridge
services:
# ------------------------------------------------------------- VPN gateway #
gluetun:
image: qmcgaw/gluetun:v3
container_name: gluetun
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
networks:
- arr-net
ports:
# Ports for anything running inside gluetun's namespace are published
# HERE, on gluetun - not on the inner container.
- 8080:8080/tcp # qBittorrent WebUI
volumes:
# Your provider's WireGuard config, renamed to wg0.conf
# (see wireguard/wg0.conf.example and the README for required edits)
- ./wireguard/wg0.conf:/gluetun/wireguard/wg0.conf:ro
environment:
VPN_SERVICE_PROVIDER: custom
VPN_TYPE: wireguard
TZ: ${TZ}
# Lets your LAN reach the qBittorrent WebUI through gluetun's firewall
FIREWALL_OUTBOUND_SUBNETS: ${LAN_SUBNET}
# --- optional: provider port forwarding (better swarm connectivity) ---
# FIREWALL_VPN_INPUT_PORTS: "12345" # port your provider assigned you
# VPN_PORT_FORWARDING: "on" # for providers with NAT-PMP API
# VPN_PORT_FORWARDING_PROVIDER: protonvpn
# --------------------------------------------------------- torrent client #
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
network_mode: service:gluetun # <-- the entire isolation trick
depends_on:
gluetun:
condition: service_healthy # start only once the tunnel is up
environment:
PUID: ${PUID}
PGID: ${PGID}
TZ: ${TZ}
WEBUI_PORT: "8080"
volumes:
- ${CONFIG_DIR}/qbittorrent:/config
- ${DATA_DIR}:/data # single shared tree -> hardlinks
# -------------------------------------------------------------- indexers #
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
restart: unless-stopped
networks: [arr-net]
ports:
- 9696:9696
environment: &arr-env
PUID: ${PUID}
PGID: ${PGID}
TZ: ${TZ}
volumes:
- ${CONFIG_DIR}/prowlarr:/config
depends_on:
- flaresolverr
# Cloudflare / JS challenge proxy for Prowlarr indexers (not a general captcha solver)
# Wire in Prowlarr: Settings → Indexers → FlareSolverr → http://flaresolverr:8191
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
container_name: flaresolverr
restart: unless-stopped
networks: [arr-net]
environment:
LOG_LEVEL: ${FLARESOLVERR_LOG_LEVEL:-info}
LOG_HTML: "false"
CAPTCHA_SOLVER: ${FLARESOLVERR_CAPTCHA_SOLVER:-none}
TZ: ${TZ}
# Optional LAN debug UI/API — leave commented to keep it internal-only
# ports:
# - "8191:8191"
# ---------------------------------------------------------- usenet / NZB #
# SABnzbd talks to Usenet providers over SSL (no VPN required).
# Incomplete + complete under /data/usenet so Sonarr/Radarr can hardlink.
sabnzbd:
image: lscr.io/linuxserver/sabnzbd:latest
container_name: sabnzbd
restart: unless-stopped
networks: [arr-net]
ports:
- "${SABNZBD_PORT:-8085}:8080"
environment: *arr-env
volumes:
- ${CONFIG_DIR}/sabnzbd:/config
- ${DATA_DIR}:/data
# -------------------------------------------------------------------- TV #
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
restart: unless-stopped
networks: [arr-net]
ports:
- 8989:8989
environment: *arr-env
volumes:
- ${CONFIG_DIR}/sonarr:/config
- ${DATA_DIR}:/data
# ----------------------------------------------------------------- movies #
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: radarr
restart: unless-stopped
networks: [arr-net]
ports:
- 7878:7878
environment: *arr-env
volumes:
- ${CONFIG_DIR}/radarr:/config
- ${DATA_DIR}:/data
# -------------------------------------------------------------- subtitles #
bazarr:
image: lscr.io/linuxserver/bazarr:latest
container_name: bazarr
restart: unless-stopped
networks: [arr-net]
ports:
- 6767:6767
environment: *arr-env
volumes:
- ${CONFIG_DIR}/bazarr:/config
- ${DATA_DIR}:/data
# ----------------------------------------------------------- media server #
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
networks: [arr-net]
ports:
- 8096:8096/tcp # web UI / clients
# - 8920:8920/tcp # optional HTTPS
# - 7359:7359/udp # optional client autodiscovery
environment: *arr-env
volumes:
- ${CONFIG_DIR}/jellyfin:/config
- ${DATA_DIR}/media:/data/media:ro # read-only: Jellyfin never touches files
# --- optional: Intel QuickSync hardware transcoding --- (Unused on this AMD machine)
# devices:
# - /dev/dri:/dev/dri
# -------------------------------- Caddy: landing + HTTPS reverse proxy --- #
# LAN map: http://192.168.8.123/
# Public: https://forgejo.devopshomelab.com https://jellyfin.devopshomelab.com
# https://rustdesk.devopshomelab.com (info page) https://devopshomelab.com
# Let's Encrypt HTTP-01 needs WAN :80/:443 → this host and grey-cloud DNS.
landing:
image: caddy:2-alpine
container_name: landing
restart: unless-stopped
networks: [arr-net]
ports:
- "${LANDING_PORT:-80}:80"
- "${LANDING_HTTPS_PORT:-443}:443"
environment:
CADDY_EMAIL: ${CADDY_EMAIL:-admin@devopshomelab.com}
volumes:
- ./landing/Caddyfile:/etc/caddy/Caddyfile:ro
- ./landing/site:/srv:ro
- ${CONFIG_DIR}/caddy-data:/data
- ${CONFIG_DIR}/caddy-config:/config
depends_on:
- forgejo
- jellyfin
# ---------------------------------------------- Cloudflare dynamic DNS (A) #
# Updates apex + subdomains to the host's public IPv4. Secrets from secrets/.env
# Render env first: ./scripts/render-cloudflare-ddns-env.sh
cloudflare-ddns:
image: timothyjmiller/cloudflare-ddns:latest
container_name: cloudflare-ddns
restart: unless-stopped
# Host networking so we see the real WAN IPv4 (not a Docker NAT address)
network_mode: host
security_opt:
- no-new-privileges:true
# Generated by: ./scripts/render-cloudflare-ddns-env.sh (from secrets/.env)
# Path is relative to the compose project dir (/opt/stack).
env_file:
- ./config/cloudflare-ddns/ddns.env
# ------------------------------------------------------------------- music #
lidarr:
image: lscr.io/linuxserver/lidarr:latest
container_name: lidarr
restart: unless-stopped
networks: [arr-net]
ports:
- 8686:8686
environment: *arr-env
volumes:
- ${CONFIG_DIR}/lidarr:/config
- ${DATA_DIR}:/data # same /data tree as Sonarr/Radarr
# ==========================================================================
# Optional media extras - uncomment as needed
# ==========================================================================
#
# jellyseerr: # request UI for Jellyfin
# image: fallenbagel/jellyseerr:latest
# container_name: jellyseerr
# restart: unless-stopped
# networks: [arr-net]
# ports: ["5055:5055"]
# environment: *arr-env
# volumes:
# - ${CONFIG_DIR}/jellyseerr:/app/config
# ==========================================================================
# Local AI — Ternary-Bonsai-27B + Open WebUI + RAG + coding helpers
# Target: Beelink SER5 PRO (Ryzen 7 7735HS + Radeon 680M, no NVIDIA)
# ==========================================================================
# OpenAI-compatible llama-server (Prism binary — Bonsai Q2_0 + standard GGUF)
# Profiles (./scripts/apply-profile.sh mini|mid|gaming) set model + CPU/Vulkan.
# mini=CPU · mid/gaming=Vulkan · download: ./scripts/download-models.sh
bonsai:
build:
context: ./ai
dockerfile: Dockerfile
args:
# cpu | vulkan — from STACK_PROFILE via .env (mini defaults cpu)
BONSAI_BACKEND: ${BONSAI_BACKEND:-cpu}
image: arr-stack/bonsai:local
container_name: bonsai
restart: unless-stopped
networks: [arr-net]
ports:
- "${BONSAI_PORT:-8081}:8080" # avoid clash with qBittorrent :8080
environment:
TZ: ${TZ}
MODEL_PATH: ${BONSAI_MODEL_PATH:-/models/Ternary-Bonsai-27B-Q2_0.gguf}
NGL: ${BONSAI_NGL:-0}
CTX_SIZE: ${BONSAI_CTX:-16384}
TEMP: ${BONSAI_TEMP:-0.7}
TOP_P: ${BONSAI_TOP_P:-0.95}
TOP_K: ${BONSAI_TOP_K:-20}
THREADS: ${BONSAI_THREADS:-0}
KV4: ${BONSAI_KV4:-0}
EXTRA_ARGS: ${BONSAI_EXTRA_ARGS:-}
volumes:
- ${BONSAI_MODELS_DIR:-./models/bonsai}:/models:ro
shm_size: "4gb"
# Vulkan devices: docker-compose.override.yml (and mid/gaming profiles)
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 10s
start_period: 180s
retries: 5
# ChatGPT-style UI with built-in RAG, tools, and code interpreter
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
networks: [arr-net]
ports:
- "${OPEN_WEBUI_PORT:-3000}:8080"
depends_on:
bonsai:
condition: service_healthy
searxng:
condition: service_started
environment:
TZ: ${TZ}
# Point at local llama-server (OpenAI-compatible)
OPENAI_API_BASE_URL: http://bonsai:8080/v1
OPENAI_API_KEY: ${OPENAI_API_KEY:-sk-local-bonsai}
# Disable Ollama unless you add it later
ENABLE_OLLAMA_API: "false"
# First account becomes admin; set WEBUI_AUTH=false only on trusted LAN
WEBUI_AUTH: ${WEBUI_AUTH:-true}
# RAG: local embeddings run inside open-webui (downloads on first use)
RAG_EMBEDDING_ENGINE: ${RAG_EMBEDDING_ENGINE:-}
CHUNK_SIZE: ${RAG_CHUNK_SIZE:-1000}
CHUNK_OVERLAP: ${RAG_CHUNK_OVERLAP:-100}
# Web search via local SearXNG
ENABLE_RAG_WEB_SEARCH: "true"
ENABLE_SEARCH_QUERY: "true"
RAG_WEB_SEARCH_ENGINE: searxng
SEARXNG_QUERY_URL: http://searxng:8080/search?q=<query>
# Allow fetching internal / LAN URLs for RAG (homelab docs)
ENABLE_RAG_LOCAL_WEB_FETCH: "true"
# Code interpreter (runs in open-webui sandbox)
ENABLE_CODE_INTERPRETER: "true"
# Reduce background LLM spam on a single local model
ENABLE_TITLE_GENERATION: "false"
ENABLE_FOLLOW_UP_GENERATION: "false"
ENABLE_TAGS_GENERATION: "false"
# Default model id as reported by llama-server /v1/models
DEFAULT_MODELS: ${OPENWEBUI_DEFAULT_MODEL:-}
volumes:
- ${CONFIG_DIR}/open-webui:/app/backend/data
# Your local files for RAG / "read my project" workflows
- ${AI_WORKSPACE_DIR:-./workspace}:/workspace:ro
# Optional: expose media library read-only for Q&A about your library tree
# - ${DATA_DIR}/media:/media:ro
extra_hosts:
- "host.docker.internal:host-gateway"
# Private metasearch (no API keys) for Open WebUI web RAG
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
restart: unless-stopped
networks: [arr-net]
# Bind to localhost only — Open WebUI reaches it on arr-net
ports:
- "127.0.0.1:${SEARXNG_PORT:-8888}:8080"
environment:
SEARXNG_BASE_URL: http://localhost:${SEARXNG_PORT:-8888}/
SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-to-a-long-random-string}
volumes:
- ./searxng:/etc/searxng:rw
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
# -------------------------------------------------------------------------
# Forgejo — self-hosted Git (Gitea soft-fork). SQLite is fine for a homelab.
# Web: http://host:3002 Git SSH: host:2222 (git@host:user/repo.git with port 2222)
# First visit: create admin account, then create repos / push code.
# -------------------------------------------------------------------------
forgejo:
image: codeberg.org/forgejo/forgejo:11
container_name: forgejo
restart: unless-stopped
networks: [arr-net]
ports:
- "${FORGEJO_HTTP_PORT:-3002}:3000"
- "${FORGEJO_SSH_PORT:-2222}:22"
environment:
USER_UID: ${PUID:-1000}
USER_GID: ${PGID:-1000}
TZ: ${TZ}
# Image already runs OpenSSH on :22 (mapped to host FORGEJO_SSH_PORT).
# Do NOT enable Forgejo's built-in SSH too — both bind :22 and the
# process fatals with "address already in use" (connection refused on :3002).
FORGEJO__server__START_SSH_SERVER: "false"
FORGEJO__server__DISABLE_SSH: "false"
FORGEJO__server__SSH_PORT: ${FORGEJO_SSH_PORT:-2222}
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN:-forgejo.devopshomelab.com}
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN:-forgejo.devopshomelab.com}
# Public HTTPS via Caddy (see landing/Caddyfile). LAN still works on :3002.
FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL:-https://forgejo.devopshomelab.com/}
FORGEJO__server__HTTP_PORT: "3000"
# SQLite by default (no extra DB container)
FORGEJO__database__DB_TYPE: sqlite3
FORGEJO__service__DISABLE_REGISTRATION: ${FORGEJO_DISABLE_REGISTRATION:-false}
FORGEJO__actions__ENABLED: "true"
volumes:
- ${CONFIG_DIR}/forgejo:/data
- /etc/localtime:/etc/localtime:ro
# -------------------------------------------------------------------------
# RustDesk server (OSS) — rustdesk.devopshomelab.com
# Clients use TCP/UDP 2111521119 (not HTTP). Open those ports on the router.
# Public key: docker exec rustdesk-hbbs cat /root/id_ed25519.pub
# -------------------------------------------------------------------------
rustdesk-hbbr:
image: rustdesk/rustdesk-server:latest
container_name: rustdesk-hbbr
restart: unless-stopped
networks: [arr-net]
command: hbbr
volumes:
- ${CONFIG_DIR}/rustdesk:/root
ports:
- "21117:21117"
- "21119:21119"
rustdesk-hbbs:
image: rustdesk/rustdesk-server:latest
container_name: rustdesk-hbbs
restart: unless-stopped
networks: [arr-net]
# -r = relay host:port advertised to clients
command: hbbs -r ${RUSTDESK_DOMAIN:-rustdesk.devopshomelab.com}:21117
volumes:
- ${CONFIG_DIR}/rustdesk:/root
ports:
- "21115:21115"
- "21116:21116"
- "21116:21116/udp"
- "21118:21118"
depends_on:
- rustdesk-hbbr
# -------------------------------------------------------------------------
# OpenHands — always deployed (this stack always brings up every service)
# UI: http://host:3001 · LLM via bonsai OpenAI API
# -------------------------------------------------------------------------
openhands:
# Prefer GHCR — docker.all-hands.dev often fails DNS / is decommissioned
image: ghcr.io/all-hands-ai/openhands:0.54
container_name: openhands
restart: unless-stopped
networks: [arr-net]
ports:
- "${OPENHANDS_PORT:-3001}:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
TZ: ${TZ}
SANDBOX_RUNTIME_CONTAINER_IMAGE: ghcr.io/all-hands-ai/runtime:0.54-nikolaik
LOG_ALL_EVENTS: "true"
LLM_BASE_URL: http://bonsai:8080/v1
LLM_API_KEY: ${OPENAI_API_KEY:-sk-local-bonsai}
LLM_MODEL: ${OPENHANDS_MODEL:-openai/Ternary-Bonsai-27B-Q2_0.gguf}
WORKSPACE_MOUNT_PATH: ${AI_WORKSPACE_DIR:-./workspace}
SANDBOX_VOLUMES: ${AI_WORKSPACE_DIR:-./workspace}:/workspace:rw
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CONFIG_DIR}/openhands:/.openhands-state
- ${AI_WORKSPACE_DIR:-./workspace}:/opt/workspace_base
depends_on:
bonsai:
condition: service_healthy