Files
infrastructure/infrastructure.org

54 KiB
Raw Blame History

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:<port> instead of http://servicename:<port>. 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| <KnownProxies> 792| <string>172.28.10.0/24</string> 793| <string>172.28.10.4</string> 794| </KnownProxies> 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

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|<?xml version="1.0" encoding="utf-8"?> 903|<NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> 904| <BaseUrl /> 905| <EnableHttps>false</EnableHttps> 906| <RequireHttps>false</RequireHttps> 907| <CertificatePath /> 908| <CertificatePassword /> 909| <InternalHttpPort>8096</InternalHttpPort> 910| <InternalHttpsPort>8920</InternalHttpsPort> 911| <PublicHttpPort>8096</PublicHttpPort> 912| <PublicHttpsPort>8920</PublicHttpsPort> 913| <AutoDiscovery>true</AutoDiscovery> 914| <EnableUPnP>true</EnableUPnP> 915| <EnableIPv4>true</EnableIPv4> 916| <EnableIPv6>false</EnableIPv6> 917| <EnableRemoteAccess>true</EnableRemoteAccess> 918| <LocalNetworkSubnets /> 919| <LocalNetworkAddresses /> 920| <KnownProxies> 921| <string>172.28.10.0/24</string> 922| <string>172.28.10.4</string> 923| </KnownProxies> 924| <IgnoreVirtualInterfaces>true</IgnoreVirtualInterfaces> 925| <VirtualInterfaceNames> 926| <string>veth</string> 927| </VirtualInterfaceNames> 928| <EnablePublishedServerUriByRequest>false</EnablePublishedServerUriByRequest> 929| <PublishedServerUriBySubnet /> 930| <RemoteIPFilter /> 931| <IsRemoteIPFilterBlacklist>false</IsRemoteIPFilterBlacklist> 932|</NetworkConfiguration> 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

labels: 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

AudioMuse-AI — Sonic Playlist Generator

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.

services:
  audiomuse-ai:
    image: ghcr.io/neptunehub/audiomuse-ai:latest
    container_name: audiomuse-ai
    restart: unless-stopped
    networks:
      - networking
    ports:
      - ${WEBUI_PORT_AUDIOMUSE:-8005}:8000
    environment:
      SERVICE_TYPE: "flask"
      TZ: ${TIMEZONE:?err}
      POSTGRES_USER: ${POSTGRESQL_USERNAME:?err}
      POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?err}
      POSTGRES_DB: audiomusedb
      POSTGRES_HOST: postgresql
      POSTGRES_PORT: "5432"
      REDIS_URL: redis://valkey:6379/0
      TEMP_DIR: /app/temp_audio
      FRONTEND_PORT: "8000"
    volumes:
      - ${FOLDER_FOR_MEDIA:?err}:/library
      - ${FOLDER_FOR_MORE:?err}:/more
      - temp-audio-flask:/app/temp_audio
    labels:
      - traefik.enable=true
      - traefik.http.routers.audiomuse.service=audiomuse
      - traefik.http.routers.audiomuse.rule=Host(`audiomuse.${CLOUDFLARE_DNS_ZONE:?err}`)
      - traefik.http.routers.audiomuse.entrypoints=tunnel
      - traefik.http.routers.audiomuse.middlewares=authentik-forwardauth@file,security-headers@file,traefik-bouncer@file
      - traefik.http.services.audiomuse.loadbalancer.server.scheme=http
      - traefik.http.services.audiomuse.loadbalancer.server.port=${WEBUI_PORT_AUDIOMUSE:-8005}

  audiomuse-worker:
    image: ghcr.io/neptunehub/audiomuse-ai:latest
    container_name: audiomuse-worker
    restart: unless-stopped
    networks:
      - networking
    environment:
      SERVICE_TYPE: "worker"
      TZ: ${TIMEZONE:?err}
      POSTGRES_USER: ${POSTGRESQL_USERNAME:?err}
      POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?err}
      POSTGRES_DB: audiomusedb
      POSTGRES_HOST: postgresql
      POSTGRES_PORT: "5432"
      REDIS_URL: redis://valkey:6379/0
      TEMP_DIR: /app/temp_audio
    volumes:
      - ${FOLDER_FOR_MEDIA:?err}:/library
      - ${FOLDER_FOR_MORE:?err}:/more
      - temp-audio-worker:/app/temp_audio

volumes:
  temp-audio-flask:
  temp-audio-worker:

[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|