diff --git a/infrastructure.org b/infrastructure.org index 2e9ed07..f79963a 100644 --- a/infrastructure.org +++ b/infrastructure.org @@ -1,1383 +1,1395 @@ -#+TITLE: Infrastructure Documentation — gharbeia.net -#+AUTHOR: Amr Gharbeia -#+DATE: 2026-05-15 - -* Architecture - -** Hosts -- =production-1= (10.10.10.201) :: Docker host, runs all services -- Hermes Agent :: Management/automation host - -** Network -- Docker network =networking= (172.28.10.0/24) -- Proxmox VLANs: 1/10/20/30/40/50 -- Services VLAN: 10.10.10.0/24 -- Domain: gharbeia.net via Cloudflare (orange cloud/proxied) - -** External Access Architecture - -#+BEGIN_EXAMPLE -Cloudflare (edge, orange cloud) - └─ Cloudflare Tunnel "home" (cloudflared on production-1) - └─ Traefik (entrypoint=tunnel, port 8081) - ├─ Authentik Forward Auth (external routers) - ├─ gharbeia-site (nginx) - ├─ jellyfin (SSO via plugin + OIDC) - ├─ gitea (native OIDC) - └─ *.gharbeia.net services -#+END_EXAMPLE - -** Internal Access Architecture - -#+BEGIN_EXAMPLE -LAN client (browser) - └─ Traefik (entrypoint=secureweb, port 443) - ├─ Authentik Forward Auth (internal.yaml routers) - ├─ gharbeia-site (public, no auth) - ├─ jellyfin (SSO via plugin) - └─ *.gharbeia.net services - -Service-to-service / automation / cross-VLAN - └─ Traefik (entrypoint=internal, port 8083 — NO auth) - └─ Same routing as secureweb, from traefik-internal-noauth.yaml -#+END_EXAMPLE - -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='ssh://git@git.gharbeia.net:2222/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 -Let's Encrypt (DNS-01 challenge through Cloudflare), routes traffic to the right -container, and applies middleware chains for auth, security, and rate limiting. - -Three entrypoints: - -- =tunnel= (=:8081=) :: Receives traffic from the Cloudflare tunnel. All routers - here have Authentik Forward Auth. -- =secureweb= (=:443=) :: Internal LAN traffic with TLS. Also has Authentik - Forward Auth for browser access. -- =internal= (=:8083=) :: Service-to-service and cross-VLAN traffic. No auth. - HTTP only. For runners, automation, and API calls that shouldn't hit Authentik. - -** Static Configuration - -The static config sets entrypoints, TLS resolvers, providers, and plugins. -It is the foundation everything else builds on. - -#+BEGIN_SRC yaml :tangle /docker/compose/traefik-static.yaml -global: - checkNewVersion: true - sendAnonymousUsage: true - -log: - level: INFO - -accessLog: - filePath: /var/log/access.log - format: json - -api: - dashboard: true - insecure: true - -entryPoints: - web: - address: :80 - http: - redirections: - entryPoint: - to: secureweb - scheme: https - permanent: true - tunnel: - address: :8081 - secureweb: - address: :443 - http: - tls: - options: default - certResolver: letsencrypt - domains: - - main: gharbeia.net - sans: - - "*.gharbeia.net" - internal: - address: :8083 - metrics: - address: :8082 - -metrics: - prometheus: - entryPoint: metrics - manualRouting: true - headerLabels: - useragent: User-Agent - buckets: - - 0.1 - - 0.3 - - 1.2 - - 5.0 - -providers: - docker: - exposedByDefault: false - file: - directory: /etc/traefik - watch: true - -certificatesResolvers: - letsencrypt: - acme: - storage: /letsencrypt/acme.json - email: gharbeia@riseup.net - keyType: EC384 - caServer: https://acme-v02.api.letsencrypt.org/directory - dnsChallenge: - provider: cloudflare - resolvers: - - 1.1.1.1:53 - - 1.0.0.1:53 - propagation: - delayBeforeChecks: 60s - -experimental: - plugins: - crowdsec-bouncer-traefik-plugin: - moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin - version: v1.4.2 -#+END_SRC - -Why each piece: -- =web= (=:80=) exists only to redirect to HTTPS. No TLS. -- =tunnel= (=:8081=) is inbound-only from cloudflared, never exposed to LAN. - Cloudflare handles TLS at the edge, so this can be plain HTTP inside Docker. -- =secureweb= (=:443=) is the LAN-facing entrypoint with Let's Encrypt certs - covering both =gharbeia.net= and =*.gharbeia.net=. -- =internal= (=:8083=) is plain HTTP for service-to-service traffic. TLS overhead - is unnecessary on the internal bridge network. -- =metrics= (=:8082=) exposes Prometheus metrics, manually routed. -- =dnsChallenge= with Cloudflare provider issues wildcard certs. The 60s - propagation delay avoids rate-limit issues with Cloudflare's API. - -** Dynamic Configuration — Middleware - -Shared middleware used by all routers. Defined once here, referenced by name -in every router block. - -#+BEGIN_SRC yaml :tangle /docker/compose/traefik-dynamic.yaml -http: - middlewares: - - authentik-forwardauth: - forwardAuth: - address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik - trustForwardHeader: true - authResponseHeaders: - - X-authentik-username - - X-authentik-groups - - X-authentik-email - - X-authentik-name - - X-authentik-uid - - security-headers: - headers: - customFrameOptionsValue: SAMEORIGIN - contentTypeNosniff: true - browserXssFilter: true - referrerPolicy: no-referrer - permissionsPolicy: "" - customResponseHeaders: - X-Robots-Tag: "noindex, nofollow" - Server: "" - - traefik-bouncer: - plugin: - crowdsec-bouncer-traefik-plugin: - enabled: "true" - crowdsecMode: live - crowdsecLapiKey: __CROWDSEC_LAPI_KEY__ - crowdsecLapiHost: crowdsec:8080 - crowdsecLapiScheme: http - updateFrequencySec: 5 - defaultDecisionLifetimeSec: 60 - - compress: - compress: - excludedContentTypes: - - text/event-stream - - ratelimit: - rateLimit: - average: 100 - burst: 50 -#+END_SRC - -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. - -=security-headers= locks down XSS, clickjacking, and fingerprinting. The empty -=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. - -** Internal Routers — Authenticated (secureweb :443) - -These routers serve LAN browser traffic. All have Authentik Forward Auth. -Backend services are referenced by Docker DNS name on the =networking= bridge. - -#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal.yaml -http: - routers: - - # -- Media & Streaming ----------------------------------------- - - jellyfin: - rule: "Host(`jellyfin.gharbeia.net`)" - service: jellyfin-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - jellyseerr: - rule: "Host(`jellyseerr.gharbeia.net`)" - service: jellyseerr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # -- *arr Suite ------------------------------------------------- - - radarr: - rule: "Host(`radarr.gharbeia.net`)" - service: radarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - sonarr: - rule: "Host(`sonarr.gharbeia.net`)" - service: sonarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - lidarr: - rule: "Host(`lidarr.gharbeia.net`)" - service: lidarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - prowlarr: - rule: "Host(`prowlarr.gharbeia.net`)" - service: prowlarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - whisparr: - rule: "Host(`whisparr.gharbeia.net`)" - service: whisparr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - mylar: - rule: "Host(`mylar.gharbeia.net`)" - service: mylar-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - lazylibrarian: - rule: "Host(`lazylibrarian.gharbeia.net`)" - service: lazylibrarian-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # -- Downloaders ------------------------------------------------ - - sabnzbd: - rule: "Host(`sabnzbd.gharbeia.net`)" - service: sabnzbd-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - qbittorrent: - rule: "Host(`qbittorrent.gharbeia.net`)" - service: qbittorrent-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - flaresolverr: - rule: "Host(`flaresolverr.gharbeia.net`)" - service: flaresolverr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # -- Homepage / Dashboards -------------------------------------- - - homepage: - rule: "Host(`homepage.gharbeia.net`)" - service: homepage-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - homarr: - rule: "Host(`homarr.gharbeia.net`)" - service: homarr-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - heimdall: - rule: "Host(`heimdall.gharbeia.net`)" - service: heimdall-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # -- Monitoring ------------------------------------------------ - - grafana: - rule: "Host(`grafana.gharbeia.net`)" - service: grafana-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - prometheus: - rule: "Host(`prometheus.gharbeia.net`)" - service: prometheus-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - # -- Website (public, no auth) ---------------------------------- - - gharbeia-site: - rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)" - service: gharbeia-site-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - - # -- Management ------------------------------------------------ - - gitea: - rule: "Host(`git.gharbeia.net`)" - service: gitea-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - security-headers@file - - traefik-bouncer@file - # No authentik-forwardauth -- Gitea has native OIDC - - portainer: - rule: "Host(`portainer.gharbeia.net`)" - service: portainer-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - authentik: - rule: "Host(`auth.gharbeia.net`)" - service: authentik-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - 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/`)" - service: headplane-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 - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - audiobookshelf: - rule: "Host(`audiobookshelf.gharbeia.net`)" - service: audiobookshelf-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - guacamole: - rule: "Host(`guacamole.gharbeia.net`)" - service: guacamole-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - security-headers@file - - traefik-bouncer@file - - tubearchivist: - rule: "Host(`tubearchivist.gharbeia.net`)" - service: tubearchivist-internal - entryPoints: - - secureweb - tls: - certResolver: letsencrypt - middlewares: - - authentik-forwardauth@file - - 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 - - services: - 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 - 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://gluetun:80 # audiobookshelf on port 80 inside gluetun - guacamole-internal: - loadBalancer: - servers: - - url: http://guacamole:8080 - traefik-dashboard-internal: - loadBalancer: - servers: - - url: http://traefik:8080 - tubearchivist-internal: - loadBalancer: - servers: - - url: http://tubearchivist:8000 -#+END_SRC - -All 28 routers follow the same pattern. The service URLs point to Docker DNS -names on the =networking= bridge. Services behind Gluetun VPN aren't on -the bridge network — they use =network_mode: service:gluetun= and are -reached via =http://gluetun:= instead of =http://servicename:=. - -** Internal Routers — No Auth (internal :8083) - -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=. - -#+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 - -** Authentication Architecture - -Three authentication mechanisms depending on the service type: - -*** Forward Auth (default for web-only services) - -Traefik middleware intercepts every request and redirects unauthenticated -users to the Authentik login page. After login, Authentik sets a session -cookie that passes subsequent checks transparently. - -Used by: all =*arr=, dashboards, monitoring, Portainer, Guacamole, etc. -Limitation: only works in browsers — native/TV apps can't use Forward Auth. - -*** Native OIDC / SSO (for services with apps) - -Services that have native mobile or TV apps need real OIDC integration so -the app can authenticate directly via a browser-based login flow. - -- Gitea: configured with Authentik OIDC provider in Gitea's admin panel. - Users log in via "Sign in with Authentik" button on the Gitea login page. - Existing user accounts are linked by username match. - -- Jellyfin: uses the SSO-Auth plugin (v4.0.0.4) with an Authentik OIDC - provider (client_id = =jellyfin-sso=). The plugin does a two-step flow: - 1. OIDC callback returns an HTML page with JavaScript + authorization state - 2. JavaScript POSTs to =/sso/OID/Auth/Authentik= to complete the login - - Critical detail: Jellyfin must trust Traefik's =X-Forwarded-Proto= header - or the JavaScript will construct URLs with =http://= instead of =https://=. - This is configured via =KnownProxies= in =/config/network.xml=: - - #+BEGIN_SRC xml - - 172.28.10.0/24 - 172.28.10.4 - - #+END_SRC - - Without this, =GetRequestBase()= returns =http://jellyfin.gharbeia.net= and - the iframe, auth POST, and final redirect all use the wrong scheme. - -*** Local users (TV apps, fallback) - -TV apps (Android TV, webOS, Tizen) often can't complete the OIDC JavaScript -two-step flow inside their embedded browser. For these, create a dedicated -Jellyfin local user (e.g. =tv=) with a simple password. The app logs in -directly with password — no SSO involved. The user keeps library access -without losing admin history/settings. - -This pattern applies to any service where native app SSO doesn't work: -create a local service account, use it for app access, keep SSO for browsers. - -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. - -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 - -** Master Compose - -The master compose defines the shared network and includes all service fragments. -It is the single entry point for =docker compose= commands. - -#+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} - -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/tubearchivist.yaml + 1|#+TITLE: Infrastructure Documentation — gharbeia.net + 2|#+AUTHOR: Amr Gharbeia + 3|#+DATE: 2026-05-15 + 4| + 5|* Architecture + 6| + 7|** Hosts + 8|- =production-1= (10.10.10.201) :: Docker host, runs all services + 9|- Hermes Agent :: Management/automation host + 10| + 11|** Network + 12|- Docker network =networking= (172.28.10.0/24) + 13|- Proxmox VLANs: 1/10/20/30/40/50 + 14|- Services VLAN: 10.10.10.0/24 + 15|- Domain: gharbeia.net via Cloudflare (orange cloud/proxied) + 16| + 17|** External Access Architecture + 18| + 19|#+BEGIN_EXAMPLE + 20|Cloudflare (edge, orange cloud) + 21| └─ Cloudflare Tunnel "home" (cloudflared on production-1) + 22| └─ Traefik (entrypoint=tunnel, port 8081) + 23| ├─ Authentik Forward Auth (external routers) + 24| ├─ gharbeia-site (nginx) + 25| ├─ jellyfin (SSO via plugin + OIDC) + 26| ├─ gitea (native OIDC) + 27| └─ *.gharbeia.net services + 28|#+END_EXAMPLE + 29| + 30|** Internal Access Architecture + 31| + 32|#+BEGIN_EXAMPLE + 33|LAN client (browser) + 34| └─ Traefik (entrypoint=secureweb, port 443) + 35| ├─ Authentik Forward Auth (internal.yaml routers) + 36| ├─ gharbeia-site (public, no auth) + 37| ├─ jellyfin (SSO via plugin) + 38| └─ *.gharbeia.net services + 39| + 40|Service-to-service / automation / cross-VLAN + 41| └─ Traefik (entrypoint=internal, port 8083 — NO auth) + 42| └─ Same routing as secureweb, from traefik-internal-noauth.yaml + 43|#+END_EXAMPLE + 44| + 45|Key distinction: =:443= = browsers/humans with Authentik auth. + 46|=:8083= = runners, automated tooling, services on other VLANs. + 47| + 48|** Tangle & Deploy Pipeline + 49| + 50|Changes are made to this org file, tangled into config files by the + 51|=tangle-deploy= script on production-1, then deployed via =docker compose=. + 52| + 53|#+BEGIN_SRC bash :tangle /docker/compose/infrastructure/tangle-deploy.sh + 54|#!/usr/bin/env bash + 55|# tangle-deploy — Tangle infrastructure.org and restart affected services + 56|GITEA_URL='ssh://git@git.gharbeia.net:2222/amr/infrastructure.git' + 57|REPO_DIR="${1:-/docker/compose/infrastructure}" + 58|ORG_FILE="${REPO_DIR}/infrastructure.org" + 59|if [ -z "${1:-}" ]; then + 60| if [ ! -d "$REPO_DIR" ]; then + 61| git clone "$GITEA_URL" "$REPO_DIR" + 62| else + 63| cd "$REPO_DIR" && git pull + 64| fi + 65|fi + 66|if [ ! -f "$ORG_FILE" ]; then + 67| echo "ERROR: $ORG_FILE not found in $REPO_DIR" + 68| exit 1 + 69|fi + 70|echo "=== Tangling $ORG_FILE ===" + 71|emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \ + 72| --eval "(require 'org)" \ + 73| --eval "(org-babel-tangle-file \"$ORG_FILE\")" 2>&1 + 74|echo "=== Restarting services ===" + 75|cd /docker/compose + 76|if [ -f /docker/compose/traefik-static.yaml ] || \ + 77| [ -f /docker/compose/traefik-internal.yaml ] || \ + 78| [ -f /docker/compose/traefik-internal-noauth.yaml ] || \ + 79| [ -f /docker/compose/traefik-dynamic.yaml ]; then + 80| echo 'Traefik config changed -- restarting...' + 81| docker compose up -d traefik + 82|fi + 83|if [ -f /docker/compose/docker-compose.yaml ]; then + 84| echo 'Docker compose changed -- restarting all services' + 85| docker compose up -d 2>&1 | tail -5 + 86|fi + 87|echo '=== Deploy complete ===' + 88|#+END_SRC + 89| + 90|The =infra-tangle.timer= polls the Gitea repo every 5 minutes and runs this + 91|script. Pushing to Gitea triggers the pipeline within 5 minutes. + 92| + 93|* Traefik — Reverse Proxy + 94| + 95|Traefik is the edge router for all HTTP traffic. It handles TLS termination via + 96|Let's Encrypt (DNS-01 challenge through Cloudflare), routes traffic to the right + 97|container, and applies middleware chains for auth, security, and rate limiting. + 98| + 99|Three entrypoints: + 100| + 101|- =tunnel= (=:8081=) :: Receives traffic from the Cloudflare tunnel. All routers + 102| here have Authentik Forward Auth. + 103|- =secureweb= (=:443=) :: Internal LAN traffic with TLS. Also has Authentik + 104| Forward Auth for browser access. + 105|- =internal= (=:8083=) :: Service-to-service and cross-VLAN traffic. No auth. + 106| HTTP only. For runners, automation, and API calls that shouldn't hit Authentik. + 107| + 108|** Static Configuration + 109| + 110|The static config sets entrypoints, TLS resolvers, providers, and plugins. + 111|It is the foundation everything else builds on. + 112| + 113|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-static.yaml + 114|global: + 115| checkNewVersion: true + 116| sendAnonymousUsage: true + 117| + 118|log: + 119| level: INFO + 120| + 121|accessLog: + 122| filePath: /var/log/access.log + 123| format: json + 124| + 125|api: + 126| dashboard: true + 127| insecure: true + 128| + 129|entryPoints: + 130| web: + 131| address: :80 + 132| http: + 133| redirections: + 134| entryPoint: + 135| to: secureweb + 136| scheme: https + 137| permanent: true + 138| tunnel: + 139| address: :8081 + 140| secureweb: + 141| address: :443 + 142| http: + 143| tls: + 144| options: default + 145| certResolver: letsencrypt + 146| domains: + 147| - main: gharbeia.net + 148| sans: + 149| - "*.gharbeia.net" + 150| internal: + 151| address: :8083 + 152| metrics: + 153| address: :8082 + 154| + 155|metrics: + 156| prometheus: + 157| entryPoint: metrics + 158| manualRouting: true + 159| headerLabels: + 160| useragent: User-Agent + 161| buckets: + 162| - 0.1 + 163| - 0.3 + 164| - 1.2 + 165| - 5.0 + 166| + 167|providers: + 168| docker: + 169| exposedByDefault: false + 170| file: + 171| directory: /etc/traefik + 172| watch: true + 173| + 174|certificatesResolvers: + 175| letsencrypt: + 176| acme: + 177| storage: /letsencrypt/acme.json + 178| email: gharbeia@riseup.net + 179| keyType: EC384 + 180| caServer: https://acme-v02.api.letsencrypt.org/directory + 181| dnsChallenge: + 182| provider: cloudflare + 183| resolvers: + 184| - 1.1.1.1:53 + 185| - 1.0.0.1:53 + 186| propagation: + 187| delayBeforeChecks: 60s + 188| + 189|experimental: + 190| plugins: + 191| crowdsec-bouncer-traefik-plugin: + 192| moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + 193| version: v1.4.2 + 194|#+END_SRC + 195| + 196|Why each piece: + 197|- =web= (=:80=) exists only to redirect to HTTPS. No TLS. + 198|- =tunnel= (=:8081=) is inbound-only from cloudflared, never exposed to LAN. + 199| Cloudflare handles TLS at the edge, so this can be plain HTTP inside Docker. + 200|- =secureweb= (=:443=) is the LAN-facing entrypoint with Let's Encrypt certs + 201| covering both =gharbeia.net= and =*.gharbeia.net=. + 202|- =internal= (=:8083=) is plain HTTP for service-to-service traffic. TLS overhead + 203| is unnecessary on the internal bridge network. + 204|- =metrics= (=:8082=) exposes Prometheus metrics, manually routed. + 205|- =dnsChallenge= with Cloudflare provider issues wildcard certs. The 60s + 206| propagation delay avoids rate-limit issues with Cloudflare's API. + 207| + 208|** Dynamic Configuration — Middleware + 209| + 210|Shared middleware used by all routers. Defined once here, referenced by name + 211|in every router block. + 212| + 213|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-dynamic.yaml + 214|http: + 215| middlewares: + 216| + 217| authentik-forwardauth: + 218| forwardAuth: + 219| address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik + 220| trustForwardHeader: true + 221| authResponseHeaders: + 222| - X-authentik-username + 223| - X-authentik-groups + 224| - X-authentik-email + 225| - X-authentik-name + 226| - X-authentik-uid + 227| + 228| security-headers: + 229| headers: + 230| customFrameOptionsValue: SAMEORIGIN + 231| contentTypeNosniff: true + 232| browserXssFilter: true + 233| referrerPolicy: no-referrer + 234| permissionsPolicy: "" + 235| customResponseHeaders: + 236| X-Robots-Tag: "noindex, nofollow" + 237| Server: "" + 238| + 239| traefik-bouncer: + 240| plugin: + 241| crowdsec-bouncer-traefik-plugin: + 242| enabled: "true" + 243| crowdsecMode: live + 244| crowdsecLapiKey: __CROWDSEC_LAPI_KEY__ + 245| crowdsecLapiHost: crowdsec:8080 + 246| crowdsecLapiScheme: http + 247| updateFrequencySec: 5 + 248| defaultDecisionLifetimeSec: 60 + 249| + 250| compress: + 251| compress: + 252| excludedContentTypes: + 253| - text/event-stream + 254| + 255| ratelimit: + 256| rateLimit: + 257| average: 100 + 258| burst: 50 + 259|#+END_SRC + 260| + 261|The auth flow: Authentik's outpost runs as a sidecar inside the =authentik= + 262|container that validates session cookies. When a request lacks a valid session, + 263|Traefik redirects to the Authentik login page. After login, Authentik redirects + 264|back to the original URL with a session cookie. + 265| + 266|=security-headers= locks down XSS, clickjacking, and fingerprinting. The empty + 267|=permissionsPolicy= disables all browser APIs by default. + 268| + 269|=traefik-bouncer= runs CrowdSec's LAPI bouncer as a Traefik plugin. IPs flagged + 270|by CrowdSec get blocked. The LAPI key is a placeholder -- fill from vault. + 271| + 272|** Internal Routers — Authenticated (secureweb :443) + 273| + 274|These routers serve LAN browser traffic. All have Authentik Forward Auth. + 275|Backend services are referenced by Docker DNS name on the =networking= bridge. + 276| + 277|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal.yaml + 278|http: + 279| routers: + 280| + 281| # -- Media & Streaming ----------------------------------------- + 282| + 283| jellyfin: + 284| rule: "Host(`jellyfin.gharbeia.net`)" + 285| service: jellyfin-internal + 286| entryPoints: + 287| - secureweb + 288| tls: + 289| certResolver: letsencrypt + 290| middlewares: + 291| - authentik-forwardauth@file + 292| - security-headers@file + 293| - traefik-bouncer@file + 294| + 295| jellyseerr: + 296| rule: "Host(`jellyseerr.gharbeia.net`)" + 297| service: jellyseerr-internal + 298| entryPoints: + 299| - secureweb + 300| tls: + 301| certResolver: letsencrypt + 302| middlewares: + 303| - authentik-forwardauth@file + 304| - security-headers@file + 305| - traefik-bouncer@file + 306| + 307| # -- *arr Suite ------------------------------------------------- + 308| + 309| radarr: + 310| rule: "Host(`radarr.gharbeia.net`)" + 311| service: radarr-internal + 312| entryPoints: + 313| - secureweb + 314| tls: + 315| certResolver: letsencrypt + 316| middlewares: + 317| - authentik-forwardauth@file + 318| - security-headers@file + 319| - traefik-bouncer@file + 320| + 321| sonarr: + 322| rule: "Host(`sonarr.gharbeia.net`)" + 323| service: sonarr-internal + 324| entryPoints: + 325| - secureweb + 326| tls: + 327| certResolver: letsencrypt + 328| middlewares: + 329| - authentik-forwardauth@file + 330| - security-headers@file + 331| - traefik-bouncer@file + 332| + 333| lidarr: + 334| rule: "Host(`lidarr.gharbeia.net`)" + 335| service: lidarr-internal + 336| entryPoints: + 337| - secureweb + 338| tls: + 339| certResolver: letsencrypt + 340| middlewares: + 341| - authentik-forwardauth@file + 342| - security-headers@file + 343| - traefik-bouncer@file + 344| + 345| prowlarr: + 346| rule: "Host(`prowlarr.gharbeia.net`)" + 347| service: prowlarr-internal + 348| entryPoints: + 349| - secureweb + 350| tls: + 351| certResolver: letsencrypt + 352| middlewares: + 353| - authentik-forwardauth@file + 354| - security-headers@file + 355| - traefik-bouncer@file + 356| + 357| whisparr: + 358| rule: "Host(`whisparr.gharbeia.net`)" + 359| service: whisparr-internal + 360| entryPoints: + 361| - secureweb + 362| tls: + 363| certResolver: letsencrypt + 364| middlewares: + 365| - authentik-forwardauth@file + 366| - security-headers@file + 367| - traefik-bouncer@file + 368| + 369| mylar: + 370| rule: "Host(`mylar.gharbeia.net`)" + 371| service: mylar-internal + 372| entryPoints: + 373| - secureweb + 374| tls: + 375| certResolver: letsencrypt + 376| middlewares: + 377| - authentik-forwardauth@file + 378| - security-headers@file + 379| - traefik-bouncer@file + 380| + 381| lazylibrarian: + 382| rule: "Host(`lazylibrarian.gharbeia.net`)" + 383| service: lazylibrarian-internal + 384| entryPoints: + 385| - secureweb + 386| tls: + 387| certResolver: letsencrypt + 388| middlewares: + 389| - authentik-forwardauth@file + 390| - security-headers@file + 391| - traefik-bouncer@file + 392| + 393| # -- Downloaders ------------------------------------------------ + 394| + 395| sabnzbd: + 396| rule: "Host(`sabnzbd.gharbeia.net`)" + 397| service: sabnzbd-internal + 398| entryPoints: + 399| - secureweb + 400| tls: + 401| certResolver: letsencrypt + 402| middlewares: + 403| - authentik-forwardauth@file + 404| - security-headers@file + 405| - traefik-bouncer@file + 406| + 407| qbittorrent: + 408| rule: "Host(`qbittorrent.gharbeia.net`)" + 409| service: qbittorrent-internal + 410| entryPoints: + 411| - secureweb + 412| tls: + 413| certResolver: letsencrypt + 414| middlewares: + 415| - authentik-forwardauth@file + 416| - security-headers@file + 417| - traefik-bouncer@file + 418| + 419| flaresolverr: + 420| rule: "Host(`flaresolverr.gharbeia.net`)" + 421| service: flaresolverr-internal + 422| entryPoints: + 423| - secureweb + 424| tls: + 425| certResolver: letsencrypt + 426| middlewares: + 427| - authentik-forwardauth@file + 428| - security-headers@file + 429| - traefik-bouncer@file + 430| + 431| # -- Homepage / Dashboards -------------------------------------- + 432| + 433| homepage: + 434| rule: "Host(`homepage.gharbeia.net`)" + 435| service: homepage-internal + 436| entryPoints: + 437| - secureweb + 438| tls: + 439| certResolver: letsencrypt + 440| middlewares: + 441| - authentik-forwardauth@file + 442| - security-headers@file + 443| - traefik-bouncer@file + 444| + 445| homarr: + 446| rule: "Host(`homarr.gharbeia.net`)" + 447| service: homarr-internal + 448| entryPoints: + 449| - secureweb + 450| tls: + 451| certResolver: letsencrypt + 452| middlewares: + 453| - authentik-forwardauth@file + 454| - security-headers@file + 455| - traefik-bouncer@file + 456| + 457| heimdall: + 458| rule: "Host(`heimdall.gharbeia.net`)" + 459| service: heimdall-internal + 460| entryPoints: + 461| - secureweb + 462| tls: + 463| certResolver: letsencrypt + 464| middlewares: + 465| - authentik-forwardauth@file + 466| - security-headers@file + 467| - traefik-bouncer@file + 468| + 469| # -- Monitoring ------------------------------------------------ + 470| + 471| grafana: + 472| rule: "Host(`grafana.gharbeia.net`)" + 473| service: grafana-internal + 474| entryPoints: + 475| - secureweb + 476| tls: + 477| certResolver: letsencrypt + 478| middlewares: + 479| - authentik-forwardauth@file + 480| - security-headers@file + 481| - traefik-bouncer@file + 482| + 483| prometheus: + 484| rule: "Host(`prometheus.gharbeia.net`)" + 485| service: prometheus-internal + 486| entryPoints: + 487| - secureweb + 488| tls: + 489| certResolver: letsencrypt + 490| middlewares: + 491| - authentik-forwardauth@file + 492| - security-headers@file + 493| - traefik-bouncer@file + 494| + 495| # -- Website (public, no auth) ---------------------------------- + 496| + 497| gharbeia-site: + 498| rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)" + 499| service: gharbeia-site-internal + 500| entryPoints: + 501| - secureweb + 502| tls: + 503| certResolver: letsencrypt + 504| + 505| # -- Management ------------------------------------------------ + 506| + 507| gitea: + 508| rule: "Host(`git.gharbeia.net`)" + 509| service: gitea-internal + 510| entryPoints: + 511| - secureweb + 512| tls: + 513| certResolver: letsencrypt + 514| middlewares: + 515| - security-headers@file + 516| - traefik-bouncer@file + 517| # No authentik-forwardauth -- Gitea has native OIDC + 518| + 519| portainer: + 520| rule: "Host(`portainer.gharbeia.net`)" + 521| service: portainer-internal + 522| entryPoints: + 523| - secureweb + 524| tls: + 525| certResolver: letsencrypt + 526| middlewares: + 527| - authentik-forwardauth@file + 528| - security-headers@file + 529| - traefik-bouncer@file + 530| + 531| authentik: + 532| rule: "Host(`auth.gharbeia.net`)" + 533| service: authentik-internal + 534| entryPoints: + 535| - secureweb + 536| tls: + 537| certResolver: letsencrypt + 538| middlewares: + 539| - security-headers@file + 540| - traefik-bouncer@file + 541| # No authentik-forwardauth -- otherwise auth loops + 542| + 543| headscale: + 544| rule: "Host(`headscale.gharbeia.net`)" + 545| service: headscale-internal + 546| entryPoints: + 547| - secureweb + 548| tls: + 549| certResolver: letsencrypt + 550| middlewares: + 551| - security-headers@file + 552| - traefik-bouncer@file + 553| # No authentik-forwardauth -- Tailscale clients need direct access + 554| + 555| headplane: + 556| rule: "Host(`headplane.gharbeia.net`) && PathPrefix(`/admin/`)" + 557| service: headplane-internal + 558| entryPoints: + 559| - secureweb + 560| tls: + 561| certResolver: letsencrypt + 562| middlewares: + 563| - authentik-forwardauth@file + 564| - security-headers@file + 565| - traefik-bouncer@file + 566| + 567| ddns-updater: + 568| rule: "Host(`ddns-updater.gharbeia.net`)" + 569| service: ddns-updater-internal + 570| entryPoints: + 571| - secureweb + 572| tls: + 573| certResolver: letsencrypt + 574| middlewares: + 575| - authentik-forwardauth@file + 576| - security-headers@file + 577| - traefik-bouncer@file + 578| + 579| audiobookshelf: + 580| rule: "Host(`audiobookshelf.gharbeia.net`)" + 581| service: audiobookshelf-internal + 582| entryPoints: + 583| - secureweb + 584| tls: + 585| certResolver: letsencrypt + 586| middlewares: + 587| - authentik-forwardauth@file + 588| - security-headers@file + 589| - traefik-bouncer@file + 590| + 591| guacamole: + 592| rule: "Host(`guacamole.gharbeia.net`)" + 593| service: guacamole-internal + 594| entryPoints: + 595| - secureweb + 596| tls: + 597| certResolver: letsencrypt + 598| middlewares: + 599| - authentik-forwardauth@file + 600| - security-headers@file + 601| - traefik-bouncer@file + 602| + 603| tubearchivist: + 604| rule: "Host(`tubearchivist.gharbeia.net`)" + 605| service: tubearchivist-internal + 606| entryPoints: + 607| - secureweb + 608| tls: + 609| certResolver: letsencrypt + 610| middlewares: + 611| - authentik-forwardauth@file + 612| - security-headers@file + 613| - traefik-bouncer@file + 614| + 615| traefik-dashboard: + 616| rule: "Host(`traefik.gharbeia.net`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" + 617| service: traefik-dashboard-internal + 618| entryPoints: + 619| - secureweb + 620| tls: + 621| certResolver: letsencrypt + 622| middlewares: + 623| - authentik-forwardauth@file + 624| - security-headers@file + 625| - traefik-bouncer@file + 626| + 627| services: + 628| jellyfin-internal: + 629| loadBalancer: + 630| servers: + 631| - url: http://gluetun:8096 + 632| jellyseerr-internal: + 633| loadBalancer: + 634| servers: + 635| - url: http://gluetun:5055 + 636| radarr-internal: + 637| loadBalancer: + 638| servers: + 639| - url: http://gluetun:7878 + 640| sonarr-internal: + 641| loadBalancer: + 642| servers: + 643| - url: http://gluetun:8989 + 644| lidarr-internal: + 645| loadBalancer: + 646| servers: + 647| - url: http://gluetun:8686 + 648| prowlarr-internal: + 649| loadBalancer: + 650| servers: + 651| - url: http://gluetun:9696 + 652| whisparr-internal: + 653| loadBalancer: + 654| servers: + 655| - url: http://gluetun:6969 + 656| mylar-internal: + 657| loadBalancer: + 658| servers: + 659| - url: http://gluetun:8090 + 660| lazylibrarian-internal: + 661| loadBalancer: + 662| servers: + 663| - url: http://gluetun:5299 + 664| sabnzbd-internal: + 665| loadBalancer: + 666| servers: + 667| - url: http://gluetun:8080 + 668| qbittorrent-internal: + 669| loadBalancer: + 670| servers: + 671| - url: http://gluetun:8200 + 672| flaresolverr-internal: + 673| loadBalancer: + 674| servers: + 675| - url: http://gluetun:8191 + 676| homepage-internal: + 677| loadBalancer: + 678| servers: + 679| - url: http://homepage:3000 + 680| homarr-internal: + 681| loadBalancer: + 682| servers: + 683| - url: http://homarr:7575 + 684| heimdall-internal: + 685| loadBalancer: + 686| servers: + 687| - url: http://heimdall:80 + 688| grafana-internal: + 689| loadBalancer: + 690| servers: + 691| - url: http://grafana:3000 + 692| prometheus-internal: + 693| loadBalancer: + 694| servers: + 695| - url: http://prometheus:9090 + 696| gharbeia-site-internal: + 697| loadBalancer: + 698| servers: + 699| - url: http://gharbeia-site:80 + 700| gitea-internal: + 701| loadBalancer: + 702| servers: + 703| - url: http://gitea:3000 + 704| portainer-internal: + 705| loadBalancer: + 706| servers: + 707| - url: http://portainer:9000 + 708| authentik-internal: + 709| loadBalancer: + 710| servers: + 711| - url: http://authentik:9000 + 712| headscale-internal: + 713| loadBalancer: + 714| servers: + 715| - url: http://headscale:8080 + 716| headplane-internal: + 717| loadBalancer: + 718| servers: + 719| - url: http://headplane:3000 + 720| ddns-updater-internal: + 721| loadBalancer: + 722| servers: + 723| - url: http://ddns-updater:8310 + 724| audiobookshelf-internal: + 725| loadBalancer: + 726| servers: + 727| - url: http://gluetun:80 # audiobookshelf on port 80 inside gluetun + 728| guacamole-internal: + 729| loadBalancer: + 730| servers: + 731| - url: http://guacamole:8080 + 732| traefik-dashboard-internal: + 733| loadBalancer: + 734| servers: + 735| - url: http://traefik:8080 + 736| tubearchivist-internal: + 737| loadBalancer: + 738| servers: + 739| - url: http://gluetun:8000 # tubearchivist on port 8000 inside gluetun + 740|#+END_SRC + 741| + 742|All 28 routers follow the same pattern. The service URLs point to Docker DNS + 743|names on the =networking= bridge. Services behind Gluetun VPN aren't on + 744|the bridge network — they use =network_mode: service:gluetun= and are + 745|reached via =http://gluetun:= instead of =http://servicename:=. + 746| + 747|** Internal Routers — No Auth (internal :8083) + 748| + 749|An identical set of routers without the =authentik-forwardauth= middleware. + 750|Used by service-to-service traffic, Gitea runner, and cross-VLAN automation. + 751|Generated by stripping the auth middleware from =traefik-internal.yaml=. + 752| + 753|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal-noauth.yaml + 754|# This file is maintained manually as a copy of traefik-internal.yaml + 755|# with all authentik-forwardauth middleware references removed. + 756|# See: docker/appdata/traefik/internal-noauth.yaml for the production copy. + 757|#+END_SRC + 758| + 759|** Authentication Architecture + 760| + 761|Three authentication mechanisms depending on the service type: + 762| + 763|*** Forward Auth (default for web-only services) + 764| + 765|Traefik middleware intercepts every request and redirects unauthenticated + 766|users to the Authentik login page. After login, Authentik sets a session + 767|cookie that passes subsequent checks transparently. + 768| + 769|Used by: all =*arr=, dashboards, monitoring, Portainer, Guacamole, etc. + 770|Limitation: only works in browsers — native/TV apps can't use Forward Auth. + 771| + 772|*** Native OIDC / SSO (for services with apps) + 773| + 774|Services that have native mobile or TV apps need real OIDC integration so + 775|the app can authenticate directly via a browser-based login flow. + 776| + 777|- Gitea: configured with Authentik OIDC provider in Gitea's admin panel. + 778| Users log in via "Sign in with Authentik" button on the Gitea login page. + 779| Existing user accounts are linked by username match. + 780| + 781|- Jellyfin: uses the SSO-Auth plugin (v4.0.0.4) with an Authentik OIDC + 782| provider (client_id = =jellyfin-sso=). The plugin does a two-step flow: + 783| 1. OIDC callback returns an HTML page with JavaScript + authorization state + 784| 2. JavaScript POSTs to =/sso/OID/Auth/Authentik= to complete the login + 785| + 786| Critical detail: Jellyfin must trust Traefik's =X-Forwarded-Proto= header + 787| or the JavaScript will construct URLs with =http://= instead of =https://=. + 788| This is configured via =KnownProxies= in =/config/network.xml=: + 789| + 790| #+BEGIN_SRC xml + 791| + 792| 172.28.10.0/24 + 793| 172.28.10.4 + 794| + 795| #+END_SRC + 796| + 797| Without this, =GetRequestBase()= returns =http://jellyfin.gharbeia.net= and + 798| the iframe, auth POST, and final redirect all use the wrong scheme. + 799| + 800|*** Local users (TV apps, fallback) + 801| + 802|TV apps (Android TV, webOS, Tizen) often can't complete the OIDC JavaScript + 803|two-step flow inside their embedded browser. For these, create a dedicated + 804|Jellyfin local user (e.g. =tv=) with a simple password. The app logs in + 805|directly with password — no SSO involved. The user keeps library access + 806|without losing admin history/settings. + 807| + 808|This pattern applies to any service where native app SSO doesn't work: + 809|create a local service account, use it for app access, keep SSO for browsers. + 810| + 811|The entire stack runs on =production-1= using Docker Compose. Services are + 812|split into individual YAML fragment files under =/docker/compose/services/=, + 813|referenced by the master compose via =include:= directives. + 814| + 815|This splitting has three benefits: + 816|1. Each service is self-contained with its own prose documentation + 817|2. Adding or removing a service is a single line in the master compose + 818|3. Differences between deployments (e.g. test vs production) are just different + 819| include lists + 820| + 821|** Master Compose + 822| + 823|The master compose defines the shared network and includes all service fragments. + 824|It is the single entry point for =docker compose= commands. + 825| + 826|#+BEGIN_SRC yaml :tangle /docker/compose/docker-compose.yaml + 827|networks: + 828| networking: + 829| name: networking + 830| driver: bridge + 831| ipam: + 832| driver: default + 833| config: + 834| - subnet: ${DOCKER_SUBNET:?err} + 835| gateway: ${DOCKER_GATEWAY:?err} + 836| + 837|include: + 838| - services/gluetun.yaml + 839| - services/postgresql.yaml + 840| - services/valkey.yaml + 841| - services/authentik.yaml + 842| - services/authentic-worker.yaml + 843| - services/traefik.yaml + 844| - services/traefik-certs-dumper.yaml + 845| - services/crowdsec.yaml + 846| - services/gitea.yaml + 847| - services/runner.yaml + 848| - services/cloudflared.yaml + 849| - services/gharbeia-site.yaml + 850| - services/unbound.yaml + 851| - services/homepage.yaml + 852| - services/homarr.yaml + 853| - services/heimdall.yaml + 854| - services/grafana.yaml + 855| - services/prometheus.yaml + 856| - services/headscale.yaml + 857| - services/tailscale.yaml + 858| - services/headplane.yaml + 859| - services/ddns-updater.yaml + 860| - services/portainer.yaml + 861| - services/guacamole.yaml + 862| - services/guacd.yaml + 863| - services/unpackerr.yaml + 864| - services/bazarr.yaml + 865| - services/flaresolverr.yaml + 866| - services/jellyfin.yaml + 867| - services/jellyseerr.yaml + 868| - services/lazylibrarian.yaml + 869| - services/lidarr.yaml + 870| - services/mylar.yaml + 871| - services/prowlarr.yaml + 872| - services/qbittorrent.yaml + 873| - services/radarr.yaml + 874| - services/sabnzbd.yaml + 875| - services/sonarr.yaml + 876| - services/stash.yaml + 877| - services/tdarr.yaml + 878| - services/tdarr-node.yaml + 879| - services/tubearchivist.yaml - services/audiobookshelf.yaml + - services/audiomuse.yaml - services/whisparr.yaml -#+END_SRC - -All 44 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. - -** Jellyfin — Media Server - -Jellyfin serves media libraries through the browser and native apps. It runs -in Gluetun's network namespace (VPN-routed), uses Authentik SSO for browser -login, and supports local user accounts for TV apps. - -*** KnownProxies (Critical for SSO) - -Jellyfin sits behind Traefik reverse proxy. Without =KnownProxies=, Jellyfin -doesn't trust =X-Forwarded-Proto: https=, so the SSO plugin's JavaScript -flow constructs URLs with HTTP instead of HTTPS. This file must match the -runtime config at =/docker/appdata/jellyfin/network.xml=. - -#+BEGIN_SRC xml :tangle /docker/appdata/jellyfin/network.xml - - - - false - false - - - 8096 - 8920 - 8096 - 8920 - true - true - true - false - true - - - - 172.28.10.0/24 - 172.28.10.4 - - true - - veth - - false - - - false - -#+END_SRC - -*** SSO-Auth Plugin Configuration - -The SSO plugin config lives at =/docker/appdata/jellyfin/plugins/configurations/SSO-Auth.xml=. -Key settings: -- OIDC provider pointing to =https://auth.gharbeia.net/application/o/jellyfin-sso= -- =EnableAuthorization=false= (bypasses group-based role checking) -- =EnableAllFolders=true= (all libraries accessible to SSO users) -- Scopes: =openid profile email groups= - -With =EnableAuthorization=false=, any Authentik user can log in to Jellyfin -via SSO. Admin rights are managed within Jellyfin itself. - -** 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 - -** Tube Archivist — YouTube Archiving - -Tube Archivist downloads and indexes YouTube channels, playlists, and -videos. Full-text search, metadata browsing, subscription management. - -The stack has three containers: -- =tubearchivist= (main app) — Django web UI on port 8000 -- =tubearchivist-es= — Elasticsearch 8.17 for metadata + search -- =tubearchivist-redis= — Redis for Celery task queue - -Tube Archivist does NOT need VPN routing (reaches YouTube directly). - -#+BEGIN_SRC yaml :tangle /docker/compose/services/tubearchivist.yaml -services: - tubearchivist: - image: bbilly1/tubearchivist:latest - container_name: tubearchivist - restart: unless-stopped - networks: - - networking - ports: - - ${WEBUI_PORT_TUBEARCHIVIST:-8000}:8000 - environment: - - TZ=${TIMEZONE:?err} - - TA_USERNAME=${TA_USERNAME:?err} - - TA_PASSWORD=${TA_PASSWORD:?err} - - ES_URL=http://tubearchivist-es:9200 + 882|#+END_SRC + 883| + 884|All 44 services are organized alphabetically by category in the include list. + 885|The order matters for startup dependencies: infrastructure services (gluetun, + 886|postgresql, valkey, authentik, traefik) come first. + 887| + 888|** Jellyfin — Media Server + 889| + 890|Jellyfin serves media libraries through the browser and native apps. It runs + 891|in Gluetun's network namespace (VPN-routed), uses Authentik SSO for browser + 892|login, and supports local user accounts for TV apps. + 893| + 894|*** KnownProxies (Critical for SSO) + 895| + 896|Jellyfin sits behind Traefik reverse proxy. Without =KnownProxies=, Jellyfin + 897|doesn't trust =X-Forwarded-Proto: https=, so the SSO plugin's JavaScript + 898|flow constructs URLs with HTTP instead of HTTPS. This file must match the + 899|runtime config at =/docker/appdata/jellyfin/network.xml=. + 900| + 901|#+BEGIN_SRC xml :tangle /docker/appdata/jellyfin/network.xml + 902| + 903| + 904| + 905| false + 906| false + 907| + 908| + 909| 8096 + 910| 8920 + 911| 8096 + 912| 8920 + 913| true + 914| true + 915| true + 916| false + 917| true + 918| + 919| + 920| + 921| 172.28.10.0/24 + 922| 172.28.10.4 + 923| + 924| true + 925| + 926| veth + 927| + 928| false + 929| + 930| + 931| false + 932| + 933|#+END_SRC + 934| + 935|*** SSO-Auth Plugin Configuration + 936| + 937|The SSO plugin config lives at =/docker/appdata/jellyfin/plugins/configurations/SSO-Auth.xml=. + 938|Key settings: + 939|- OIDC provider pointing to =https://auth.gharbeia.net/application/o/jellyfin-sso= + 940|- =EnableAuthorization=false= (bypasses group-based role checking) + 941|- =EnableAllFolders=true= (all libraries accessible to SSO users) + 942|- Scopes: =openid profile email groups= + 943| + 944|With =EnableAuthorization=false=, any Authentik user can log in to Jellyfin + 945|via SSO. Admin rights are managed within Jellyfin itself. + 946| + 947|** Gluetun — VPN Client + 948| + 949|Gluetun is the VPN gateway for all media-related traffic. Services that need + 950|VPN routing use =network_mode: service:gluetun= to share its network namespace. + 951|This means their traffic exits through the VPN tunnel, not the host's public IP. + 952| + 953|Key architectural decisions: + 954|- All VPN-routed services share Gluetun's port mappings (configured on Gluetun) + 955|- =extra_hosts= resolves *.gharbeia.net to 10.10.10.201 so VPN-routed services + 956| can reach Traefik without leaking DNS + 957|- The =FIREWALL_OUTBOUND_SUBNETS= allows LAN access through the VPN + 958| + 959|#+BEGIN_SRC yaml :tangle /docker/compose/services/gluetun.yaml + 960|services: + 961| gluetun: + 962| image: qmcgaw/gluetun:latest + 963| container_name: gluetun + 964| restart: always + 965| cap_add: + 966| - NET_ADMIN + 967| devices: + 968| - /dev/net/tun:/dev/net/tun + 969| ports: + 970| - 8888:8888/tcp + 971| - 8388:8388/tcp + 972| - 8388:8388/udp + 973| - ${GLUETUN_CONTROL_PORT:?err}:${GLUETUN_CONTROL_PORT:?err} + 974| - ${WEBUI_PORT_AUDIOBOOKSHELF:?err}:80 + 975| - ${WEBUI_PORT_BAZARR:?err}:6767 + 976| - ${WEBUI_PORT_FILEBOT:?err}:5454 + 977| - ${WEBUI_PORT_HUNTARR:?err}:9705 + 978| - ${WEBUI_PORT_JELLYFIN:?err}:8096 + 979| - ${WEBUI_PORT_JELLYSEERR:?err}:5055 + 980| - ${WEBUI_PORT_LAZYLIBRARIAN:?err}:5299 + 981| - ${WEBUI_PORT_LIDARR:?err}:8686 + 982| - ${WEBUI_PORT_MYLAR:?err}:8090 + 983| - ${WEBUI_PORT_PROWLARR:?err}:9696 + 984| - ${WEBUI_PORT_RADARR:?err}:7878 + 985| - ${WEBUI_PORT_READARR:?err}:8787 + 986| - ${WEBUI_PORT_SABNZBD:?err}:8080 + 987| - ${WEBUI_PORT_SONARR:?err}:8989 + 988| - ${WEBUI_PORT_STASH:?err}:7777 + 989| - ${WEBUI_PORT_WHISPARR:?err}:6969 + 990| - ${WEBUI_PORT_QBITTORRENT:?err}:${WEBUI_PORT_QBITTORRENT:?err} + 991| - ${QBIT_PORT:?err}:6881 + 992| - ${FLARESOLVERR_PORT:?err}:8191 + 993| - ${TDARR_SERVER_PORT:?err}:${TDARR_SERVER_PORT:?err} + 994| - ${WEBUI_PORT_TDARR:?err}:${WEBUI_PORT_TDARR:?err} + 995| - ${WEBUI_PORT_PLEX:?err}:32400 + 996| - ${WEBUI_PORT_TUBEARCHIVIST:-8000}:8000 + 997| - 8324:8324 + 998| - 32410:32410/udp + 999| - 32412:32412/udp + 1000| - 32413:32413/udp + 1001| - 32414:32414/udp + 1002| - 32469:32469 + 1003| extra_hosts: + 1004| - ${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP} + 1005| - "*.${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}" + 1006| volumes: + 1007| - ${FOLDER_FOR_DATA:?err}/gluetun:/gluetun + 1008| environment: + 1009| - PUID=${PUID:?err} + 1010| - PGID=${PGID:?err} + 1011| - UMASK=${UMASK:?err} + 1012| - TZ=${TIMEZONE:?err} + 1013| - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER:?err} + 1014| - OPENVPN_USER=${VPN_USERNAME:?err} + 1015| - OPENVPN_PASSWORD=${VPN_PASSWORD:?err} + 1016| - SERVER_COUNTRIES=${SERVER_COUNTRIES} + 1017| - SERVER_REGIONS=${SERVER_REGIONS} + 1018| - SERVER_CITIES=${SERVER_CITIES} + 1019| - SERVER_HOSTNAMES=${SERVER_HOSTNAMES} + 1020| - SERVER_CATEGORIES=${SERVER_CATEGORIES} + 1021| - FIREWALL_OUTBOUND_SUBNETS=${LOCAL_SUBNET:?err} + 1022| - OPENVPN_CUSTOM_CONFIG=${OPENVPN_CUSTOM_CONFIG} + 1023| - HTTP_CONTROL_SERVER_ADDRESS=:${GLUETUN_CONTROL_PORT:?err} + 1024| - VPN_TYPE=${VPN_TYPE} + 1025| - VPN_ENDPOINT_IP=${VPN_ENDPOINT_IP} + 1026| - VPN_ENDPOINT_PORT=${VPN_ENDPOINT_PORT} + 1027| - WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY} + 1028| - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY} + 1029| - WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY} + 1030| - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} + 1031| - HTTPPROXY=on + 1032| - SHADOWSOCKS=on + 1033| networks: + 1034| - networking + 1035|#+END_SRC + 1036| + 1037|** Authentik — Identity Provider + 1038| + 1039|Authentik provides universal authentication for all web services. It acts as + 1040|both the SSO login page (via Traefik Forward Auth) and the OIDC provider for + 1041|services that support it natively (Gitea, Jellyfin via plugin). + 1042| + 1043|The stack has two containers: + 1044|- =authentik= (server) — handles login flows, session management, policies + 1045|- =authentic-worker= — background tasks, outpost management + 1046| + 1047|Both connect to the same Postgres and Valkey databases. + 1048| + 1049|#+BEGIN_SRC yaml :tangle /docker/compose/services/authentik.yaml + 1050|services: + 1051| authentik: + 1052| image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:?err} + 1053| container_name: authentik + 1054| restart: unless-stopped + 1055| networks: + 1056| - networking + 1057| user: ${PUID:?err}:${PGID:?err} + 1058| command: server + 1059| environment: + 1060| - TZ=${TIMEZONE:?err} + 1061| - AUTHENTIK_LOG_LEVEL=info + 1062| - AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY:?err} + 1063| - AUTHENTIK_REDIS__HOST=valkey + 1064| - AUTHENTIK_POSTGRESQL__HOST=postgresql + 1065| - AUTHENTIK_POSTGRESQL__NAME=${AUTHENTIK_DATABASE:?err} + 1066| - AUTHENTIK_POSTGRESQL__USER=${POSTGRESQL_USERNAME:?err} + 1067| - AUTHENTIK_POSTGRESQL__PASSWORD=${POSTGRESQL_PASSWORD:?err} + 1068| - AUTHENTIK_ERROR_REPORTING__ENABLED=false + 1069| - AUTHENTIK_EMAIL__HOST=${EMAIL_SERVER_HOST} + 1070| - AUTHENTIK_EMAIL__PORT=${EMAIL_SERVER_PORT} + 1071| - AUTHENTIK_EMAIL__USERNAME=${EMAIL_ADDRESS} + 1072| - AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD} + 1073| - AUTHENTIK_EMAIL__USE_TLS=true + 1074| - AUTHENTIK_EMAIL__USE_SSL=false + 1075| - AUTHENTIK_EMAIL__FROM=${EMAIL_SENDER} + 1076| - AUTHENTIK_EMAIL__TIMEOUT=15 + 1077| volumes: + 1078| - ${FOLDER_FOR_DATA:?err}/authentik/media:/media + 1079| - ${FOLDER_FOR_DATA:?err}/authentik/templates:/templates + 1080| ports: + 1081| - ${WEBUI_PORT_AUTHENTIK:?err}:9000 + 1082| depends_on: + 1083| postgresql: + 1084| condition: service_healthy + 1085| restart: true + 1086| valkey: + 1087| condition: service_healthy + 1088| restart: true + 1089| labels: + 1090| - traefik.enable=true + 1091| - traefik.http.routers.authentik.service=authentik + 1092| - traefik.http.routers.authentik.rule=Host(`auth.${CLOUDFLARE_DNS_ZONE:?err}`) + 1093| - traefik.http.routers.authentik.entrypoints=secureweb + 1094| - traefik.http.routers.authentik.middlewares=security-headers@file,traefik-bouncer@file + 1095| - traefik.http.services.authentik.loadbalancer.server.scheme=http + 1096| - traefik.http.services.authentik.loadbalancer.server.port=9000 + 1097|#+END_SRC + 1098| + 1099|** Gitea — Git Hosting + 1100| + 1101|Gitea hosts the infrastructure repo and triggers the tangle-deploy pipeline. + 1102|The runner connects via the authless internal entrypoint (:8083) so it can + 1103|check out repos without SSO interference. + 1104| + 1105|#+BEGIN_SRC yaml :tangle /docker/compose/services/gitea.yaml + 1106|services: + 1107| gitea: + 1108| image: docker.gitea.com/gitea:1.25.5 + 1109| container_name: gitea + 1110| restart: always + 1111| networks: + 1112| - networking + 1113| environment: + 1114| - USER_UID=1000 + 1115| - USER_GID=1000 + 1116| volumes: + 1117| - /docker/appdata/gitea:/data + 1118| - /memex:/memex + 1119| - /etc/timezone:/etc/timezone:ro + 1120| - /etc/localtime:/etc/localtime:ro + 1121| ports: + 1122| - "3001:3000" + 1123| - "2222:22" + 1124| labels: + 1125| - traefik.enable=true + 1126| - traefik.http.routers.gitea.service=gitea + 1127| - traefik.http.routers.gitea.rule=Host(`git.${CLOUDFLARE_DNS_ZONE:?err}`) + 1128| - traefik.http.routers.gitea.entrypoints=tunnel + 1129| - traefik.http.routers.gitea.middlewares=security-headers@file,traefik-bouncer@file + 1130| - traefik.http.services.gitea.loadbalancer.server.scheme=http + 1131| - traefik.http.services.gitea.loadbalancer.server.port=3000 + 1132|#+END_SRC + 1133| + 1134|** Infrastructure Services + 1135| + 1136|Core data and networking services that everything depends on. + 1137| + 1138|*** Postgresql + 1139|#+BEGIN_SRC yaml :tangle /docker/compose/services/postgresql.yaml + 1140|services: + 1141| postgresql: + 1142| image: docker.io/library/postgres:17 + 1143| container_name: postgresql + 1144| restart: unless-stopped + 1145| networks: + 1146| - networking + 1147| user: ${PUID:?err}:${PGID:?err} + 1148| ports: + 1149| - ${POSTGRESQL_PORT:?err}:5432 + 1150| healthcheck: + 1151| test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + 1152| start_period: 20s + 1153| interval: 30s + 1154| retries: 5 + 1155| timeout: 5s + 1156| volumes: + 1157| - ${FOLDER_FOR_DATA:?err}/postgresql:/var/lib/postgresql/data + 1158| environment: + 1159| - TZ=${TIMEZONE:?err} + 1160| - POSTGRES_DB=${AUTHENTIK_DATABASE:?err} + 1161| - POSTGRES_USER=${POSTGRESQL_USERNAME:?err} + 1162| - POSTGRES_PASSWORD=${POSTGRESQL_PASSWORD:?err} + 1163|#+END_SRC + 1164| + 1165|*** Valkey (Redis Alternative) + 1166|#+BEGIN_SRC yaml :tangle /docker/compose/services/valkey.yaml + 1167|services: + 1168| valkey: + 1169| image: valkey/valkey:alpine + 1170| container_name: valkey + 1171| restart: unless-stopped + 1172| networks: + 1173| - networking + 1174| command: --save 60 1 --loglevel warning + 1175| user: ${PUID:?err}:${PGID:?err} + 1176| ports: + 1177| - ${VALKEY_PORT:?err}:6379 + 1178| healthcheck: + 1179| test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] + 1180| start_period: 20s + 1181| interval: 30s + 1182| retries: 5 + 1183| timeout: 3s + 1184| volumes: + 1185| - ${FOLDER_FOR_DATA:?err}/valkey:/data + 1186|#+END_SRC + 1187| + 1188|*** Unbound — DNS Resolver + 1189|#+BEGIN_SRC yaml :tangle /docker/compose/services/unbound.yaml + 1190|services: + 1191| unbound: + 1192| image: mvance/unbound:latest + 1193| container_name: unbound + 1194| restart: unless-stopped + 1195| networks: + 1196| - networking + 1197| ports: + 1198| - 53:53/tcp + 1199| - 53:53/udp + 1200| volumes: + 1201| - /docker/appdata/unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro + 1202|#+END_SRC + 1203| + 1204|** Tube Archivist — YouTube Archiving + 1205| + 1206|Tube Archivist downloads and indexes YouTube channels, playlists, and + 1207|videos. Full-text search, metadata browsing, subscription management. + 1208| + 1209|The stack has three containers: + 1210|- =tubearchivist= (main app) — Django web UI on port 8000 + 1211|- =tubearchivist-es= — Elasticsearch 8.17 for metadata + search + 1212|- =tubearchivist-redis= — Redis for Celery task queue + 1213| + 1214|Tube Archivist routes through Gluetun VPN to avoid YouTube geo-blocking. + 1215| + 1216|#+BEGIN_SRC yaml :tangle /docker/compose/services/tubearchivist.yaml + 1217|services: + 1218| tubearchivist: + 1219| image: bbilly1/tubearchivist:latest + 1220| container_name: tubearchivist + 1221| restart: unless-stopped + 1222| depends_on: + 1223| gluetun: + 1224| condition: service_healthy + 1225| restart: true + 1226| network_mode: service:gluetun + 1227| volumes: + 1228| - ${FOLDER_FOR_MORE:?err}/media/youtube:/youtube + 1229| - ${FOLDER_FOR_DATA:?err}/tubearchivist/cache:/cache + 1230| environment: + 1231| - TZ=${TIMEZONE:?err} + 1232| - TA_USERNAME=${TA_USERNAME:?err} + 1233| - TA_PASSWORD=${TA_PASSWORD:?err} + 1234| - ES_URL=http://tubearchivist-es:9200 - REDIS_CON=redis://tubearchivist-redis:6379 + - TA_HOST=https://tubearchivist.gharbeia.net + - ELASTIC_PASSWORD=tubearchivist - HOST_UID=${PUID:?err} - HOST_GID=${PGID:?err} - - ELASTIC_PASSWORD=tubearchivist - - TA_HOST=tubearchivist.gharbeia.net - volumes: - - ${FOLDER_FOR_DATA:?err}/tubearchivist/media:/youtube - - ${FOLDER_FOR_DATA:?err}/tubearchivist/cache:/cache - depends_on: - tubearchivist-es: - condition: service_healthy - tubearchivist-redis: - condition: service_healthy labels: - - traefik.enable=true - - traefik.http.routers.tubearchivist.service=tubearchivist - - traefik.http.routers.tubearchivist.rule=Host(`tubearchivist.${CLOUDFLARE_DNS_ZONE:?err}`) - - traefik.http.routers.tubearchivist.entrypoints=tunnel - - traefik.http.routers.tubearchivist.middlewares=authentik-forwardauth@file,security-headers@file,traefik-bouncer@file - - traefik.http.services.tubearchivist.loadbalancer.server.scheme=http - - traefik.http.services.tubearchivist.loadbalancer.server.port=8000 + 1257| - traefik.http.services.tubearchivist.loadbalancer.server.port=8000 + 1258| + 1259| tubearchivist-es: + 1260| image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0 + 1261| container_name: tubearchivist-es + 1262| restart: unless-stopped + 1263| networks: + 1264| - networking + 1265| environment: + 1266| - discovery.type=single-node + 1267| - ES_JAVA_OPTS=-Xms512m -Xmx512m + 1268| - xpack.security.enabled=false + 1269| - path.repo=/usr/share/elasticsearch/data/snapshot + 1270| volumes: + 1271| - ${FOLDER_FOR_DATA:?err}/tubearchivist/es:/usr/share/elasticsearch/data + 1272| healthcheck: + 1273| test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"' + 1274| interval: 30s + 1275| timeout: 10s + 1276| retries: 3 + 1277| + 1278| tubearchivist-redis: + 1279| image: redis:7-alpine + 1280| container_name: tubearchivist-redis + 1281| restart: unless-stopped + 1282| networks: + 1283| - networking + 1284| command: --save 60 1 --loglevel warning + 1285| volumes: + 1286| - ${FOLDER_FOR_DATA:?err}/tubearchivist/redis:/data + 1287| healthcheck: + 1288| test: redis-cli ping | grep PONG + 1289| interval: 30s + 1290| timeout: 10s + 1291| retries: 3 + 1292|#+END_SRC + 1293| + 1294|** Remaining Services + 1295| + 1296|The following services follow the same pattern as those documented above. + 1297|Each is a YAML fragment in =/docker/compose/services/= with its container + 1298|definition, environment, volumes, and Traefik labels. + 1299| + 1300|- =traefik.yaml= — Reverse proxy (documented above) + 1301|- =traefik-certs-dumper.yaml= — Export Let's Encrypt certs for other services + 1302|- =crowdsec.yaml= — Intrusion prevention (blocks malicious IPs via Traefik bouncer) + 1303|- =cloudflared.yaml= — Cloudflare Tunnel client + 1304|- =gharbeia-site.yaml= — Static website via nginx + 1305|- =homepage.yaml, homarr.yaml, heimdall.yaml= — Dashboard UIs + 1306|- =grafana.yaml, prometheus.yaml= — Monitoring stack + 1307|- =headscale.yaml, tailscale.yaml, headplane.yaml= — Wireguard mesh VPN + 1308|- =ddns-updater.yaml= — Dynamic DNS + 1309|- =portainer.yaml= — Docker GUI + 1310|- =guacamole.yaml, guacd.yaml= — Remote desktop gateway + 1311|- =unpackerr.yaml= — Archive extraction for *arr downloads + 1312|- =runner.yaml= — Gitea Actions runner + 1313|- =bazarr.yaml, flaresolverr.yaml= — Subtitle downloader, Cloudflare bypass + 1314|- =jellyfin.yaml, jellyseerr.yaml= — Media server + request manager + 1315|- =lazylibrarian.yaml, lidarr.yaml, mylar.yaml= — Ebook, music, comic managers + 1316|- =prowlarr.yaml, radarr.yaml, sonarr.yaml, whisparr.yaml= — *arr indexer + library managers + 1317|- =qbittorrent.yaml, sabnzbd.yaml= — Torrent and usenet clients + 1318|- =stash.yaml= — Adult content library manager + 1319|- =tdarr.yaml, tdarr-node.yaml= — Media transcoding automation + 1320|- =tubearchivist.yaml= — YouTube archiving (Tube Archivist) + 1321|- =audiobookshelf.yaml= — Audiobook and podcast server - tubearchivist-es: - image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0 - container_name: tubearchivist-es - restart: unless-stopped - networks: - - networking - environment: - - discovery.type=single-node - - ES_JAVA_OPTS=-Xms512m -Xmx512m - - xpack.security.enabled=false - - path.repo=/usr/share/elasticsearch/data/snapshot - volumes: - - ${FOLDER_FOR_DATA:?err}/tubearchivist/es:/usr/share/elasticsearch/data - healthcheck: - test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"' - interval: 30s - timeout: 10s - retries: 3 +** AudioMuse-AI — Sonic Playlist Generator - tubearchivist-redis: - image: redis:7-alpine - container_name: tubearchivist-redis - restart: unless-stopped - networks: - - networking - command: --save 60 1 --loglevel warning - volumes: - - ${FOLDER_FOR_DATA:?err}/tubearchivist/redis:/data - healthcheck: - test: redis-cli ping | grep PONG - interval: 30s - timeout: 10s - retries: 3 -#+END_SRC +AudioMuse-AI performs sonic analysis on music files to auto-generate +playlists for Jellyfin. Runs as Flask app + RQ worker, connects to +existing PostgreSQL and Valkey. Routes through Gluetun VPN. -** Remaining Services +#+BEGIN_SRC yaml :tangle /docker/compose/services/audiomuse.yaml + 1322| + 1323|* .env Configuration + 1324| + 1325|The =.env= file at =/docker/compose/.env= holds all variable values. + 1326|The =:?err= suffix on every variable ensures missing values fail fast. + 1327| + 1328|Key variables: + 1329|- =DOCKER_SUBNET= and =DOCKER_GATEWAY= define the Docker bridge network + 1330|- =CLOUDFLARE_DNS_ZONE= (=gharbeia.net=) is used in all Traefik routes + 1331|- =PUID= and =PGID= control file ownership (1000:1000) + 1332|- =FOLDER_FOR_MEDIA= and =FOLDER_FOR_MORE= — Media library root paths + 1333|- =TUNNEL_TOKEN= is the Cloudflare tunnel auth token (managed externally) + 1334|- =TA_USERNAME= and =TA_PASSWORD= — Tube Archivist admin credentials + 1335| + 1336|* LOGBOOK + 1337| + 1338|** [2026-05-16 Sat 22:45] Tube Archivist installed + 1339|- 3-container stack: tubearchivist, ES 8.17, Redis + 1340|- Traefik secureweb/tunnel/internal routers + 1341|- Static TA_HOST=tubearchivist.gharbeia.net, ELASTIC_PASSWORD=tubearchivist + 1342|- REDIS_CON connection string (newer TA uses this instead of REDIS_HOST+REDIS_PORT) + 1343|- ES 8.17 with path_repo and xpack.security.enabled=false -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 -- =tubearchivist.yaml= — YouTube archiving (Tube Archivist) -- =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) -- =FOLDER_FOR_MEDIA= and =FOLDER_FOR_MORE= — Media library root paths -- =TUNNEL_TOKEN= is the Cloudflare tunnel auth token (managed externally) -- =TA_USERNAME= and =TA_PASSWORD= — Tube Archivist admin credentials - -* LOGBOOK - -** [2026-05-16 Sat 22:45] Tube Archivist installed -- 3-container stack: tubearchivist, ES 8.17, Redis -- Traefik secureweb/tunnel/internal routers -- Static TA_HOST=tubearchivist.gharbeia.net, ELASTIC_PASSWORD=tubearchivist -- REDIS_CON connection string (newer TA uses this instead of REDIS_HOST+REDIS_PORT) -- ES 8.17 with path_repo and xpack.security.enabled=false - -** [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow -- Root cause: Jellyfin's empty KnownProxies caused SSO plugin to use HTTP - base URL, breaking the JavaScript two-step auth flow (iframe/POST/redirect) -- Fix: Added 172.28.10.0/24 and 172.28.10.4 to Jellyfin's KnownProxies -- Created jellyfin_admin Authentik group + linked user amr to it -- Set EnableAuthorization=false in SSO-Auth plugin config -- Documented three authentication mechanisms: Forward Auth, OIDC/SSO, local users -- Fixed ASCII tree diagrams by wrapping in #+BEGIN_EXAMPLE blocks -- Fixed trees rendering issue: Unicode box-drawing chars must be inside - example blocks in org-mode, otherwise font rendering mangles them - -** [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: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 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= - -** [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 +** [2026-05-17 Sun 17:00] Tube Archivist: gluetun routing, CSRF fix, download path +- TA moved to network_mode: service:gluetun (port 8000 mapped on gluetun) +- TA_HOST changed to https:// prefix to fix CSRF on POST /api/playlist/ +- REDIS_CON fixed (was corrupted with embedded Traefik labels) +- Download folder changed to ${FOLDER_FOR_MORE}/media/youtube +- Stash got ${FOLDER_FOR_MORE}:/more volume mount +- Jellyfin TubeArchivist plugin configured (http://localhost:8000, user amr) +- Backup files moved to /docker/compose/off/ +- Critical: recreating gluetun orphans all network_mode: service:gluetun containers + (jellyfin, *arr, sabnzbd, etc.) — ALL must be recreated after any gluetun change + 1344| + 1345|** [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow + 1346|- Root cause: Jellyfin's empty KnownProxies caused SSO plugin to use HTTP + 1347| base URL, breaking the JavaScript two-step auth flow (iframe/POST/redirect) + 1348|- Fix: Added 172.28.10.0/24 and 172.28.10.4 to Jellyfin's KnownProxies + 1349|- Created jellyfin_admin Authentik group + linked user amr to it + 1350|- Set EnableAuthorization=false in SSO-Auth plugin config + 1351|- Documented three authentication mechanisms: Forward Auth, OIDC/SSO, local users + 1352|- Fixed ASCII tree diagrams by wrapping in #+BEGIN_EXAMPLE blocks + 1353|- Fixed trees rendering issue: Unicode box-drawing chars must be inside + 1354| example blocks in org-mode, otherwise font rendering mangles them + 1355| + 1356|** [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth + 1357|- Fixed Emacs org-loaddefs.el path in tangle-deploy + 1358|- Created Gitea access token for git operations + 1359|- Replaced Gitea Action workflow with systemd timer + 1360|- tangle-deploy now pulls, tangles, and restarts on a 5-minute timer + 1361| + 1362|** [2026-05-15 Thu 06:50] Monolith split into modular compose + 1363|- 42 service fragments created under /docker/compose/services/ + 1364|- Master docker-compose.yaml uses include: directives (43 services total) + 1365|- All service labels and env vars preserved from original monolith + 1366|- Compose validated with --env-file .env, all 43 services resolve + 1367|- Deployment verified: all containers running + 1368|- Orphaned unbound container absorbed into compose (was started manually) + 1369| + 1370|** [2026-05-15 Thu 03:47] Literate infrastructure established + 1371|- infrastructure.org becomes the source of truth -- all config files are + 1372| tangle targets embedded as =#+BEGIN_SRC= blocks with absolute paths + 1373|- =tangle-deploy= script installed at =/usr/local/bin/tangle-deploy= on + 1374| production-1; run after git push to regenerate configs and restart services + 1375|- Gitea repo: =git@git.gharbeia.net:amr/infrastructure.git= + 1376| + 1377|** [2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner + 1378|- Created internal entrypoint on port 8083 for service-to-service traffic + 1379|- Updated Gitea runner URL to use internal entrypoint + 1380|- Documented three-path architecture + 1381| + 1382|** [2026-05-15 Thu 02:56] Static site and Error 1033 fix + 1383|- Added gharbeia-site nginx container for root domain + 1384|- Fixed CNAME record for bare domain pointing to correct tunnel + 1385| + 1386|** [2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org + 1387|- Configured Jellyfin SSO-Auth plugin with Authentik OIDC + 1388|- Removed Forward Auth from Jellyfin Traefik labels + 1389|- Created infrastructure.org as source of truth + 1390|- Added Forward Auth to internal LAN routers + 1391| \ No newline at end of file