69 KiB
Infrastructure Documentation — gharbeia.net
- Architecture
- Traefik — Reverse Proxy
- Static Configuration
- Dynamic Configuration — Middleware
- Internal Routers — Authenticated (secureweb :443)
- Internal Routers — No Auth (internal :8083)
- Authentication Architecture
- Master Compose
- Jellyfin — Media Server
- Gluetun — VPN Client
- Authentik — Identity Provider
- Gitea — Git Hosting
- Infrastructure Services
- Tube Archivist — YouTube Archiving
- qBittorrent — Torrent Client
- Remaining Services
- AudioMuse-AI — Sonic Playlist Generator
- Khaled Fahmy — WordPress Blog (khaledfahmy.org)
- [2026-05-17 Sun 17:00] Tube Archivist: gluetun routing, CSRF fix, download path
- [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow
- [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth
- [2026-05-15 Thu 06:50] Monolith split into modular compose
- [2026-05-15 Thu 03:47] Literate infrastructure established
- [2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner
- [2026-05-15 Thu 02:56] Static site and Error 1033 fix
- [2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org
- [2026-06-06 Sat] Systemd tangle-deploy fixed — ob-shell, chmod, OIDC proxy
- [2026-06-02 Tue] Gluetun port cleanup — only downloaders behind VPN
- Tunnel Entrypoint — External Routes
- Environment Variables
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
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
Internal Access Architecture
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
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.
#!/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
chmod +x "$0"
echo "=== Tangling $ORG_FILE ==="
emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \
--eval "(require 'org)" \
--eval "(require 'ob-shell)" \
--eval '(let ((org-confirm-babel-evaluate nil)) (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 /etc/systemd/system/oidc-proxy.service ]; then
echo 'Systemd unit changed -- reloading...'
systemctl daemon-reload
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 ==='
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.yamland service fragments go to/docker/compose/because docker compose reads from there.tangle-deploy.shstays 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.
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
forwardedHeaders:
insecure: true
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
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 bothgharbeia.netand*.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.dnsChallengewith 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.
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_key()>>
crowdsecLapiHost: crowdsec:8080
crowdsecLapiScheme: http
updateFrequencySec: 5
defaultDecisionLifetimeSec: 60
compress:
compress:
excludedContentTypes:
- text/event-stream
ratelimit:
rateLimit:
average: 100
burst: 50
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.
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
tdarr:
rule: "Host(`tdarr.gharbeia.net`)"
service: tdarr-internal
entryPoints:
- tunnel
- secureweb
middlewares:
- authentik-forwardauth@file
- security-headers@file
- traefik-bouncer@file
stash:
rule: "Host(`stash.gharbeia.net`)"
service: stash-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
bazarr:
rule: "Host(`bazarr.gharbeia.net`)"
service: bazarr-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
unpackerr:
rule: "Host(`unpackerr.gharbeia.net`)"
service: unpackerr-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
audiomuse:
rule: "Host(`audiomuse.gharbeia.net`)"
service: audiomuse-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
audiomuse-internal:
loadBalancer:
servers:
- url: http://audiomuse-ai:8000
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
bazarr-internal:
loadBalancer:
servers:
- url: http://bazarr:6767
tdarr-internal:
loadBalancer:
servers:
- url: http://tdarr:8265
stash-internal:
loadBalancer:
servers:
- url: http://stash:7777
unpackerr-internal:
loadBalancer:
servers:
- url: http://unpackerr:5656
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:<port>.
Services that DO NOT need the VPN (jellyfin, sonarr, radarr, etc.) run
standalone on the bridge and are reached via http://servicename:<port>.
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.
# 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.
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.
Critical manual fix: The OIDC auto-discovery URL uses the internal Docker hostname (
http://authentik:9000/application/o/gitea-oidc/...). This is correct for Gitea's backend (token/userinfo endpoints) but Authentik's discovery response returnshttp://authentik:9000/...forauthorization_endpoint, which the browser cannot resolve.To fix: add
CustomURLMapping.auth_urlin Gitea'slogin_sourcetable so the redirect uses the public URL:UPDATE login_source SET cfg = json_set(cfg, '$.CustomURLMapping', json_object('auth_url', 'https://auth.gharbeia.net/application/o/gitea-oidc/authorize')) WHERE id = 1;This change is in Gitea's SQLite database at
/data/gitea/gitea.db, not managed by tangling. It survives container rebuilds because the database volume persists. To verify:SELECT json_extract(cfg, '$.CustomURLMapping') FROM login_source WHERE id=1;should show the mapping. Restart Gitea after updating (docker restart gitea). -
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:- OIDC callback returns an HTML page with JavaScript + authorization state
- JavaScript POSTs to
/sso/OID/Auth/Authentikto complete the login
Critical detail: Jellyfin must trust Traefik's
X-Forwarded-Protoheader or the JavaScript will construct URLs withhttp://instead ofhttps://. This is configured viaKnownProxiesin/config/network.xml:<KnownProxies> <string>172.28.10.0/24</string> <string>172.28.10.4</string> </KnownProxies>Without this,
GetRequestBase()returnshttp://jellyfin.gharbeia.netand 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:
- Each service is self-contained with its own prose documentation
- Adding or removing a service is a single line in the master compose
- 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.
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
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.
<?xml version="1.0" encoding="utf-8"?>
<NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<BaseUrl />
<EnableHttps>false</EnableHttps>
<RequireHttps>false</RequireHttps>
<CertificatePath />
<CertificatePassword />
<InternalHttpPort>8096</InternalHttpPort>
<InternalHttpsPort>8920</InternalHttpsPort>
<PublicHttpPort>8096</PublicHttpPort>
<PublicHttpsPort>8920</PublicHttpsPort>
<AutoDiscovery>true</AutoDiscovery>
<EnableUPnP>true</EnableUPnP>
<EnableIPv4>true</EnableIPv4>
<EnableIPv6>false</EnableIPv6>
<EnableRemoteAccess>true</EnableRemoteAccess>
<LocalNetworkSubnets />
<LocalNetworkAddresses />
<KnownProxies>
<string>172.28.10.0/24</string>
<string>172.28.10.4</string>
</KnownProxies>
<IgnoreVirtualInterfaces>true</IgnoreVirtualInterfaces>
<VirtualInterfaceNames>
<string>veth</string>
</VirtualInterfaceNames>
<EnablePublishedServerUriByRequest>false</EnablePublishedServerUriByRequest>
<PublishedServerUriBySubnet />
<RemoteIPFilter />
<IsRemoteIPFilterBlacklist>false</IsRemoteIPFilterBlacklist>
</NetworkConfiguration>
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_hostsresolves *.gharbeia.net to 10.10.10.201 so VPN-routed services can reach Traefik without leaking DNS- The
FIREWALL_OUTBOUND_SUBNETSallows LAN access through the VPN
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
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, policiesauthentic-worker— background tasks, outpost management
Both connect to the same Postgres and Valkey databases.
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
Gitea — Git Hosting
Gitea hosts the infrastructure repo and personal projects at
git.gharbeia.net. SSH on port 2222, web UI via Traefik Tunnel.
Requires GITEA__webhook__ALLOWED_HOST_LIST=private to allow webhook
delivery to the auto-deploy listener on the Docker bridge gateway.
The runner connects via the authless internal entrypoint (:8083) so it can
services:
gitea:
image: docker.gitea.com/gitea:1.25.5
container_name: gitea
restart: always
networks:
- networking
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__webhook__ALLOWED_HOST_LIST=private
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
Gitea OIDC — Discovery Rewrite Proxy
Gitea's OIDC provider uses Authentik's auto-discovery URL to fetch
endpoint metadata. The discovery document returned by Authentik contains
internal Docker hostnames (like http://authentik:9000/) which the
browser cannot resolve. Gitea's openidConnect provider in v1.25.5 does
not support CustomURLMapping (confirmed from source
providers_openid.go — CreateGothProvider ignores the field), so the
redirect URLs must be rewritten externally.
The fix is a small HTTP proxy at 172.28.10.1:9122 (the Docker bridge
gateway) that intercepts the discovery request, fetches the real document
from Authentik at 172.28.10.33:9000, and rewrites all internal URLs to
https://auth.gharbeia.net.
The proxy runs as a systemd service on production-1:
[Unit]
Description=OIDC discovery rewrite proxy for Gitea
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /docker/compose/oidc-rewrite-proxy.py
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
The proxy lives at /docker/compose/oidc-rewrite-proxy.py on production-1:
#!/usr/bin/env python3
"""Minimal OIDC discovery rewrite proxy for Gitea's openidConnect provider.
Listens on 0.0.0.0:9122. Rewrites internal Authentik URLs to the public
HTTPS URL so Gitea redirects browsers to auth.gharbeia.net.
"""
import json
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import urlopen, Request
UPSTREAM = "http://172.28.10.33:9000"
PUBLIC = "https://auth.gharbeia.net"
DISCOVERY_PATH = "/application/o/gitea-oidc/.well-known/openid-configuration"
class RewriteHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != DISCOVERY_PATH:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found")
return
try:
upstream_url = f"{UPSTREAM}{DISCOVERY_PATH}"
req = Request(upstream_url, headers={"Accept": "application/json"})
with urlopen(req, timeout=5) as resp:
body = resp.read().decode()
body = body.replace(UPSTREAM, PUBLIC)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
except Exception as e:
self.send_response(502)
self.end_headers()
self.wfile.write(f"Proxy error: {e}".encode())
def log_message(self, format, *args):
pass
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 9122), RewriteHandler)
print("OIDC rewrite proxy listening on :9122", flush=True)
server.serve_forever()
The systemd service is tangled from the block above. After deploying, enable
with systemctl enable --now oidc-proxy.
Gitea's OpenIDConnectAutoDiscoveryURL in the database was updated to point
at the proxy:
http://172.28.10.1:9122/application/o/gitea-oidc/.well-known/openid-configuration
Infrastructure Services
Core data and networking services that everything depends on.
Postgresql
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}
Valkey (Redis Alternative)
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
Unbound — DNS Resolver
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
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 8000tubearchivist-es— Elasticsearch 8.17 for metadata + searchtubearchivist-redis— Redis for Celery task queue
Tube Archivist routes through Gluetun VPN to avoid YouTube geo-blocking.
services:
tubearchivist:
image: bbilly1/tubearchivist:latest
container_name: tubearchivist
restart: unless-stopped
depends_on:
gluetun:
condition: service_healthy
restart: true
network_mode: service:gluetun
volumes:
- ${FOLDER_FOR_MORE:?err}/media/youtube:/youtube
- ${FOLDER_FOR_DATA:?err}/tubearchivist/cache:/cache
environment:
- TZ=${TIMEZONE:?err}
- TA_USERNAME=${TA_USERNAME:?err}
- TA_PASSWORD=${TA_PASSWORD:?err}
- ES_URL=http://tubearchivist-es:9200
- REDIS_CON=redis://tubearchivist-redis:6379
- TA_HOST=https://tubearchivist.gharbeia.net
- ELASTIC_PASSWORD=tubearchivist
- HOST_UID=${PUID:?err}
- HOST_GID=${PGID:?err}
labels:
- traefik.enable=true
- traefik.http.routers.tubearchivist.service=tubearchivist
- traefik.http.routers.tubearchivist.rule=Host(`tubearchivist.${CLOUDFLARE_DNS_ZONE:?err}`)
- traefik.http.routers.tubearchivist.entrypoints=tunnel
- traefik.http.routers.tubearchivist.middlewares=authentik-forwardauth@file,security-headers@file
- traefik.http.services.tubearchivist.loadbalancer.server.scheme=http
- traefik.http.services.tubearchivist.loadbalancer.server.port=8000
tubearchivist-es:
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
container_name: tubearchivist-es
restart: unless-stopped
networks:
- networking
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms512m -Xmx512m
- xpack.security.enabled=false
- path.repo=/usr/share/elasticsearch/data/snapshot
volumes:
- ${FOLDER_FOR_DATA:?err}/tubearchivist/es:/usr/share/elasticsearch/data
healthcheck:
test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"'
interval: 30s
timeout: 10s
retries: 3
tubearchivist-redis:
image: redis:7-alpine
container_name: tubearchivist-redis
restart: unless-stopped
networks:
- networking
command: --save 60 1 --loglevel warning
volumes:
- ${FOLDER_FOR_DATA:?err}/tubearchivist/redis:/data
healthcheck:
test: redis-cli ping | grep PONG
interval: 30s
timeout: 10s
retries: 3
qBittorrent — Torrent Client
qBittorrent downloads via BitTorrent, routed through Gluetun VPN. Web UI
at port 8200. Theme.park styling via DOCKER_MODS. Uses Gluetun's network
namespace so other *arr services reach it at gluetun:8200.
services:
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
restart: unless-stopped
depends_on:
gluetun:
condition: service_healthy
restart: true
healthcheck:
test: ["CMD", "sh", "-c", "cat /proc/net/route | grep -q tun0 || exit 1"]
interval: 30s
retries: 2
start_period: 10s
volumes:
- ${FOLDER_FOR_MEDIA:?err}:/library
- ${FOLDER_FOR_DATA:?err}/qbittorrent:/config
environment:
- PUID=${PUID:?err}
- PGID=${PGID:?err}
- UMASK=${UMASK:?err}
- TZ=${TIMEZONE:?err}
- WEBUI_PORT=${WEBUI_PORT_QBITTORRENT:?err}
- QBT_PROFILE=/config
- DOCKER_MODS=ghcr.io/themepark-dev/theme.park:qbittorrent
- TP_THEME=${TP_THEME:?err}
network_mode: service:gluetun
labels:
- traefik.enable=true
- traefik.http.routers.qbittorrent.service=qbittorrent
- traefik.http.routers.qbittorrent.rule=Host(`qbittorrent.${CLOUDFLARE_DNS_ZONE:?err}`)
- traefik.http.routers.qbittorrent.middlewares=authentik-forwardauth@file,security-headers@file
- traefik.http.services.qbittorrent.loadbalancer.server.scheme=http
- traefik.http.services.qbittorrent.loadbalancer.server.port=${WEBUI_PORT_QBITTORRENT:?err}
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 servicescrowdsec.yaml— Intrusion prevention (blocks malicious IPs via Traefik bouncer)cloudflared.yaml— Cloudflare Tunnel clientgharbeia-site.yaml— Static website via nginxhomepage.yaml, homarr.yaml, heimdall.yaml— Dashboard UIsgrafana.yaml, prometheus.yaml— Monitoring stackheadscale.yaml, tailscale.yaml, headplane.yaml— Wireguard mesh VPNddns-updater.yaml— Dynamic DNSportainer.yaml— Docker GUIguacamole.yaml, guacd.yaml— Remote desktop gatewayunpackerr.yaml— Archive extraction for *arr downloadsrunner.yaml— Gitea Actions runnerbazarr.yaml, flaresolverr.yaml— Subtitle downloader, Cloudflare bypassjellyfin.yaml, jellyseerr.yaml— Media server + request managerlazylibrarian.yaml, lidarr.yaml, mylar.yaml— Ebook, music, comic managersprowlarr.yaml, radarr.yaml, sonarr.yaml, whisparr.yaml— *arr indexer + library managerssabnzbd.yaml— Usenet clientstash.yaml— Adult content library managertdarr.yaml, tdarr-node.yaml— Media transcoding automationtubearchivist.yaml— YouTube archiving (Tube Archivist)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. On the bridge network (not gluetun).
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,web,secureweb
- traefik.http.routers.audiomuse.middlewares=authentik-forwardauth@file,security-headers@file
- traefik.http.services.audiomuse.loadbalancer.server.scheme=http
- traefik.http.services.audiomuse.loadbalancer.server.port=8000
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:
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 sois_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/hostsentry pointingkhaledfahmy.orgto 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_childrenwas increased from 5 to 15 to prevent worker exhaustion during loopback tests and cron runs. Thewww-datauser 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_COREandDISALLOW_FILE_MODSconstants were removed from wp-config.php so WordPress can apply security updates automatically. TheWP_DEBUGconstant is set tofalsein production (was accidentally left on from the Docker env block). - File permissions: The
/var/www/htmlmount in the PHP container is writable (noro). 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
usermodto 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)
- 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
[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_SRCblocks with absolute paths tangle-deployscript installed at/usr/local/bin/tangle-deployon 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-06 Sat] Systemd tangle-deploy fixed — ob-shell, chmod, OIDC proxy
- Replaced stale /usr/local/bin/tangle-deploy (missing (require 'ob-shell)) with the correct repo version. Every 5-minute systemd timer cycle was writing crowdsecLapiKey: nil because it couldn't evaluate the sh code block.
- Added chmod +x "$0" to tangle-deploy.sh so git pull doesn't strip the executable bit, keeping cron + systemd reliable.
- Created and deployed oidc-rewrite-proxy.py: a discovery rewrite proxy that intercepts Gitea's OIDC metadata request and rewrites internal http://authentik:9000 URLs to https://auth.gharbeia.net.
- Registered oidc-proxy as a systemd service on production-1 (port 9122).
- Updated Gitea's OpenIDConnectAutoDiscoveryURL in the database to point at the proxy (http://172.28.10.1:9122/...).
- Reverted Gitea's CustomURLMapping to {} (confirmed unused by source code).
- Fixed qBittorrent v5.2.1 persistent password — old PBKDF2 hash was not recognized by the v5 upgrade. Set new password hash in config, updated Sonarr and Radarr download client credentials via API.
[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:<port>
- Documented the architectural decision: only qbittorrent + sabnzbd need VPN; media management, streaming, and indexing services run standalone
- Added
Khaled Fahmysection 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.
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"
service: http://10.10.10.29:8082 middleware: tunnel-headers@file
Environment Variables
Tangled to docker/compose.env. The CROWDSEC_LAPI_KEY is a noweb reference to the shared crowdsec_key block, so it stays in sync with dynamic.yaml.
## Auto-generated from infrastructure.org -- do not edit directly.
## Edit infrastructure.org and tangle to update.
#################################################################################
#################################################################################
#################################################################################
##
## Docker Compose Environment Variable file for Jellyfin / *ARR Media Stack
##
## Update any of the environment variables below as required.
##
## It is highly recommended Linux users set up a "docker" user, so the
## applications can access the local filesystem with this user's access
## privileges. Use PUID / PGID to map user access between the Docker apps
## and local filesystem.
##
## The MediaStack Guide is located at https://MediaStack.Guide
##
#################################################################################
#################################################################################
#################################################################################
###################################################
## add /dev/net/tun to LXC
## https://pve.proxmox.com/wiki/OpenVPN_in_LXC
###################################################
# Name of the project in Docker
COMPOSE_PROJECT_NAME=docker-production
COMPOSE_BAKE=true
# This is the network subnet which will be used inside the docker "media_network", change as required.
# LOCAL_SUBNET is your home network and is needed so the VPN client allows access to your home computers.
DOCKER_SUBNET=172.28.10.0/24
DOCKER_GATEWAY=172.28.10.1
LOCAL_SUBNET=10.10.10.0/24 # This is the IP Subnet used on your home network
LOCAL_DOCKER_IP=10.10.10.201 # This is the IP Address of your Docker computer
# Each of the "*ARR" applications have been configured so the theme can be changed to your needs.
# Refer to Theme Park for more info / options: https://docs.theme-park.dev/theme-options/aquamarine/
TP_THEME=nord
# If you intend to use Plex as your Media Server, then enter your Plex Claim
# information below, to link this Plex Media Server to your Plex account
# Access Plex claim at: https://account.plex.tv/en/claim
PLEX_CLAIM=claim-1234567890abcdef
# These are the folders on your local host computer / NAS running docker, they MUST exist
# and have correct permissions for PUID and PGUI prior to running the docker compose.
#
# Use the commands in the Guide to create all the sub-folders in each of these folders.
# Host Data Folders - Will accept Linux, Windows, NAS folders.
# Make sure these folders exists before running the "docker compose" command.
FOLDER_FOR_MEDIA=/library
# <-- Update for your folders - Synology Example: /volume1/media
FOLDER_FOR_DATA=/docker/appdata
# <-- Update for your folders - Synology Example: /volume1/docker/appdata
FOLDER_FOR_MORE=/more
# File access, date and time details for the containers / applications to use.
# Run "sudo id docker" on host computer to find PUID / PGID and update these to suit.
PUID=1000
PGID=1000
UMASK=0002
TIMEZONE=America/New_York
# Update your own Internet VPN provide details below
# Online documentation: https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers
VPN_TYPE=openvpn
VPN_SERVICE_PROVIDER=privado
VPN_USERNAME=nhmpxamumlrj
VPN_PASSWORD=ulm8kRtJdmFLAum3tEb
# You MUST provide at least one entry to the SERVER variables below, that supports your VPN provider's settings.
# If you want to add more than one entry per line, use comma separated values: "one,two,three" etc...
SERVER_COUNTRIES=Netherlands
SERVER_REGIONS=
SERVER_CITIES=
SERVER_HOSTNAMES=
SERVER_CATEGORIES=
# Fill in this item ONLY if you're using a custom OpenVPN configuration
# Should be inside gluetun data folder - Example: /gluetun/custom-openvpn.conf
# You can then edit it inside the FOLDER_FOR_DATA location for gluetun.
OPENVPN_CUSTOM_CONFIG=
GLUETUN_CONTROL_PORT=8320
# Fill in these items ONLY if you change VPN_TYPE to "wireguard"
VPN_ENDPOINT_IP=
VPN_ENDPOINT_PORT=
WIREGUARD_PUBLIC_KEY=
WIREGUARD_PRIVATE_KEY=
WIREGUARD_PRESHARED_KEY=
WIREGUARD_ADDRESSES=
# These are the ports used to access each of the applications in your web browser.
# You can safely change these if you need, but they can't conflict with other active ports.
QBIT_PORT=6881
FLARESOLVERR_PORT=8191
TDARR_SERVER_PORT=8266
# WebUI ports for internal access to applications
WEBUI_PORT_AUDIOBOOKSHELF=13378
WEBUI_PORT_AUTHENTIK=6080
WEBUI_PORT_BAZARR=6767
WEBUI_PORT_CHROMIUM=3650
WEBUI_PORT_DDNS_UPDATER=8310
WEBUI_PORT_FILEBOT=5454
WEBUI_PORT_GUACAMOLE=9200
WEBUI_PORT_GRAFANA=3800
WEBUI_PORT_HEADPLANE=3500
WEBUI_PORT_HEIMDALL=2080
WEBUI_PORT_HOMARR=3200
WEBUI_PORT_HOMEPAGE=3000
WEBUI_PORT_HUNTARR=9705
WEBUI_PORT_JELLYFIN=8096
WEBUI_PORT_JELLYSEERR=5055
WEBUI_PORT_LAZYLIBRARIAN=5299
WEBUI_PORT_LIDARR=8686
WEBUI_PORT_MYLAR=8090
WEBUI_PORT_PLEX=32400
WEBUI_PORT_PORTAINER=9000
WEBUI_PORT_PROMETHEUS=9090
WEBUI_PORT_PROWLARR=9696
WEBUI_PORT_QBITTORRENT=8200
WEBUI_PORT_RADARR=7878
WEBUI_PORT_READARR=8787
WEBUI_PORT_SABNZBD=8100
WEBUI_PORT_SONARR=8989
WEBUI_PORT_STASH=7777
WEBUI_PORT_TDARR=8265
WEBUI_PORT_TRAEFIK=8080
WEBUI_PORT_WHISPARR=6969
CHROMIUM_START_PAGE="https://github.com/geekau/mediastack/"
# Traefik is configured for Reverse Proxy. Set your Internet gateway to redirect incoming ports 80 and 443
# to the ports used below (using Docker IP Address), and they will be translated back to 80 and 443 by Traefik.
# Change these port numbers if you have conflicting services running on the Docker host computer.
# If ports 80 and 443 are already used, then adjust and redirect incoming ports to 5080 and 5443, or similar.
REVERSE_PROXY_PORT_HTTP=80
REVERSE_PROXY_PORT_HTTPS=443
# Traefik Configuration
# Your CloudFlare Account Email Address
CLOUDFLARE_EMAIL=gharbeia@riseup.net
# Your CloudFlare Registered Domain Name
CLOUDFLARE_DNS_ZONE=gharbeia.net
# Your CloudFlare Read / Write API Token
CLOUDFLARE_DNS_API_TOKEN=cfut_unDIAx2wqL2tm8OmcZWpzrQTRPPA5FlenlVfeL7Nf94c360b
# Headscale / Headplane / Tailscale VPN Wireguard Mesh Networking
# These port settings are only to change the internal port due to conflicts, Headscale, Tailscale and Headplane will
# all function normally using the default ports as they are routed through Traefik reverse proxy.
CONNECT_PORT_HEADSCALE=4080
METRICS_PORT_HEADSCALE=4090
CROWDSEC_PORT=9080
METRICS_PORT_TRAEFIK=8082
INTERNAL_PORT_TRAEFIK=8083
METRICS_PORT_UNPACKERR=5656
# The Tailscale Docker container is configured as an exit node inside your home network, so traffic can route securely
# across the Internet, and break out behind your home gateway / router.
# sudo docker exec -it headscale headscale users create exit-node
# sudo docker exec -it headscale headscale --user exit-node preauthkeys create
# NOTE: Headscale must be running before the commands can be executed, then update authkey below and restart Tailscale.
TAILSCALE_AUTHKEY=57332c4f29ef77727d3310a34d903ae83ccae4c6392460fc
# Connect to the following address to complete the initial setup of Authentik after first deployment:
# http://<DOCKER-IP-ADDRESS>:6080/if/flow/initial-setup/
# echo AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')
AUTHENTIK_SECRET_KEY=P)'K"7sF#>Ia+m-8jOyn6\]6S
AUTHENTIK_VERSION=2025.4.1
AUTHENTIK_ERROR_REPORTING__ENABLED=true
POSTGRESQL_PORT=5432
VALKEY_PORT=6379
# echo POSTGRESQL_PASSWORD=$(openssl rand -base64 60 | tr -d '\n')
POSTGRESQL_PASSWORD=1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
POSTGRESQL_USERNAME=library-postgresql
AUTHENTIK_DATABASE=library-authentik
GUACAMOLE_DATABASE=library-guacamole
GUACD_PORT=4822
# SMTP Host Emails are sent to
EMAIL_SERVER_HOST=smtp.fastmail.com
EMAIL_SERVER_PORT=25
# Optionally authenticate (don't add quotation marks to your password)
EMAIL_ADDRESS=amr@gharbeia.net
EMAIL_PASSWORD=3a58334x8a9r8x7e
# Use StartTLS
EMAIL_TLS=true
# Use SSL - StartTLS and SSL can't both be true
EMAIL_SSL=false
# Email address authentik will send from, should have a correct @domain.name
EMAIL_SENDER=authentik@gharbeia.net
#### Gitea
GITEA__database__DB_TYPE=sqlite3
GITEA__database__PATH=/data/gitea/gitea.db
GITEA__server__DOMAIN=10.10.10.201
GITEA__server__HTTP_PORT=3001
GITEA__server__SSH_PORT=2222
GITEA__server__ROOT_URL=http://10.10.10.201:3000/
GITEA__security__INSTALL_LOCK=true
GITEA__service__DISABLE_REGISTRATION=false
# ports:
# - "127.0.0.1:3000:3000"
# - "127.0.0.1:2222:22"
# Cloudflare Tunnel token for cloudflared
TUNNEL_TOKEN=eyJhIjoiYWY0Y2RkYWM0N2UwMDFmZDZkNWMyMGFjNmRkZGFkM2QiLCJ0IjoiYzI5Mjk1YzUtOTQ2YS00ZGRmLWJkZmUtN2VhZmNkNzRmYWEzIiwicyI6Ik5qQm1aVEV4TjJFdFptRTFPUzAwTjJWbUxUZ3pORE10TURKa1lqRXhNMlptT1RVNCJ9
# Tube Archivist
TA_USERNAME=admin
TA_PASSWORD=DsO1BPfMEXMJROG9NlgEslOd
WEBUI_PORT_AUDIOMUSE=8005
# khaledfahmy.org
KHALEDFAHMY_DB_ROOT_PASSWORD=kf_root_ch4ng3_m3
KHALEDFAHMY_DB_PASSWORD=t1)~Bt~1uwmwe?pq}sZj%b!t8
# Nancy Okail (WordPress)
NANCYOKAIL_DB_ROOT_PASSWORD=changeme_placeholder
NANCYOKAIL_DB_PASSWORD=changeme_placeholder
# Rim Naguib (WordPress)
RIMNAGUIB_DB_ROOT_PASSWORD=changeme_placeholder
RIMNAGUIB_DB_PASSWORD=changeme_placeholder
# Fishere (WordPress)
FISHERE_DB_ROOT_PASSWORD=changeme_placeholder
FISHERE_DB_PASSWORD=changeme_placeholder
CROWDSEC_LAPI_KEY=<<crowdsec_key()>>