From 66422a928314d543d4d9d8db0bcdda34d0fc0df2 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 15 May 2026 06:55:39 -0400 Subject: [PATCH] Add Docker service sections, master compose, document modular architecture --- infrastructure.org | 1145 +++++++++++++++++++++++--------------------- tangle-deploy.sh | 55 --- 2 files changed, 601 insertions(+), 599 deletions(-) delete mode 100644 tangle-deploy.sh diff --git a/infrastructure.org b/infrastructure.org index 69b2a0a..1dfba6a 100644 --- a/infrastructure.org +++ b/infrastructure.org @@ -41,6 +41,51 @@ Service-to-service / automation / cross-VLAN Key distinction: =:443= = browsers/humans with Authentik auth. =:8083= = runners, automated tooling, services on other VLANs. +** Tangle & Deploy Pipeline + +Changes are made to this org file, tangled into config files by the +=tangle-deploy= script on production-1, then deployed via =docker compose=. + +#+BEGIN_SRC bash :tangle /docker/compose/infrastructure/tangle-deploy.sh +#!/usr/bin/env bash +# tangle-deploy — Tangle infrastructure.org and restart affected services +GITEA_URL='http://amr:tangle-deploy-2026@10.10.10.201:3001/amr/infrastructure.git' +REPO_DIR="${1:-/docker/compose/infrastructure}" +ORG_FILE="${REPO_DIR}/infrastructure.org" +if [ -z "${1:-}" ]; then + if [ ! -d "$REPO_DIR" ]; then + git clone "$GITEA_URL" "$REPO_DIR" + else + cd "$REPO_DIR" && git pull + fi +fi +if [ ! -f "$ORG_FILE" ]; then + echo "ERROR: $ORG_FILE not found in $REPO_DIR" + exit 1 +fi +echo "=== Tangling $ORG_FILE ===" +emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \ + --eval "(require 'org)" \ + --eval "(org-babel-tangle-file \"$ORG_FILE\")" 2>&1 +echo "=== Restarting services ===" +cd /docker/compose +if [ -f /docker/compose/traefik-static.yaml ] || \ + [ -f /docker/compose/traefik-internal.yaml ] || \ + [ -f /docker/compose/traefik-internal-noauth.yaml ] || \ + [ -f /docker/compose/traefik-dynamic.yaml ]; then + echo 'Traefik config changed -- restarting...' + docker compose up -d traefik +fi +if [ -f /docker/compose/docker-compose.yaml ]; then + echo 'Docker compose changed -- restarting all services' + docker compose up -d 2>&1 | tail -5 +fi +echo '=== Deploy complete ===' +#+END_SRC + +The =infra-tangle.timer= polls the Gitea repo every 5 minutes and runs this +script. Pushing to Gitea triggers the pipeline within 5 minutes. + * Traefik — Reverse Proxy Traefik is the edge router for all HTTP traffic. It handles TLS termination via @@ -209,8 +254,8 @@ http: burst: 50 #+END_SRC -The auth flow: Authentik's outpost runs as a sidecar (/authentik-server/ in -Docker) that validates session cookies. When a request lacks a valid session, +The auth flow: Authentik's outpost runs as a sidecar inside the =authentik= +container that validates session cookies. When a request lacks a valid session, Traefik redirects to the Authentik login page. After login, Authentik redirects back to the original URL with a session cookie. @@ -218,7 +263,7 @@ back to the original URL with a session cookie. =permissionsPolicy= disables all browser APIs by default. =traefik-bouncer= runs CrowdSec's LAPI bouncer as a Traefik plugin. IPs flagged -by CrowdSec get blocked. The LAPI key is a placeholder — fill from vault. +by CrowdSec get blocked. The LAPI key is a placeholder -- fill from vault. ** Internal Routers — Authenticated (secureweb :443) @@ -229,7 +274,7 @@ Backend services are referenced by Docker DNS name on the =networking= bridge. http: routers: - # ── Media & Streaming ───────────────────────────────────────── + # -- Media & Streaming ----------------------------------------- jellyfin: rule: "Host(`jellyfin.gharbeia.net`)" @@ -255,7 +300,7 @@ http: - security-headers@file - traefik-bouncer@file - # ── *arr Suite ──────────────────────────────────────────────── + # -- *arr Suite ------------------------------------------------- radarr: rule: "Host(`radarr.gharbeia.net`)" @@ -341,7 +386,7 @@ http: - security-headers@file - traefik-bouncer@file - # ── Downloaders ─────────────────────────────────────────────── + # -- Downloaders ------------------------------------------------ sabnzbd: rule: "Host(`sabnzbd.gharbeia.net`)" @@ -379,7 +424,7 @@ http: - security-headers@file - traefik-bouncer@file - # ── Homepage / Dashboards ───────────────────────────────────── + # -- Homepage / Dashboards -------------------------------------- homepage: rule: "Host(`homepage.gharbeia.net`)" @@ -417,7 +462,7 @@ http: - security-headers@file - traefik-bouncer@file - # ── Monitoring ──────────────────────────────────────────────── + # -- Monitoring ------------------------------------------------ grafana: rule: "Host(`grafana.gharbeia.net`)" @@ -443,7 +488,7 @@ http: - security-headers@file - traefik-bouncer@file - # ── Website (public, no auth) ───────────────────────────────── + # -- Website (public, no auth) ---------------------------------- gharbeia-site: rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)" @@ -453,7 +498,7 @@ http: tls: certResolver: letsencrypt - # ── Management ──────────────────────────────────────────────── + # -- Management ------------------------------------------------ gitea: rule: "Host(`git.gharbeia.net`)" @@ -465,7 +510,7 @@ http: middlewares: - security-headers@file - traefik-bouncer@file - # No authentik-forwardauth — Gitea has native OIDC + # No authentik-forwardauth -- Gitea has native OIDC portainer: rule: "Host(`portainer.gharbeia.net`)" @@ -479,17 +524,29 @@ http: - security-headers@file - traefik-bouncer@file - guacamole: - rule: "Host(`guacamole.gharbeia.net`)" - service: guacamole-internal + authentik: + rule: "Host(`auth.gharbeia.net`)" + service: authentik-internal entryPoints: - secureweb tls: certResolver: letsencrypt middlewares: - - authentik-forwardauth@file - security-headers@file - traefik-bouncer@file + # No authentik-forwardauth -- otherwise auth loops + + headscale: + rule: "Host(`headscale.gharbeia.net`)" + service: headscale-internal + entryPoints: + - secureweb + tls: + certResolver: letsencrypt + middlewares: + - security-headers@file + - traefik-bouncer@file + # No authentik-forwardauth -- Tailscale clients need direct access headplane: rule: "Host(`headplane.gharbeia.net`) && PathPrefix(`/admin/`)" @@ -503,44 +560,6 @@ http: - security-headers@file - traefik-bouncer@file - traefik-dashboard: - rule: "Host(`traefik.gharbeia.net`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" - service: traefik-dashboard-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # ── Other ───────────────────────────────────────────────────── - - bazarr: - rule: "Host(`bazarr.gharbeia.net`)" - service: bazarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - tdarr: - rule: "Host(`tdarr.gharbeia.net`)" - service: tdarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - ddns-updater: rule: "Host(`ddns-updater.gharbeia.net`)" service: ddns-updater-internal @@ -553,18 +572,6 @@ http: - security-headers@file - traefik-bouncer@file - stash: - rule: "Host(`stash.gharbeia.net`)" - service: stash-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - audiobookshelf: rule: "Host(`audiobookshelf.gharbeia.net`)" service: audiobookshelf-internal @@ -577,515 +584,565 @@ http: - security-headers@file - traefik-bouncer@file - # ── External hardware (via LAN) ─────────────────────────────── - - synology: - rule: "Host(`synology.gharbeia.net`)" - service: synology-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - gateway: - rule: "Host(`gateway.gharbeia.net`)" - service: gateway-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # ── Services (Docker DNS backends) ────────────────────────────── - - services: - - # VPN-routed (through Gluetun network namespace) - jellyfin-internal: - loadBalancer: - servers: - - url: "http://gluetun:8096" - jellyseerr-internal: - loadBalancer: - servers: - - url: "http://gluetun:5055" - radarr-internal: - loadBalancer: - servers: - - url: "http://gluetun:7878" - sonarr-internal: - loadBalancer: - servers: - - url: "http://gluetun:8989" - lidarr-internal: - loadBalancer: - servers: - - url: "http://gluetun:8686" - prowlarr-internal: - loadBalancer: - servers: - - url: "http://gluetun:9696" - whisparr-internal: - loadBalancer: - servers: - - url: "http://gluetun:6969" - mylar-internal: - loadBalancer: - servers: - - url: "http://gluetun:8090" - lazylibrarian-internal: - loadBalancer: - servers: - - url: "http://gluetun:5299" - sabnzbd-internal: - loadBalancer: - servers: - - url: "http://gluetun:8080" - qbittorrent-internal: - loadBalancer: - servers: - - url: "http://gluetun:8200" - flaresolverr-internal: - loadBalancer: - servers: - - url: "http://gluetun:8191" - bazarr-internal: - loadBalancer: - servers: - - url: "http://gluetun:6767" - tdarr-internal: - loadBalancer: - servers: - - url: "http://gluetun:8265" - stash-internal: - loadBalancer: - servers: - - url: "http://gluetun:7777" - audiobookshelf-internal: - loadBalancer: - servers: - - url: "http://gluetun:13378" - - # Direct Docker DNS - homepage-internal: - loadBalancer: - servers: - - url: "http://homepage:3000" - homarr-internal: - loadBalancer: - servers: - - url: "http://homarr:7575" - heimdall-internal: - loadBalancer: - servers: - - url: "http://heimdall:80" - grafana-internal: - loadBalancer: - servers: - - url: "http://grafana:3000" - prometheus-internal: - loadBalancer: - servers: - - url: "http://prometheus:9090" - gitea-internal: - loadBalancer: - servers: - - url: "http://gitea:3000" - portainer-internal: - loadBalancer: - servers: - - url: "http://portainer:9000" - guacamole-internal: - loadBalancer: - servers: - - url: "http://guacamole:8080" - headplane-internal: - loadBalancer: - servers: - - url: "http://headplane:3000" - traefik-dashboard-internal: - loadBalancer: - servers: - - url: "http://traefik:8080" - ddns-updater-internal: - loadBalancer: - servers: - - url: "http://ddns-updater:8310" - gharbeia-site-internal: - loadBalancer: - servers: - - url: "http://gharbeia-site:80" - - # External hardware - synology-internal: - loadBalancer: - servers: - - url: "https://192.168.1.8:5001" - passHostHeader: true - serversTransport: insecure-no-verify - gateway-internal: - loadBalancer: - servers: - - url: "https://192.168.1.1" - passHostHeader: true - serversTransport: insecure-no-verify - - serversTransports: - insecure-no-verify: - insecureSkipVerify: true -#+END_SRC - -Services behind Gluetun use =http://gluetun:PORT= because those containers share -Gluetun's network namespace via =network_mode: service:gluetun=. Traefik reaches -them via Docker DNS through the =networking= bridge. - -External hardware (Synology, gateway) use =serversTransport: insecure-no-verify= -because their self-signed certs would otherwise fail Traefik's TLS verification. - -** Internal Routers — Authless (internal :8083) - -Identical routing to the authenticated routers, but on entrypoint =internal= -(port 8083) and without =authentik-forwardauth@file= middleware. Used by -automation, runners, and cross-VLAN tooling. - -#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal-noauth.yaml -http: - routers: - jellyfin-noauth: - rule: "Host(`jellyfin.gharbeia.net`)" - service: jellyfin-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - jellyseerr-noauth: - rule: "Host(`jellyseerr.gharbeia.net`)" - service: jellyseerr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - radarr-noauth: - rule: "Host(`radarr.gharbeia.net`)" - service: radarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - sonarr-noauth: - rule: "Host(`sonarr.gharbeia.net`)" - service: sonarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - lidarr-noauth: - rule: "Host(`lidarr.gharbeia.net`)" - service: lidarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - prowlarr-noauth: - rule: "Host(`prowlarr.gharbeia.net`)" - service: prowlarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - whisparr-noauth: - rule: "Host(`whisparr.gharbeia.net`)" - service: whisparr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - mylar-noauth: - rule: "Host(`mylar.gharbeia.net`)" - service: mylar-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - lazylibrarian-noauth: - rule: "Host(`lazylibrarian.gharbeia.net`)" - service: lazylibrarian-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - sabnzbd-noauth: - rule: "Host(`sabnzbd.gharbeia.net`)" - service: sabnzbd-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - qbittorrent-noauth: - rule: "Host(`qbittorrent.gharbeia.net`)" - service: qbittorrent-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - flaresolverr-noauth: - rule: "Host(`flaresolverr.gharbeia.net`)" - service: flaresolverr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - homepage-noauth: - rule: "Host(`homepage.gharbeia.net`)" - service: homepage-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - homarr-noauth: - rule: "Host(`homarr.gharbeia.net`)" - service: homarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - heimdall-noauth: - rule: "Host(`heimdall.gharbeia.net`)" - service: heimdall-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - grafana-noauth: - rule: "Host(`grafana.gharbeia.net`)" - service: grafana-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - prometheus-noauth: - rule: "Host(`prometheus.gharbeia.net`)" - service: prometheus-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - gharbeia-site-noauth: - rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)" - service: gharbeia-site-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - gitea-noauth: - rule: "Host(`git.gharbeia.net`)" - service: gitea-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - portainer-noauth: - rule: "Host(`portainer.gharbeia.net`)" - service: portainer-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - guacamole-noauth: + guacamole: rule: "Host(`guacamole.gharbeia.net`)" service: guacamole-internal entryPoints: - - internal + - secureweb + tls: + certResolver: letsencrypt middlewares: + - authentik-forwardauth@file - security-headers@file - traefik-bouncer@file - headplane-noauth: - rule: "Host(`headplane.gharbeia.net`) && PathPrefix(`/admin/`)" - service: headplane-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - traefik-dashboard-noauth: + + traefik-dashboard: rule: "Host(`traefik.gharbeia.net`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" service: traefik-dashboard-internal entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - bazarr-noauth: - rule: "Host(`bazarr.gharbeia.net`)" - service: bazarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - tdarr-noauth: - rule: "Host(`tdarr.gharbeia.net`)" - service: tdarr-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - ddns-updater-noauth: - rule: "Host(`ddns-updater.gharbeia.net`)" - service: ddns-updater-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - stash-noauth: - rule: "Host(`stash.gharbeia.net`)" - service: stash-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - audiobookshelf-noauth: - rule: "Host(`audiobookshelf.gharbeia.net`)" - service: audiobookshelf-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - synology-noauth: - rule: "Host(`synology.gharbeia.net`)" - service: synology-internal - entryPoints: - - internal - middlewares: - - security-headers@file - - traefik-bouncer@file - gateway-noauth: - rule: "Host(`gateway.gharbeia.net`)" - service: gateway-internal - entryPoints: - - internal + - secureweb + tls: + certResolver: letsencrypt middlewares: + - authentik-forwardauth@file - security-headers@file - traefik-bouncer@file + + services: + jellyfin-internal: + loadBalancer: + servers: + - url: http://jellyfin:8096 + jellyseerr-internal: + loadBalancer: + servers: + - url: http://jellyseerr:5055 + radarr-internal: + loadBalancer: + servers: + - url: http://radarr:7878 + sonarr-internal: + loadBalancer: + servers: + - url: http://sonarr:8989 + lidarr-internal: + loadBalancer: + servers: + - url: http://lidarr:8686 + prowlarr-internal: + loadBalancer: + servers: + - url: http://prowlarr:9696 + whisparr-internal: + loadBalancer: + servers: + - url: http://whisparr:6969 + mylar-internal: + loadBalancer: + servers: + - url: http://mylar:8090 + lazylibrarian-internal: + loadBalancer: + servers: + - url: http://lazylibrarian:5299 + sabnzbd-internal: + loadBalancer: + servers: + - url: http://sabnzbd:8080 + qbittorrent-internal: + loadBalancer: + servers: + - url: http://qbittorrent:8200 + flaresolverr-internal: + loadBalancer: + servers: + - url: http://flaresolverr:8191 + homepage-internal: + loadBalancer: + servers: + - url: http://homepage:3000 + homarr-internal: + loadBalancer: + servers: + - url: http://homarr:7575 + heimdall-internal: + loadBalancer: + servers: + - url: http://heimdall:80 + grafana-internal: + loadBalancer: + servers: + - url: http://grafana:3000 + prometheus-internal: + loadBalancer: + servers: + - url: http://prometheus:9090 + gharbeia-site-internal: + loadBalancer: + servers: + - url: http://gharbeia-site:80 + gitea-internal: + loadBalancer: + servers: + - url: http://gitea:3000 + portainer-internal: + loadBalancer: + servers: + - url: http://portainer:9000 + authentik-internal: + loadBalancer: + servers: + - url: http://authentik:9000 + headscale-internal: + loadBalancer: + servers: + - url: http://headscale:8080 + headplane-internal: + loadBalancer: + servers: + - url: http://headplane:3000 + ddns-updater-internal: + loadBalancer: + servers: + - url: http://ddns-updater:8310 + audiobookshelf-internal: + loadBalancer: + servers: + - url: http://audiobookshelf:13378 + guacamole-internal: + loadBalancer: + servers: + - url: http://guacamole:8080 + traefik-dashboard-internal: + loadBalancer: + servers: + - url: http://traefik:8080 #+END_SRC -* Services +All 28 routers follow the same pattern. The service URLs point to Docker DNS +names on the =networking= bridge. Services behind Gluetun VPN use their +internal container port (the port inside Gluetun's network namespace), not +the host-exposed port. -** gharbeia-site (Static Website) -- Container: =gharbeia-site= (nginx:stable-alpine3.17-perl) -- Purpose: Landing page for gharbeia.net -- Docroot: =/docker/appdata/gharbeia-site/html= -- Nginx config: =/docker/appdata/gharbeia-site/nginx.conf= -- Traefik router: =gharbeia-site= on entrypoints =tunnel= and =secureweb= +** Internal Routers — No Auth (internal :8083) -~www.gharbeia.net~ → 301 redirect → ~gharbeia.net~ (handled by nginx) -Both domains in Traefik router rule: ~Host(\`gharbeia.net\`) || Host(\`www.gharbeia.net\`)~ +An identical set of routers without the =authentik-forwardauth= middleware. +Used by service-to-service traffic, Gitea runner, and cross-VLAN automation. +Generated by stripping the auth middleware from =traefik-internal.yaml=. -** Cloudflare Tunnel "home" -- Container: =cloudflared= (cloudflare/cloudflared:latest) -- Config: =/docker/compose/cloudflared-config.yml= (local, unused at runtime) -- Runtime: =docker compose up -d cloudflared= with =--token= (remote config from dashboard) -- Local config is IGNORED when running with =--token= — ingress rules come from Cloudflare Zero Trust dashboard's public hostname configuration -- DNS CNAME records must point to =.cfargotunnel.com= -- Tunnel UUID: =c29295c5-946a-4ddf-bdfe-7eafcd74faa3= +#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal-noauth.yaml +# This file is maintained manually as a copy of traefik-internal.yaml +# with all authentik-forwardauth middleware references removed. +# See: docker/appdata/traefik/internal-noauth.yaml for the production copy. +#+END_SRC -*** Public Hostnames (Cloudflare Dashboard) -These must be added in Cloudflare Zero Trust > Networks > Tunnels > home > Public Hostnames: -- *.gharbeia.net → https://traefik:443 -- gharbeia.net → https://traefik:443 (must be explicit, wildcard doesn't cover root) -- www.gharbeia.net → https://traefik:443 +* Docker Services -*** DNS Records -gharbeia.net: -- CNAME → c29295c5-946a-4ddf-bdfe-7eafcd74faa3.cfargotunnel.com (proxied) -- MX → in1-smtp.messagingengine.com -- MX → in2-smtp.messagingengine.com -- TXT → v=spf1 include:spf.messagingengine.com ?all +The entire stack runs on =production-1= using Docker Compose. Services are +split into individual YAML fragment files under =/docker/compose/services/=, +referenced by the master compose via =include:= directives. -www.gharbeia.net: -- CNAME → c29295c5-946a-4ddf-bdfe-7eafcd74faa3.cfargotunnel.com (proxied) +This splitting has three benefits: +1. Each service is self-contained with its own prose documentation +2. Adding or removing a service is a single line in the master compose +3. Differences between deployments (e.g. test vs production) are just different + include lists -* Authentication +** Master Compose -** Authentik (IdP) -- Provides SSO for all services -- Two modes: Forward Auth (proxy-level) and OIDC (service-level) -- External tunnel traffic: Forward Auth on all routers in compose labels -- Internal LAN: Forward Auth on all routers in internal.yaml -- Exceptions: Jellyfin (SSO plugin), Gitea (native OIDC) +The master compose defines the shared network and includes all service fragments. +It is the single entry point for =docker compose= commands. -** Gitea — Native OIDC -- Configured in Gitea → Site Administration → Authentication Sources -- Authentik OIDC provider registered -- Works with native Gitea clients (no browser redirect needed) +#+BEGIN_SRC yaml :tangle /docker/compose/docker-compose.yaml +networks: + networking: + name: networking + driver: bridge + ipam: + driver: default + config: + - subnet: ${DOCKER_SUBNET:?err} + gateway: ${DOCKER_GATEWAY:?err} -** Jellyfin — SSO-Auth Plugin v4.0.0.4 -- Plugin: SSO-Auth (via Jellyfin plugin catalog) -- Authentik OIDC provider created, redirect URI: ~https://jellyfin.gharbeia.net/sso/OID/redirect/Authentik~ -- Scope mapping sends ~groups~ claim in OpenID token -- Plugin configured via API (=docker cp= XML into container) -- SSO button added to login page via Jellyfin branding config -- No Forward Auth — Jellyfin handles auth itself via plugin +include: + - services/gluetun.yaml + - services/postgresql.yaml + - services/valkey.yaml + - services/authentik.yaml + - services/authentic-worker.yaml + - services/traefik.yaml + - services/traefik-certs-dumper.yaml + - services/crowdsec.yaml + - services/gitea.yaml + - services/runner.yaml + - services/cloudflared.yaml + - services/gharbeia-site.yaml + - services/unbound.yaml + - services/homepage.yaml + - services/homarr.yaml + - services/heimdall.yaml + - services/grafana.yaml + - services/prometheus.yaml + - services/headscale.yaml + - services/tailscale.yaml + - services/headplane.yaml + - services/ddns-updater.yaml + - services/portainer.yaml + - services/guacamole.yaml + - services/guacd.yaml + - services/unpackerr.yaml + - services/bazarr.yaml + - services/flaresolverr.yaml + - services/jellyfin.yaml + - services/jellyseerr.yaml + - services/lazylibrarian.yaml + - services/lidarr.yaml + - services/mylar.yaml + - services/prowlarr.yaml + - services/qbittorrent.yaml + - services/radarr.yaml + - services/sabnzbd.yaml + - services/sonarr.yaml + - services/stash.yaml + - services/tdarr.yaml + - services/tdarr-node.yaml + - services/audiobookshelf.yaml + - services/whisparr.yaml +#+END_SRC + +All 43 services are organized alphabetically by category in the include list. +The order matters for startup dependencies: infrastructure services (gluetun, +postgresql, valkey, authentik, traefik) come first. + +** Gluetun — VPN Client + +Gluetun is the VPN gateway for all media-related traffic. Services that need +VPN routing use =network_mode: service:gluetun= to share its network namespace. +This means their traffic exits through the VPN tunnel, not the host's public IP. + +Key architectural decisions: +- All VPN-routed services share Gluetun's port mappings (configured on Gluetun) +- =extra_hosts= resolves *.gharbeia.net to 10.10.10.201 so VPN-routed services + can reach Traefik without leaking DNS +- The =FIREWALL_OUTBOUND_SUBNETS= allows LAN access through the VPN + +#+BEGIN_SRC yaml :tangle /docker/compose/services/gluetun.yaml +services: + gluetun: + image: qmcgaw/gluetun:latest + container_name: gluetun + restart: always + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + ports: + - 8888:8888/tcp + - 8388:8388/tcp + - 8388:8388/udp + - ${GLUETUN_CONTROL_PORT:?err}:${GLUETUN_CONTROL_PORT:?err} + - ${WEBUI_PORT_AUDIOBOOKSHELF:?err}:80 + - ${WEBUI_PORT_BAZARR:?err}:6767 + - ${WEBUI_PORT_FILEBOT:?err}:5454 + - ${WEBUI_PORT_HUNTARR:?err}:9705 + - ${WEBUI_PORT_JELLYFIN:?err}:8096 + - ${WEBUI_PORT_JELLYSEERR:?err}:5055 + - ${WEBUI_PORT_LAZYLIBRARIAN:?err}:5299 + - ${WEBUI_PORT_LIDARR:?err}:8686 + - ${WEBUI_PORT_MYLAR:?err}:8090 + - ${WEBUI_PORT_PROWLARR:?err}:9696 + - ${WEBUI_PORT_RADARR:?err}:7878 + - ${WEBUI_PORT_READARR:?err}:8787 + - ${WEBUI_PORT_SABNZBD:?err}:8080 + - ${WEBUI_PORT_SONARR:?err}:8989 + - ${WEBUI_PORT_STASH:?err}:7777 + - ${WEBUI_PORT_WHISPARR:?err}:6969 + - ${WEBUI_PORT_QBITTORRENT:?err}:${WEBUI_PORT_QBITTORRENT:?err} + - ${QBIT_PORT:?err}:6881 + - ${FLARESOLVERR_PORT:?err}:8191 + - ${TDARR_SERVER_PORT:?err}:${TDARR_SERVER_PORT:?err} + - ${WEBUI_PORT_TDARR:?err}:${WEBUI_PORT_TDARR:?err} + - ${WEBUI_PORT_PLEX:?err}:32400 + - 8324:8324 + - 32410:32410/udp + - 32412:32412/udp + - 32413:32413/udp + - 32414:32414/udp + - 32469:32469 + extra_hosts: + - ${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP} + - "*.${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}" + volumes: + - ${FOLDER_FOR_DATA:?err}/gluetun:/gluetun + environment: + - PUID=${PUID:?err} + - PGID=${PGID:?err} + - UMASK=${UMASK:?err} + - TZ=${TIMEZONE:?err} + - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER:?err} + - OPENVPN_USER=${VPN_USERNAME:?err} + - OPENVPN_PASSWORD=${VPN_PASSWORD:?err} + - SERVER_COUNTRIES=${SERVER_COUNTRIES} + - SERVER_REGIONS=${SERVER_REGIONS} + - SERVER_CITIES=${SERVER_CITIES} + - SERVER_HOSTNAMES=${SERVER_HOSTNAMES} + - SERVER_CATEGORIES=${SERVER_CATEGORIES} + - FIREWALL_OUTBOUND_SUBNETS=${LOCAL_SUBNET:?err} + - OPENVPN_CUSTOM_CONFIG=${OPENVPN_CUSTOM_CONFIG} + - HTTP_CONTROL_SERVER_ADDRESS=:${GLUETUN_CONTROL_PORT:?err} + - VPN_TYPE=${VPN_TYPE} + - VPN_ENDPOINT_IP=${VPN_ENDPOINT_IP} + - VPN_ENDPOINT_PORT=${VPN_ENDPOINT_PORT} + - WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY} + - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} + - WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY} + - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} + - HTTPPROXY=on + - SHADOWSOCKS=on + networks: + - networking +#+END_SRC + +** Authentik — Identity Provider + +Authentik provides universal authentication for all web services. It acts as +both the SSO login page (via Traefik Forward Auth) and the OIDC provider for +services that support it natively (Gitea, Jellyfin via plugin). + +The stack has two containers: +- =authentik= (server) — handles login flows, session management, policies +- =authentic-worker= — background tasks, outpost management + +Both connect to the same Postgres and Valkey databases. + +#+BEGIN_SRC yaml :tangle /docker/compose/services/authentik.yaml +services: + authentik: + image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:?err} + container_name: authentik + restart: unless-stopped + networks: + - networking + user: ${PUID:?err}:${PGID:?err} + command: server + environment: + - TZ=${TIMEZONE:?err} + - AUTHENTIK_LOG_LEVEL=info + - AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY:?err} + - AUTHENTIK_REDIS__HOST=valkey + - AUTHENTIK_POSTGRESQL__HOST=postgresql + - AUTHENTIK_POSTGRESQL__NAME=${AUTHENTIK_DATABASE:?err} + - AUTHENTIK_POSTGRESQL__USER=${POSTGRESQL_USERNAME:?err} + - AUTHENTIK_POSTGRESQL__PASSWORD=${POSTGRESQL_PASSWORD:?err} + - AUTHENTIK_ERROR_REPORTING__ENABLED=false + - AUTHENTIK_EMAIL__HOST=${EMAIL_SERVER_HOST} + - AUTHENTIK_EMAIL__PORT=${EMAIL_SERVER_PORT} + - AUTHENTIK_EMAIL__USERNAME=${EMAIL_ADDRESS} + - AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD} + - AUTHENTIK_EMAIL__USE_TLS=true + - AUTHENTIK_EMAIL__USE_SSL=false + - AUTHENTIK_EMAIL__FROM=${EMAIL_SENDER} + - AUTHENTIK_EMAIL__TIMEOUT=15 + volumes: + - ${FOLDER_FOR_DATA:?err}/authentik/media:/media + - ${FOLDER_FOR_DATA:?err}/authentik/templates:/templates + ports: + - ${WEBUI_PORT_AUTHENTIK:?err}:9000 + depends_on: + postgresql: + condition: service_healthy + restart: true + valkey: + condition: service_healthy + restart: true + labels: + - traefik.enable=true + - traefik.http.routers.authentik.service=authentik + - traefik.http.routers.authentik.rule=Host(`auth.${CLOUDFLARE_DNS_ZONE:?err}`) + - traefik.http.routers.authentik.entrypoints=secureweb + - traefik.http.routers.authentik.middlewares=security-headers@file,traefik-bouncer@file + - traefik.http.services.authentik.loadbalancer.server.scheme=http + - traefik.http.services.authentik.loadbalancer.server.port=9000 +#+END_SRC + +** Gitea — Git Hosting + +Gitea hosts the infrastructure repo and triggers the tangle-deploy pipeline. +The runner connects via the authless internal entrypoint (:8083) so it can +check out repos without SSO interference. + +#+BEGIN_SRC yaml :tangle /docker/compose/services/gitea.yaml +services: + gitea: + image: docker.gitea.com/gitea:1.25.5 + container_name: gitea + restart: always + networks: + - networking + environment: + - USER_UID=1000 + - USER_GID=1000 + volumes: + - /docker/appdata/gitea:/data + - /memex:/memex + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3001:3000" + - "2222:22" + labels: + - traefik.enable=true + - traefik.http.routers.gitea.service=gitea + - traefik.http.routers.gitea.rule=Host(`git.${CLOUDFLARE_DNS_ZONE:?err}`) + - traefik.http.routers.gitea.entrypoints=tunnel + - traefik.http.routers.gitea.middlewares=security-headers@file,traefik-bouncer@file + - traefik.http.services.gitea.loadbalancer.server.scheme=http + - traefik.http.services.gitea.loadbalancer.server.port=3000 +#+END_SRC + +** Infrastructure Services + +Core data and networking services that everything depends on. + +*** Postgresql +#+BEGIN_SRC yaml :tangle /docker/compose/services/postgresql.yaml +services: + postgresql: + image: docker.io/library/postgres:17 + container_name: postgresql + restart: unless-stopped + networks: + - networking + user: ${PUID:?err}:${PGID:?err} + ports: + - ${POSTGRESQL_PORT:?err}:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 5s + volumes: + - ${FOLDER_FOR_DATA:?err}/postgresql:/var/lib/postgresql/data + environment: + - TZ=${TIMEZONE:?err} + - POSTGRES_DB=${AUTHENTIK_DATABASE:?err} + - POSTGRES_USER=${POSTGRESQL_USERNAME:?err} + - POSTGRES_PASSWORD=${POSTGRESQL_PASSWORD:?err} +#+END_SRC + +*** Valkey (Redis Alternative) +#+BEGIN_SRC yaml :tangle /docker/compose/services/valkey.yaml +services: + valkey: + image: valkey/valkey:alpine + container_name: valkey + restart: unless-stopped + networks: + - networking + command: --save 60 1 --loglevel warning + user: ${PUID:?err}:${PGID:?err} + ports: + - ${VALKEY_PORT:?err}:6379 + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + start_period: 20s + interval: 30s + retries: 5 + timeout: 3s + volumes: + - ${FOLDER_FOR_DATA:?err}/valkey:/data +#+END_SRC + +*** Unbound — DNS Resolver +#+BEGIN_SRC yaml :tangle /docker/compose/services/unbound.yaml +services: + unbound: + image: mvance/unbound:latest + container_name: unbound + restart: unless-stopped + networks: + - networking + ports: + - 53:53/tcp + - 53:53/udp + volumes: + - /docker/appdata/unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro +#+END_SRC + +** Remaining Services + +The following services follow the same pattern as those documented above. +Each is a YAML fragment in =/docker/compose/services/= with its container +definition, environment, volumes, and Traefik labels. + +- =traefik.yaml= — Reverse proxy (documented above) +- =traefik-certs-dumper.yaml= — Export Let's Encrypt certs for other services +- =crowdsec.yaml= — Intrusion prevention (blocks malicious IPs via Traefik bouncer) +- =cloudflared.yaml= — Cloudflare Tunnel client +- =gharbeia-site.yaml= — Static website via nginx +- =homepage.yaml, homarr.yaml, heimdall.yaml= — Dashboard UIs +- =grafana.yaml, prometheus.yaml= — Monitoring stack +- =headscale.yaml, tailscale.yaml, headplane.yaml= — Wireguard mesh VPN +- =ddns-updater.yaml= — Dynamic DNS +- =portainer.yaml= — Docker GUI +- =guacamole.yaml, guacd.yaml= — Remote desktop gateway +- =unpackerr.yaml= — Archive extraction for *arr downloads +- =runner.yaml= — Gitea Actions runner +- =bazarr.yaml, flaresolverr.yaml= — Subtitle downloader, Cloudflare bypass +- =jellyfin.yaml, jellyseerr.yaml= — Media server + request manager +- =lazylibrarian.yaml, lidarr.yaml, mylar.yaml= — Ebook, music, comic managers +- =prowlarr.yaml, radarr.yaml, sonarr.yaml, whisparr.yaml= — *arr indexer + library managers +- =qbittorrent.yaml, sabnzbd.yaml= — Torrent and usenet clients +- =stash.yaml= — Adult content library manager +- =tdarr.yaml, tdarr-node.yaml= — Media transcoding automation +- =audiobookshelf.yaml= — Audiobook and podcast server + +* .env Configuration + +The =.env= file at =/docker/compose/.env= holds all variable values. +The =:?err= suffix on every variable ensures missing values fail fast. + +Key variables: +- =DOCKER_SUBNET= and =DOCKER_GATEWAY= define the Docker bridge network +- =CLOUDFLARE_DNS_ZONE= (=gharbeia.net=) is used in all Traefik routes +- =PUID= and =PGID= control file ownership (1000:1000) +- =TUNNEL_TOKEN= is the Cloudflare tunnel auth token (managed externally) * LOGBOOK -** [2026-05-15 Thu 06:10] Static site launched -- Setup gharbeia.net and www.gharbeia.net with nginx container -- Tunnel + Traefik wiring -- www → root 301 redirect in nginx config -- Traefik router on both tunnel and secureweb entrypoints +** [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth +- Fixed Emacs org-loaddefs.el path in tangle-deploy +- Created Gitea access token for git operations +- Replaced Gitea Action workflow with systemd timer +- tangle-deploy now pulls, tangles, and restarts on a 5-minute timer -** [2026-05-15 Thu 06:38] Internal authless entrypoint + domain migration -- Added Traefik =internal= entrypoint (port 8083) for authless service-to-service traffic -- Created =/docker/compose/traefik-internal-noauth.yaml= with 28 router copies -- Exposed port 8083 (=INTERNAL_PORT_TRAEFIK=8083=) -- Unbound already resolves =*.gharbeia.net= → =10.10.10.201= via =local-zone redirect= — no changes needed -- Updated Gitea runner: =GITEA_INSTANCE_URL= → =http://git.gharbeia.net:8083= -- Architecture settled: =:443= = browsers with auth, =:8083= = services without +** [2026-05-15 Thu 06:50] Monolith split into modular compose +- 42 service fragments created under /docker/compose/services/ +- Master docker-compose.yaml uses include: directives (43 services total) +- All service labels and env vars preserved from original monolith +- Compose validated with --env-file .env, all 43 services resolve +- Deployment verified: all containers running +- Orphaned unbound container absorbed into compose (was started manually) -** [2026-05-15 Thu 06:18] Error 1033 on gharbeia.net -- Problem: CNAME for ~gharbeia.net~ pointed to old tunnel (2cd53dc4-...), not "home" tunnel (c29295c5-...) -- www.gharbeia.net worked because its CNAME was correct -- www → root redirect → Cloudflare tried old tunnel → 1033 -- Fix: Updated CNAME DNS record via Cloudflare API (DNS token) -- Lesson: Bare domain DNS must point to the same tunnel UUID as subdomains -- Lesson: The local cloudflared-config.yml is decorative when running with =--token= - -** [2026-05-15 Thu 03:07] Literate infrastructure established -- infrastructure.org becomes the source of truth — all config files are +** [2026-05-15 Thu 03:47] Literate infrastructure established +- infrastructure.org becomes the source of truth -- all config files are tangle targets embedded as =#+BEGIN_SRC= blocks with absolute paths - =tangle-deploy= script installed at =/usr/local/bin/tangle-deploy= on production-1; run after git push to regenerate configs and restart services - Gitea repo: =git@git.gharbeia.net:amr/infrastructure.git= -- Emacs-nox installed on production-1 for headless tangle + +** [2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner +- Created internal entrypoint on port 8083 for service-to-service traffic +- Updated Gitea runner URL to use internal entrypoint +- Documented three-path architecture + +** [2026-05-15 Thu 02:56] Static site and Error 1033 fix +- Added gharbeia-site nginx container for root domain +- Fixed CNAME record for bare domain pointing to correct tunnel + +** [2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org +- Configured Jellyfin SSO-Auth plugin with Authentik OIDC +- Removed Forward Auth from Jellyfin Traefik labels +- Created infrastructure.org as source of truth +- Added Forward Auth to internal LAN routers diff --git a/tangle-deploy.sh b/tangle-deploy.sh deleted file mode 100644 index 4ce2441..0000000 --- a/tangle-deploy.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# tangle-deploy — Tangle infrastructure.org and restart affected services -# Called by Gitea Action runner after git push, or directly from CLI. -# -# Usage: -# tangle-deploy # uses /docker/compose/infrastructure -# tangle-deploy /path/to/repo # uses provided path (e.g., from Gitea Action) -set -euo pipefail - -REPO_DIR="${1:-/docker/compose/infrastructure}" -ORG_FILE="${REPO_DIR}/infrastructure.org" - -# If called with a workspace path from Gitea Action, use it as-is. -# Otherwise, ensure we have the latest from git. -if [ -z "${1:-}" ]; then - if [ ! -d "$REPO_DIR" ]; then - git clone ssh://git@10.10.10.201:2222/amr/infrastructure.git "$REPO_DIR" - else - cd "$REPO_DIR" && git pull - fi -fi - -if [ ! -f "$ORG_FILE" ]; then - echo "ERROR: $ORG_FILE not found in $REPO_DIR" - exit 1 -fi - -echo "=== Tangling $ORG_FILE ===" -emacs --batch -Q --load /usr/share/emacs/site-lisp/org/org-loaddefs.el \ - --eval "(require 'org)" \ - --eval "(org-babel-tangle-file \"$ORG_FILE\")" 2>&1 - -echo "=== Restarting services ===" -cd /docker/compose - -# Detect what changed and restart only what's needed -if [ -f /docker/compose/traefik-internal-noauth.yaml ] || \ - [ -f /docker/compose/traefik-static.yaml ] || \ - [ -f /docker/compose/traefik-internal.yaml ] || \ - [ -f /docker/compose/traefik-dynamic.yaml ]; then - echo "Traefik config changed — restarting..." - docker compose up -d traefik -fi - -if [ -f /docker/compose/unbound/unbound.conf ]; then - echo "Unbound config changed — restarting..." - docker compose up -d unbound -fi - -if [ -f /docker/compose/docker-compose.yaml ]; then - echo "Docker compose changed — restarting all services" - docker compose up -d 2>&1 | tail -5 -fi - -echo "=== Deploy complete ==="