From 54827a4256498c8ffa1a4b4252bbc50edae780c9 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 6 Jun 2026 12:27:21 -0400 Subject: [PATCH] infrastructure: use __CROWDSEC_LAPI_KEY__ placeholder in dynamic.yaml tangle block --- infrastructure.org | 2694 +++++++++++++++++++++++--------------------- 1 file changed, 1385 insertions(+), 1309 deletions(-) diff --git a/infrastructure.org b/infrastructure.org index 96d290f..179acc2 100644 --- a/infrastructure.org +++ b/infrastructure.org @@ -1,1237 +1,1232 @@ - 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| # -- Brain Knowledge Base (private, behind Authentik) ------------ - 506| - 507| brain: - 508| rule: "Host(`brain.gharbeia.net`)" - 509| service: brain-internal - 510| entryPoints: - 511| - secureweb - 512| tls: - 513| certResolver: letsencrypt - 514| middlewares: - 515| - authentik-forwardauth@file - 516| - security-headers@file - 517| - 518| # -- 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://10.10.10.29:8083 - 700| brain-internal: - 701| loadBalancer: - 702| servers: - 703| - url: "http://10.10.10.29:8082" - 704| 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 +#+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/appdata/traefik/traefik.yaml ] || \ + [ -f /docker/appdata/traefik/internal.yaml ] || \ + [ -f /docker/appdata/traefik/internal-noauth.yaml ] || \ + [ -f /docker/appdata/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. +** Tangle Destination Convention + +Each tangle target in this file must go to the directory where the +consuming process reads it. The convention is: + +- Traefik config files (static, dynamic, internal, internal-noauth) go to + =/docker/appdata/traefik/= because that directory is bind-mounted into the + Traefik container at =/etc/traefik=. +- =docker-compose.yaml= and service fragments go to =/docker/compose/= because + docker compose reads from there. +- =tangle-deploy.sh= stays in =/docker/compose/infrastructure/= alongside this + file so the Git repo is self-contained. + +Previously these paths pointed to =/docker/compose/= for the Traefik configs. +The tangle-deploy pipeline ran and claimed success, but the generated files were +never read by Traefik. The running configs in =/docker/appdata/traefik/= were +hand-edited copies that drifted from the org. This was discovered when adding +the =tunnel= entrypoint to the internal routers: the edit propagated to the +tangled file in =/docker/compose/=, but Traefik kept serving the stale +hand-edited copy in =/docker/appdata/traefik/=. + +* 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/appdata/traefik/traefik.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/appdata/traefik/dynamic.yaml +http: + middlewares: + + authentik-forwardauth: + forwardAuth: + address: http://authentik: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/appdata/traefik/internal.yaml +http: + routers: + + + authentik-outpost: + rule: "HostRegexp(`.{1,}.gharbeia.net`) && PathPrefix(`/outpost.goauthentik.io/`)" + service: authentik-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - security-headers@file + + # -- Media & Streaming ----------------------------------------- + + jellyfin: + rule: "Host(`jellyfin.gharbeia.net`)" + service: jellyfin-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + jellyseerr: + rule: "Host(`jellyseerr.gharbeia.net`)" + service: jellyseerr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + # -- *arr Suite ------------------------------------------------- + + radarr: + rule: "Host(`radarr.gharbeia.net`)" + service: radarr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + sonarr: + rule: "Host(`sonarr.gharbeia.net`)" + service: sonarr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + lidarr: + rule: "Host(`lidarr.gharbeia.net`)" + service: lidarr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + prowlarr: + rule: "Host(`prowlarr.gharbeia.net`)" + service: prowlarr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + whisparr: + rule: "Host(`whisparr.gharbeia.net`)" + service: whisparr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + mylar: + rule: "Host(`mylar.gharbeia.net`)" + service: mylar-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + lazylibrarian: + rule: "Host(`lazylibrarian.gharbeia.net`)" + service: lazylibrarian-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + # -- Downloaders ------------------------------------------------ + + sabnzbd: + rule: "Host(`sabnzbd.gharbeia.net`)" + service: sabnzbd-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + qbittorrent: + rule: "Host(`qbittorrent.gharbeia.net`)" + service: qbittorrent-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + flaresolverr: + rule: "Host(`flaresolverr.gharbeia.net`)" + service: flaresolverr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + # -- Homepage / Dashboards -------------------------------------- + + homepage: + rule: "Host(`homepage.gharbeia.net`)" + service: homepage-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + homarr: + rule: "Host(`homarr.gharbeia.net`)" + service: homarr-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + heimdall: + rule: "Host(`heimdall.gharbeia.net`)" + service: heimdall-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + # -- Monitoring ------------------------------------------------ + + grafana: + rule: "Host(`grafana.gharbeia.net`)" + service: grafana-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + prometheus: + rule: "Host(`prometheus.gharbeia.net`)" + service: prometheus-internal + entryPoints: + - tunnel + - secureweb + 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: + - tunnel + - secureweb + + # -- Brain Knowledge Base (private, behind Authentik) ------------ + + brain: + rule: "Host(`brain.gharbeia.net`)" + service: brain-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + + # -- Management ------------------------------------------------ + + gitea: + rule: "Host(`git.gharbeia.net`)" + service: gitea-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - security-headers@file + - traefik-bouncer@file + # No authentik-forwardauth -- Gitea has native OIDC + + portainer: + rule: "Host(`portainer.gharbeia.net`)" + service: portainer-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + authentik: + rule: "Host(`auth.gharbeia.net`)" + service: authentik-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - security-headers@file + - traefik-bouncer@file + # No authentik-forwardauth -- otherwise auth loops + + headscale: + rule: "Host(`headscale.gharbeia.net`)" + service: headscale-internal + entryPoints: + - tunnel + - secureweb + 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: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + ddns-updater: + rule: "Host(`ddns-updater.gharbeia.net`)" + service: ddns-updater-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + audiobookshelf: + rule: "Host(`audiobookshelf.gharbeia.net`)" + service: audiobookshelf-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + guacamole: + rule: "Host(`guacamole.gharbeia.net`)" + service: guacamole-internal + entryPoints: + - tunnel + - secureweb + middlewares: + - authentik-forwardauth@file + - security-headers@file + - traefik-bouncer@file + + tubearchivist: + rule: "Host(`tubearchivist.gharbeia.net`)" + service: tubearchivist-internal + entryPoints: + - tunnel + - secureweb + 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: + - tunnel + - secureweb + 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://gluetun:8080 + qbittorrent-internal: + loadBalancer: + servers: + - url: http://gluetun: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://10.10.10.29:8083 + brain-internal: + loadBalancer: + servers: + - url: "http://10.10.10.29:8082" + 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:80 # standalone, no longer behind gluetun 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 # standalone, no longer behind gluetun on port 8000 inside gluetun +#+END_SRC + +All 28 routers follow the same pattern. The service URLs point to Docker DNS +names on the =networking= bridge. +Services that need VPN routing (sabnzbd, qbittorrent) use +=network_mode: service:gluetun= and are reached via =http://gluetun:=. +Services that DO NOT need the VPN (jellyfin, sonarr, radarr, etc.) run +standalone on the bridge and are reached via =http://servicename:=. +This split was made because the VPN is a single point of failure — when +gluetun goes down, only actual downloaders are affected, not the library +management or streaming services. +** 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/appdata/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 - services/audiobookshelf.yaml - services/audiomuse.yaml - services/whisparr.yaml - 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 +#+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 download clients. Only qbittorrent (torrents) +and sabnzbd (usenet) actually need VPN routing to protect download privacy. +All other media services (jellyfin, sonarr, radarr, *arr suite, stash, etc.) +run standalone on the =networking= bridge without the VPN. + +Originally, nearly every media service was routed through gluetun for +simplicity — all traffic behind one tunnel. This created a single point of +failure: when gluetun crashed or lost its VPN connection, the entire media +stack went down with it. Services like jellyfin (which never downloads +anything) had no business being behind the VPN. + +After a gluetun crash-loop in June 2026 that took down the whole stack for +over an hour, we split the architecture: downloaders stay on the VPN, all +other services run standalone. This means a VPN outage only affects active +downloads, not library browsing, requests, or streaming. + +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_SABNZBD:?err}:8080 + - ${WEBUI_PORT_QBITTORRENT:?err}:${WEBUI_PORT_QBITTORRENT:?err} + - ${QBIT_PORT:?err}:6881 + 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-tunnel.rule=Host(`auth.${CLOUDFLARE_DNS_ZONE:?err}`) + - traefik.http.routers.authentik-tunnel.entrypoints=tunnel,secureweb + - traefik.http.routers.authentik-tunnel.middlewares=tunnel-headers@file,security-headers@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.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 routes through Gluetun VPN to avoid YouTube geo-blocking. + +#+BEGIN_SRC yaml :tangle /docker/compose/services/tubearchivist.yaml services: tubearchivist: image: bbilly1/tubearchivist:latest @@ -1260,7 +1255,7 @@ services: - 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.routers.tubearchivist.middlewares=authentik-forwardauth@file,security-headers@file - traefik.http.services.tubearchivist.loadbalancer.server.scheme=http - traefik.http.services.tubearchivist.loadbalancer.server.port=8000 @@ -1298,35 +1293,35 @@ services: timeout: 10s retries: 3 #+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 + +** 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 +- =tubearchivist.yaml= — YouTube archiving (Tube Archivist) +- =audiobookshelf.yaml= — Audiobook and podcast server ** AudioMuse-AI — Sonic Playlist Generator @@ -1394,6 +1389,54 @@ volumes: temp-audio-worker: #+END_SRC +** Khaled Fahmy — WordPress Blog (khaledfahmy.org) + +khaledfahmy.org is a WordPress site hosted on production-1 in a +three-container stack: nginx (khaledfahmy-web), PHP-FPM (khaledfahmy-php), +and MariaDB (khaledfahmy-db). All containers are on the =networking= bridge. + +Architecture decisions: + +- **Traffic flow**: Cloudflare Tunnel → Traefik (tunnel entrypoint :8081) + → nginx → PHP-FPM. No direct LAN/internal access (no secureweb router). + All TLS is handled by Cloudflare; nginx sees plain HTTP internally. + +- **HTTPS detection**: WordPress needs to know it's behind HTTPS even though + nginx receives plain HTTP from the tunnel. The tunnel-headers Traefik + middleware sets =X-Forwarded-Proto: https=, and the nginx config passes + this as a FastCGI param. wp-config.php also has =$_SERVER['HTTPS'] = 'on'= + as a fallback so =is_ssl()= returns true. + +- **Loopback support**: WordPress site health tests make HTTP requests to + itself (=https://khaledfahmy.org/=). Since the site is accessed through + Cloudflare's external DNS, internal loopbacks would resolve to the public + IP and fail. The PHP container has a =/etc/hosts= entry pointing + =khaledfahmy.org= to the nginx container's internal IP, and nginx has a + self-signed SSL cert on port 443 so the loopback HTTPS connection succeeds. + +- **PHP-FPM tuning**: =pm.max_children= was increased from 5 to 15 to + prevent worker exhaustion during loopback tests and cron runs. The + =www-data= user UID was changed from 82 to 33 to match the file ownership + on the mounted volume, fixing permission errors for uploads and updates. + +- **Auto-updates**: =WP_AUTO_UPDATE_CORE= and =DISALLOW_FILE_MODS= constants + were removed from wp-config.php so WordPress can apply security updates + automatically. The =WP_DEBUG= constant is set to =false= in production + (was accidentally left on from the Docker env block). + +- **File permissions**: The =/var/www/html= mount in the PHP container is + writable (no =ro=). The nginx container mount is read-only (=ro=). + +- **Container UIDs**: Files on the host are owned by uid 33, which is the + standard www-data uid on Debian/Ubuntu. The Alpine-based PHP image has + www-data at uid 82 by default. The UID was remapped with =usermod= to + match, so the PHP-FPM worker (running as www-data) can write to + wp-content/uploads, upgrade-temp-backup, and other writable directories. + +Compose YAML: =/docker/compose/services/khaledfahmy-site.yaml= +Nginx config: =/docker/appdata/khaledfahmy-site/nginx/wordpress.conf= +PHP ini: =/docker/appdata/khaledfahmy-site/php/php.ini= +wp-config: =/docker/appdata/khaledfahmy-site/html/wp-config.php= ** [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) @@ -1405,51 +1448,84 @@ volumes: - 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 + +** [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-06-02 Tue] Gluetun port cleanup — only downloaders behind VPN +- Removed 20+ stale port mappings from gluetun (services migrated off VPN) +- Updated all internal router URLs: standalone services now referenced by + their own container names instead of http://gluetun: +- Documented the architectural decision: only qbittorrent + sabnzbd need + VPN; media management, streaming, and indexing services run standalone +- Added =Khaled Fahmy= section documenting the WordPress stack config + +** Tunnel Entrypoint — External Routes + +Services that are not Docker containers (external machines, LXCs) need +file provider routes for the tunnel entrypoint. The backend at 10.10.10.29 +is a separate LXC hosting the Hugo-generated brain site. + +#+BEGIN_SRC yaml :tangle /docker/appdata/traefik/tunnel-routes.yaml +http: + routers: + brain-tunnel: + rule: "Host(`brain.gharbeia.net`)" + entryPoints: + - tunnel + service: brain-tunnel-svc + middlewares: + - tunnel-headers@file + + services: + brain-tunnel-svc: + loadBalancer: + servers: + - url: "http://10.10.10.29:8082" +#+END_SRC + service: http://10.10.10.29:8082 + middleware: tunnel-headers@file