1383 lines
44 KiB
Org Mode
1383 lines
44 KiB
Org Mode
#+TITLE: Infrastructure Documentation — gharbeia.net
|
|
#+AUTHOR: Amr Gharbeia
|
|
#+DATE: 2026-05-15
|
|
|
|
* Architecture
|
|
|
|
** Hosts
|
|
- =production-1= (10.10.10.201) :: Docker host, runs all services
|
|
- Hermes Agent :: Management/automation host
|
|
|
|
** Network
|
|
- Docker network =networking= (172.28.10.0/24)
|
|
- Proxmox VLANs: 1/10/20/30/40/50
|
|
- Services VLAN: 10.10.10.0/24
|
|
- Domain: gharbeia.net via Cloudflare (orange cloud/proxied)
|
|
|
|
** External Access Architecture
|
|
|
|
#+BEGIN_EXAMPLE
|
|
Cloudflare (edge, orange cloud)
|
|
└─ Cloudflare Tunnel "home" (cloudflared on production-1)
|
|
└─ Traefik (entrypoint=tunnel, port 8081)
|
|
├─ Authentik Forward Auth (external routers)
|
|
├─ gharbeia-site (nginx)
|
|
├─ jellyfin (SSO via plugin + OIDC)
|
|
├─ gitea (native OIDC)
|
|
└─ *.gharbeia.net services
|
|
#+END_EXAMPLE
|
|
|
|
** Internal Access Architecture
|
|
|
|
#+BEGIN_EXAMPLE
|
|
LAN client (browser)
|
|
└─ Traefik (entrypoint=secureweb, port 443)
|
|
├─ Authentik Forward Auth (internal.yaml routers)
|
|
├─ gharbeia-site (public, no auth)
|
|
├─ jellyfin (SSO via plugin)
|
|
└─ *.gharbeia.net services
|
|
|
|
Service-to-service / automation / cross-VLAN
|
|
└─ Traefik (entrypoint=internal, port 8083 — NO auth)
|
|
└─ Same routing as secureweb, from traefik-internal-noauth.yaml
|
|
#+END_EXAMPLE
|
|
|
|
Key distinction: =:443= = browsers/humans with Authentik auth.
|
|
=:8083= = runners, automated tooling, services on other VLANs.
|
|
|
|
** Tangle & Deploy Pipeline
|
|
|
|
Changes are made to this org file, tangled into config files by the
|
|
=tangle-deploy= script on production-1, then deployed via =docker compose=.
|
|
|
|
#+BEGIN_SRC bash :tangle /docker/compose/infrastructure/tangle-deploy.sh
|
|
#!/usr/bin/env bash
|
|
# tangle-deploy — Tangle infrastructure.org and restart affected services
|
|
GITEA_URL='ssh://git@git.gharbeia.net:2222/amr/infrastructure.git'
|
|
REPO_DIR="${1:-/docker/compose/infrastructure}"
|
|
ORG_FILE="${REPO_DIR}/infrastructure.org"
|
|
if [ -z "${1:-}" ]; then
|
|
if [ ! -d "$REPO_DIR" ]; then
|
|
git clone "$GITEA_URL" "$REPO_DIR"
|
|
else
|
|
cd "$REPO_DIR" && git pull
|
|
fi
|
|
fi
|
|
if [ ! -f "$ORG_FILE" ]; then
|
|
echo "ERROR: $ORG_FILE not found in $REPO_DIR"
|
|
exit 1
|
|
fi
|
|
echo "=== Tangling $ORG_FILE ==="
|
|
emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \
|
|
--eval "(require 'org)" \
|
|
--eval "(org-babel-tangle-file \"$ORG_FILE\")" 2>&1
|
|
echo "=== Restarting services ==="
|
|
cd /docker/compose
|
|
if [ -f /docker/compose/traefik-static.yaml ] || \
|
|
[ -f /docker/compose/traefik-internal.yaml ] || \
|
|
[ -f /docker/compose/traefik-internal-noauth.yaml ] || \
|
|
[ -f /docker/compose/traefik-dynamic.yaml ]; then
|
|
echo 'Traefik config changed -- restarting...'
|
|
docker compose up -d traefik
|
|
fi
|
|
if [ -f /docker/compose/docker-compose.yaml ]; then
|
|
echo 'Docker compose changed -- restarting all services'
|
|
docker compose up -d 2>&1 | tail -5
|
|
fi
|
|
echo '=== Deploy complete ==='
|
|
#+END_SRC
|
|
|
|
The =infra-tangle.timer= polls the Gitea repo every 5 minutes and runs this
|
|
script. Pushing to Gitea triggers the pipeline within 5 minutes.
|
|
|
|
* Traefik — Reverse Proxy
|
|
|
|
Traefik is the edge router for all HTTP traffic. It handles TLS termination via
|
|
Let's Encrypt (DNS-01 challenge through Cloudflare), routes traffic to the right
|
|
container, and applies middleware chains for auth, security, and rate limiting.
|
|
|
|
Three entrypoints:
|
|
|
|
- =tunnel= (=:8081=) :: Receives traffic from the Cloudflare tunnel. All routers
|
|
here have Authentik Forward Auth.
|
|
- =secureweb= (=:443=) :: Internal LAN traffic with TLS. Also has Authentik
|
|
Forward Auth for browser access.
|
|
- =internal= (=:8083=) :: Service-to-service and cross-VLAN traffic. No auth.
|
|
HTTP only. For runners, automation, and API calls that shouldn't hit Authentik.
|
|
|
|
** Static Configuration
|
|
|
|
The static config sets entrypoints, TLS resolvers, providers, and plugins.
|
|
It is the foundation everything else builds on.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/traefik-static.yaml
|
|
global:
|
|
checkNewVersion: true
|
|
sendAnonymousUsage: true
|
|
|
|
log:
|
|
level: INFO
|
|
|
|
accessLog:
|
|
filePath: /var/log/access.log
|
|
format: json
|
|
|
|
api:
|
|
dashboard: true
|
|
insecure: true
|
|
|
|
entryPoints:
|
|
web:
|
|
address: :80
|
|
http:
|
|
redirections:
|
|
entryPoint:
|
|
to: secureweb
|
|
scheme: https
|
|
permanent: true
|
|
tunnel:
|
|
address: :8081
|
|
secureweb:
|
|
address: :443
|
|
http:
|
|
tls:
|
|
options: default
|
|
certResolver: letsencrypt
|
|
domains:
|
|
- main: gharbeia.net
|
|
sans:
|
|
- "*.gharbeia.net"
|
|
internal:
|
|
address: :8083
|
|
metrics:
|
|
address: :8082
|
|
|
|
metrics:
|
|
prometheus:
|
|
entryPoint: metrics
|
|
manualRouting: true
|
|
headerLabels:
|
|
useragent: User-Agent
|
|
buckets:
|
|
- 0.1
|
|
- 0.3
|
|
- 1.2
|
|
- 5.0
|
|
|
|
providers:
|
|
docker:
|
|
exposedByDefault: false
|
|
file:
|
|
directory: /etc/traefik
|
|
watch: true
|
|
|
|
certificatesResolvers:
|
|
letsencrypt:
|
|
acme:
|
|
storage: /letsencrypt/acme.json
|
|
email: gharbeia@riseup.net
|
|
keyType: EC384
|
|
caServer: https://acme-v02.api.letsencrypt.org/directory
|
|
dnsChallenge:
|
|
provider: cloudflare
|
|
resolvers:
|
|
- 1.1.1.1:53
|
|
- 1.0.0.1:53
|
|
propagation:
|
|
delayBeforeChecks: 60s
|
|
|
|
experimental:
|
|
plugins:
|
|
crowdsec-bouncer-traefik-plugin:
|
|
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
|
version: v1.4.2
|
|
#+END_SRC
|
|
|
|
Why each piece:
|
|
- =web= (=:80=) exists only to redirect to HTTPS. No TLS.
|
|
- =tunnel= (=:8081=) is inbound-only from cloudflared, never exposed to LAN.
|
|
Cloudflare handles TLS at the edge, so this can be plain HTTP inside Docker.
|
|
- =secureweb= (=:443=) is the LAN-facing entrypoint with Let's Encrypt certs
|
|
covering both =gharbeia.net= and =*.gharbeia.net=.
|
|
- =internal= (=:8083=) is plain HTTP for service-to-service traffic. TLS overhead
|
|
is unnecessary on the internal bridge network.
|
|
- =metrics= (=:8082=) exposes Prometheus metrics, manually routed.
|
|
- =dnsChallenge= with Cloudflare provider issues wildcard certs. The 60s
|
|
propagation delay avoids rate-limit issues with Cloudflare's API.
|
|
|
|
** Dynamic Configuration — Middleware
|
|
|
|
Shared middleware used by all routers. Defined once here, referenced by name
|
|
in every router block.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/traefik-dynamic.yaml
|
|
http:
|
|
middlewares:
|
|
|
|
authentik-forwardauth:
|
|
forwardAuth:
|
|
address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
|
|
trustForwardHeader: true
|
|
authResponseHeaders:
|
|
- X-authentik-username
|
|
- X-authentik-groups
|
|
- X-authentik-email
|
|
- X-authentik-name
|
|
- X-authentik-uid
|
|
|
|
security-headers:
|
|
headers:
|
|
customFrameOptionsValue: SAMEORIGIN
|
|
contentTypeNosniff: true
|
|
browserXssFilter: true
|
|
referrerPolicy: no-referrer
|
|
permissionsPolicy: ""
|
|
customResponseHeaders:
|
|
X-Robots-Tag: "noindex, nofollow"
|
|
Server: ""
|
|
|
|
traefik-bouncer:
|
|
plugin:
|
|
crowdsec-bouncer-traefik-plugin:
|
|
enabled: "true"
|
|
crowdsecMode: live
|
|
crowdsecLapiKey: __CROWDSEC_LAPI_KEY__
|
|
crowdsecLapiHost: crowdsec:8080
|
|
crowdsecLapiScheme: http
|
|
updateFrequencySec: 5
|
|
defaultDecisionLifetimeSec: 60
|
|
|
|
compress:
|
|
compress:
|
|
excludedContentTypes:
|
|
- text/event-stream
|
|
|
|
ratelimit:
|
|
rateLimit:
|
|
average: 100
|
|
burst: 50
|
|
#+END_SRC
|
|
|
|
The auth flow: Authentik's outpost runs as a sidecar inside the =authentik=
|
|
container that validates session cookies. When a request lacks a valid session,
|
|
Traefik redirects to the Authentik login page. After login, Authentik redirects
|
|
back to the original URL with a session cookie.
|
|
|
|
=security-headers= locks down XSS, clickjacking, and fingerprinting. The empty
|
|
=permissionsPolicy= disables all browser APIs by default.
|
|
|
|
=traefik-bouncer= runs CrowdSec's LAPI bouncer as a Traefik plugin. IPs flagged
|
|
by CrowdSec get blocked. The LAPI key is a placeholder -- fill from vault.
|
|
|
|
** Internal Routers — Authenticated (secureweb :443)
|
|
|
|
These routers serve LAN browser traffic. All have Authentik Forward Auth.
|
|
Backend services are referenced by Docker DNS name on the =networking= bridge.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal.yaml
|
|
http:
|
|
routers:
|
|
|
|
# -- Media & Streaming -----------------------------------------
|
|
|
|
jellyfin:
|
|
rule: "Host(`jellyfin.gharbeia.net`)"
|
|
service: jellyfin-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
jellyseerr:
|
|
rule: "Host(`jellyseerr.gharbeia.net`)"
|
|
service: jellyseerr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- *arr Suite -------------------------------------------------
|
|
|
|
radarr:
|
|
rule: "Host(`radarr.gharbeia.net`)"
|
|
service: radarr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
sonarr:
|
|
rule: "Host(`sonarr.gharbeia.net`)"
|
|
service: sonarr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
lidarr:
|
|
rule: "Host(`lidarr.gharbeia.net`)"
|
|
service: lidarr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
prowlarr:
|
|
rule: "Host(`prowlarr.gharbeia.net`)"
|
|
service: prowlarr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
whisparr:
|
|
rule: "Host(`whisparr.gharbeia.net`)"
|
|
service: whisparr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
mylar:
|
|
rule: "Host(`mylar.gharbeia.net`)"
|
|
service: mylar-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
lazylibrarian:
|
|
rule: "Host(`lazylibrarian.gharbeia.net`)"
|
|
service: lazylibrarian-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Downloaders ------------------------------------------------
|
|
|
|
sabnzbd:
|
|
rule: "Host(`sabnzbd.gharbeia.net`)"
|
|
service: sabnzbd-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
qbittorrent:
|
|
rule: "Host(`qbittorrent.gharbeia.net`)"
|
|
service: qbittorrent-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
flaresolverr:
|
|
rule: "Host(`flaresolverr.gharbeia.net`)"
|
|
service: flaresolverr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Homepage / Dashboards --------------------------------------
|
|
|
|
homepage:
|
|
rule: "Host(`homepage.gharbeia.net`)"
|
|
service: homepage-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
homarr:
|
|
rule: "Host(`homarr.gharbeia.net`)"
|
|
service: homarr-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
heimdall:
|
|
rule: "Host(`heimdall.gharbeia.net`)"
|
|
service: heimdall-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Monitoring ------------------------------------------------
|
|
|
|
grafana:
|
|
rule: "Host(`grafana.gharbeia.net`)"
|
|
service: grafana-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
prometheus:
|
|
rule: "Host(`prometheus.gharbeia.net`)"
|
|
service: prometheus-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Website (public, no auth) ----------------------------------
|
|
|
|
gharbeia-site:
|
|
rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)"
|
|
service: gharbeia-site-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
|
|
# -- Management ------------------------------------------------
|
|
|
|
gitea:
|
|
rule: "Host(`git.gharbeia.net`)"
|
|
service: gitea-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
# No authentik-forwardauth -- Gitea has native OIDC
|
|
|
|
portainer:
|
|
rule: "Host(`portainer.gharbeia.net`)"
|
|
service: portainer-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
authentik:
|
|
rule: "Host(`auth.gharbeia.net`)"
|
|
service: authentik-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
# No authentik-forwardauth -- otherwise auth loops
|
|
|
|
headscale:
|
|
rule: "Host(`headscale.gharbeia.net`)"
|
|
service: headscale-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
# No authentik-forwardauth -- Tailscale clients need direct access
|
|
|
|
headplane:
|
|
rule: "Host(`headplane.gharbeia.net`) && PathPrefix(`/admin/`)"
|
|
service: headplane-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
ddns-updater:
|
|
rule: "Host(`ddns-updater.gharbeia.net`)"
|
|
service: ddns-updater-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
audiobookshelf:
|
|
rule: "Host(`audiobookshelf.gharbeia.net`)"
|
|
service: audiobookshelf-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
guacamole:
|
|
rule: "Host(`guacamole.gharbeia.net`)"
|
|
service: guacamole-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
tubearchivist:
|
|
rule: "Host(`tubearchivist.gharbeia.net`)"
|
|
service: tubearchivist-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
traefik-dashboard:
|
|
rule: "Host(`traefik.gharbeia.net`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
|
|
service: traefik-dashboard-internal
|
|
entryPoints:
|
|
- secureweb
|
|
tls:
|
|
certResolver: letsencrypt
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
services:
|
|
jellyfin-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://jellyfin:8096
|
|
jellyseerr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://jellyseerr:5055
|
|
radarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://radarr:7878
|
|
sonarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://sonarr:8989
|
|
lidarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://lidarr:8686
|
|
prowlarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://prowlarr:9696
|
|
whisparr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://whisparr:6969
|
|
mylar-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://mylar:8090
|
|
lazylibrarian-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://lazylibrarian:5299
|
|
sabnzbd-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://sabnzbd:8080
|
|
qbittorrent-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://qbittorrent:8200
|
|
flaresolverr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://flaresolverr:8191
|
|
homepage-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://homepage:3000
|
|
homarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://homarr:7575
|
|
heimdall-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://heimdall:80
|
|
grafana-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://grafana:3000
|
|
prometheus-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://prometheus:9090
|
|
gharbeia-site-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://gharbeia-site:80
|
|
gitea-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://gitea:3000
|
|
portainer-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://portainer:9000
|
|
authentik-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://authentik:9000
|
|
headscale-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://headscale:8080
|
|
headplane-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://headplane:3000
|
|
ddns-updater-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://ddns-updater:8310
|
|
audiobookshelf-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://audiobookshelf:13378
|
|
guacamole-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://guacamole:8080
|
|
traefik-dashboard-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://traefik:8080
|
|
tubearchivist-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://tubearchivist:8000
|
|
#+END_SRC
|
|
|
|
All 28 routers follow the same pattern. The service URLs point to Docker DNS
|
|
names on the =networking= bridge. Services behind Gluetun VPN use their
|
|
internal container port (the port inside Gluetun's network namespace), not
|
|
the host-exposed port.
|
|
|
|
** Internal Routers — No Auth (internal :8083)
|
|
|
|
An identical set of routers without the =authentik-forwardauth= middleware.
|
|
Used by service-to-service traffic, Gitea runner, and cross-VLAN automation.
|
|
Generated by stripping the auth middleware from =traefik-internal.yaml=.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal-noauth.yaml
|
|
# This file is maintained manually as a copy of traefik-internal.yaml
|
|
# with all authentik-forwardauth middleware references removed.
|
|
# See: docker/appdata/traefik/internal-noauth.yaml for the production copy.
|
|
#+END_SRC
|
|
|
|
** Authentication Architecture
|
|
|
|
Three authentication mechanisms depending on the service type:
|
|
|
|
*** Forward Auth (default for web-only services)
|
|
|
|
Traefik middleware intercepts every request and redirects unauthenticated
|
|
users to the Authentik login page. After login, Authentik sets a session
|
|
cookie that passes subsequent checks transparently.
|
|
|
|
Used by: all =*arr=, dashboards, monitoring, Portainer, Guacamole, etc.
|
|
Limitation: only works in browsers — native/TV apps can't use Forward Auth.
|
|
|
|
*** Native OIDC / SSO (for services with apps)
|
|
|
|
Services that have native mobile or TV apps need real OIDC integration so
|
|
the app can authenticate directly via a browser-based login flow.
|
|
|
|
- Gitea: configured with Authentik OIDC provider in Gitea's admin panel.
|
|
Users log in via "Sign in with Authentik" button on the Gitea login page.
|
|
Existing user accounts are linked by username match.
|
|
|
|
- Jellyfin: uses the SSO-Auth plugin (v4.0.0.4) with an Authentik OIDC
|
|
provider (client_id = =jellyfin-sso=). The plugin does a two-step flow:
|
|
1. OIDC callback returns an HTML page with JavaScript + authorization state
|
|
2. JavaScript POSTs to =/sso/OID/Auth/Authentik= to complete the login
|
|
|
|
Critical detail: Jellyfin must trust Traefik's =X-Forwarded-Proto= header
|
|
or the JavaScript will construct URLs with =http://= instead of =https://=.
|
|
This is configured via =KnownProxies= in =/config/network.xml=:
|
|
|
|
#+BEGIN_SRC xml
|
|
<KnownProxies>
|
|
<string>172.28.10.0/24</string>
|
|
<string>172.28.10.4</string>
|
|
</KnownProxies>
|
|
#+END_SRC
|
|
|
|
Without this, =GetRequestBase()= returns =http://jellyfin.gharbeia.net= and
|
|
the iframe, auth POST, and final redirect all use the wrong scheme.
|
|
|
|
*** Local users (TV apps, fallback)
|
|
|
|
TV apps (Android TV, webOS, Tizen) often can't complete the OIDC JavaScript
|
|
two-step flow inside their embedded browser. For these, create a dedicated
|
|
Jellyfin local user (e.g. =tv=) with a simple password. The app logs in
|
|
directly with password — no SSO involved. The user keeps library access
|
|
without losing admin history/settings.
|
|
|
|
This pattern applies to any service where native app SSO doesn't work:
|
|
create a local service account, use it for app access, keep SSO for browsers.
|
|
|
|
The entire stack runs on =production-1= using Docker Compose. Services are
|
|
split into individual YAML fragment files under =/docker/compose/services/=,
|
|
referenced by the master compose via =include:= directives.
|
|
|
|
This splitting has three benefits:
|
|
1. Each service is self-contained with its own prose documentation
|
|
2. Adding or removing a service is a single line in the master compose
|
|
3. Differences between deployments (e.g. test vs production) are just different
|
|
include lists
|
|
|
|
** Master Compose
|
|
|
|
The master compose defines the shared network and includes all service fragments.
|
|
It is the single entry point for =docker compose= commands.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/docker-compose.yaml
|
|
networks:
|
|
networking:
|
|
name: networking
|
|
driver: bridge
|
|
ipam:
|
|
driver: default
|
|
config:
|
|
- subnet: ${DOCKER_SUBNET:?err}
|
|
gateway: ${DOCKER_GATEWAY:?err}
|
|
|
|
include:
|
|
- services/gluetun.yaml
|
|
- services/postgresql.yaml
|
|
- services/valkey.yaml
|
|
- services/authentik.yaml
|
|
- services/authentic-worker.yaml
|
|
- services/traefik.yaml
|
|
- services/traefik-certs-dumper.yaml
|
|
- services/crowdsec.yaml
|
|
- services/gitea.yaml
|
|
- services/runner.yaml
|
|
- services/cloudflared.yaml
|
|
- services/gharbeia-site.yaml
|
|
- services/unbound.yaml
|
|
- services/homepage.yaml
|
|
- services/homarr.yaml
|
|
- services/heimdall.yaml
|
|
- services/grafana.yaml
|
|
- services/prometheus.yaml
|
|
- services/headscale.yaml
|
|
- services/tailscale.yaml
|
|
- services/headplane.yaml
|
|
- services/ddns-updater.yaml
|
|
- services/portainer.yaml
|
|
- services/guacamole.yaml
|
|
- services/guacd.yaml
|
|
- services/unpackerr.yaml
|
|
- services/bazarr.yaml
|
|
- services/flaresolverr.yaml
|
|
- services/jellyfin.yaml
|
|
- services/jellyseerr.yaml
|
|
- services/lazylibrarian.yaml
|
|
- services/lidarr.yaml
|
|
- services/mylar.yaml
|
|
- services/prowlarr.yaml
|
|
- services/qbittorrent.yaml
|
|
- services/radarr.yaml
|
|
- services/sabnzbd.yaml
|
|
- services/sonarr.yaml
|
|
- services/stash.yaml
|
|
- services/tdarr.yaml
|
|
- services/tdarr-node.yaml
|
|
- services/tubearchivist.yaml
|
|
- services/audiobookshelf.yaml
|
|
- services/whisparr.yaml
|
|
#+END_SRC
|
|
|
|
All 44 services are organized alphabetically by category in the include list.
|
|
The order matters for startup dependencies: infrastructure services (gluetun,
|
|
postgresql, valkey, authentik, traefik) come first.
|
|
|
|
** Jellyfin — Media Server
|
|
|
|
Jellyfin serves media libraries through the browser and native apps. It runs
|
|
in Gluetun's network namespace (VPN-routed), uses Authentik SSO for browser
|
|
login, and supports local user accounts for TV apps.
|
|
|
|
*** KnownProxies (Critical for SSO)
|
|
|
|
Jellyfin sits behind Traefik reverse proxy. Without =KnownProxies=, Jellyfin
|
|
doesn't trust =X-Forwarded-Proto: https=, so the SSO plugin's JavaScript
|
|
flow constructs URLs with HTTP instead of HTTPS. This file must match the
|
|
runtime config at =/docker/appdata/jellyfin/network.xml=.
|
|
|
|
#+BEGIN_SRC xml :tangle /docker/appdata/jellyfin/network.xml
|
|
<?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>
|
|
#+END_SRC
|
|
|
|
*** SSO-Auth Plugin Configuration
|
|
|
|
The SSO plugin config lives at =/docker/appdata/jellyfin/plugins/configurations/SSO-Auth.xml=.
|
|
Key settings:
|
|
- OIDC provider pointing to =https://auth.gharbeia.net/application/o/jellyfin-sso=
|
|
- =EnableAuthorization=false= (bypasses group-based role checking)
|
|
- =EnableAllFolders=true= (all libraries accessible to SSO users)
|
|
- Scopes: =openid profile email groups=
|
|
|
|
With =EnableAuthorization=false=, any Authentik user can log in to Jellyfin
|
|
via SSO. Admin rights are managed within Jellyfin itself.
|
|
|
|
** Gluetun — VPN Client
|
|
|
|
Gluetun is the VPN gateway for all media-related traffic. Services that need
|
|
VPN routing use =network_mode: service:gluetun= to share its network namespace.
|
|
This means their traffic exits through the VPN tunnel, not the host's public IP.
|
|
|
|
Key architectural decisions:
|
|
- All VPN-routed services share Gluetun's port mappings (configured on Gluetun)
|
|
- =extra_hosts= resolves *.gharbeia.net to 10.10.10.201 so VPN-routed services
|
|
can reach Traefik without leaking DNS
|
|
- The =FIREWALL_OUTBOUND_SUBNETS= allows LAN access through the VPN
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/gluetun.yaml
|
|
services:
|
|
gluetun:
|
|
image: qmcgaw/gluetun:latest
|
|
container_name: gluetun
|
|
restart: always
|
|
cap_add:
|
|
- NET_ADMIN
|
|
devices:
|
|
- /dev/net/tun:/dev/net/tun
|
|
ports:
|
|
- 8888:8888/tcp
|
|
- 8388:8388/tcp
|
|
- 8388:8388/udp
|
|
- ${GLUETUN_CONTROL_PORT:?err}:${GLUETUN_CONTROL_PORT:?err}
|
|
- ${WEBUI_PORT_AUDIOBOOKSHELF:?err}:80
|
|
- ${WEBUI_PORT_BAZARR:?err}:6767
|
|
- ${WEBUI_PORT_FILEBOT:?err}:5454
|
|
- ${WEBUI_PORT_HUNTARR:?err}:9705
|
|
- ${WEBUI_PORT_JELLYFIN:?err}:8096
|
|
- ${WEBUI_PORT_JELLYSEERR:?err}:5055
|
|
- ${WEBUI_PORT_LAZYLIBRARIAN:?err}:5299
|
|
- ${WEBUI_PORT_LIDARR:?err}:8686
|
|
- ${WEBUI_PORT_MYLAR:?err}:8090
|
|
- ${WEBUI_PORT_PROWLARR:?err}:9696
|
|
- ${WEBUI_PORT_RADARR:?err}:7878
|
|
- ${WEBUI_PORT_READARR:?err}:8787
|
|
- ${WEBUI_PORT_SABNZBD:?err}:8080
|
|
- ${WEBUI_PORT_SONARR:?err}:8989
|
|
- ${WEBUI_PORT_STASH:?err}:7777
|
|
- ${WEBUI_PORT_WHISPARR:?err}:6969
|
|
- ${WEBUI_PORT_QBITTORRENT:?err}:${WEBUI_PORT_QBITTORRENT:?err}
|
|
- ${QBIT_PORT:?err}:6881
|
|
- ${FLARESOLVERR_PORT:?err}:8191
|
|
- ${TDARR_SERVER_PORT:?err}:${TDARR_SERVER_PORT:?err}
|
|
- ${WEBUI_PORT_TDARR:?err}:${WEBUI_PORT_TDARR:?err}
|
|
- ${WEBUI_PORT_PLEX:?err}:32400
|
|
- 8324:8324
|
|
- 32410:32410/udp
|
|
- 32412:32412/udp
|
|
- 32413:32413/udp
|
|
- 32414:32414/udp
|
|
- 32469:32469
|
|
extra_hosts:
|
|
- ${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}
|
|
- "*.${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}"
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/gluetun:/gluetun
|
|
environment:
|
|
- PUID=${PUID:?err}
|
|
- PGID=${PGID:?err}
|
|
- UMASK=${UMASK:?err}
|
|
- TZ=${TIMEZONE:?err}
|
|
- VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER:?err}
|
|
- OPENVPN_USER=${VPN_USERNAME:?err}
|
|
- OPENVPN_PASSWORD=${VPN_PASSWORD:?err}
|
|
- SERVER_COUNTRIES=${SERVER_COUNTRIES}
|
|
- SERVER_REGIONS=${SERVER_REGIONS}
|
|
- SERVER_CITIES=${SERVER_CITIES}
|
|
- SERVER_HOSTNAMES=${SERVER_HOSTNAMES}
|
|
- SERVER_CATEGORIES=${SERVER_CATEGORIES}
|
|
- FIREWALL_OUTBOUND_SUBNETS=${LOCAL_SUBNET:?err}
|
|
- OPENVPN_CUSTOM_CONFIG=${OPENVPN_CUSTOM_CONFIG}
|
|
- HTTP_CONTROL_SERVER_ADDRESS=:${GLUETUN_CONTROL_PORT:?err}
|
|
- VPN_TYPE=${VPN_TYPE}
|
|
- VPN_ENDPOINT_IP=${VPN_ENDPOINT_IP}
|
|
- VPN_ENDPOINT_PORT=${VPN_ENDPOINT_PORT}
|
|
- WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY}
|
|
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
|
|
- WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY}
|
|
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
|
|
- HTTPPROXY=on
|
|
- SHADOWSOCKS=on
|
|
networks:
|
|
- networking
|
|
#+END_SRC
|
|
|
|
** Authentik — Identity Provider
|
|
|
|
Authentik provides universal authentication for all web services. It acts as
|
|
both the SSO login page (via Traefik Forward Auth) and the OIDC provider for
|
|
services that support it natively (Gitea, Jellyfin via plugin).
|
|
|
|
The stack has two containers:
|
|
- =authentik= (server) — handles login flows, session management, policies
|
|
- =authentic-worker= — background tasks, outpost management
|
|
|
|
Both connect to the same Postgres and Valkey databases.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/authentik.yaml
|
|
services:
|
|
authentik:
|
|
image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:?err}
|
|
container_name: authentik
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
user: ${PUID:?err}:${PGID:?err}
|
|
command: server
|
|
environment:
|
|
- TZ=${TIMEZONE:?err}
|
|
- AUTHENTIK_LOG_LEVEL=info
|
|
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY:?err}
|
|
- AUTHENTIK_REDIS__HOST=valkey
|
|
- AUTHENTIK_POSTGRESQL__HOST=postgresql
|
|
- AUTHENTIK_POSTGRESQL__NAME=${AUTHENTIK_DATABASE:?err}
|
|
- AUTHENTIK_POSTGRESQL__USER=${POSTGRESQL_USERNAME:?err}
|
|
- AUTHENTIK_POSTGRESQL__PASSWORD=${POSTGRESQL_PASSWORD:?err}
|
|
- AUTHENTIK_ERROR_REPORTING__ENABLED=false
|
|
- AUTHENTIK_EMAIL__HOST=${EMAIL_SERVER_HOST}
|
|
- AUTHENTIK_EMAIL__PORT=${EMAIL_SERVER_PORT}
|
|
- AUTHENTIK_EMAIL__USERNAME=${EMAIL_ADDRESS}
|
|
- AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD}
|
|
- AUTHENTIK_EMAIL__USE_TLS=true
|
|
- AUTHENTIK_EMAIL__USE_SSL=false
|
|
- AUTHENTIK_EMAIL__FROM=${EMAIL_SENDER}
|
|
- AUTHENTIK_EMAIL__TIMEOUT=15
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/authentik/media:/media
|
|
- ${FOLDER_FOR_DATA:?err}/authentik/templates:/templates
|
|
ports:
|
|
- ${WEBUI_PORT_AUTHENTIK:?err}:9000
|
|
depends_on:
|
|
postgresql:
|
|
condition: service_healthy
|
|
restart: true
|
|
valkey:
|
|
condition: service_healthy
|
|
restart: true
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.authentik.service=authentik
|
|
- traefik.http.routers.authentik.rule=Host(`auth.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.authentik.entrypoints=secureweb
|
|
- traefik.http.routers.authentik.middlewares=security-headers@file,traefik-bouncer@file
|
|
- traefik.http.services.authentik.loadbalancer.server.scheme=http
|
|
- traefik.http.services.authentik.loadbalancer.server.port=9000
|
|
#+END_SRC
|
|
|
|
** Gitea — Git Hosting
|
|
|
|
Gitea hosts the infrastructure repo and triggers the tangle-deploy pipeline.
|
|
The runner connects via the authless internal entrypoint (:8083) so it can
|
|
check out repos without SSO interference.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/gitea.yaml
|
|
services:
|
|
gitea:
|
|
image: docker.gitea.com/gitea:1.25.5
|
|
container_name: gitea
|
|
restart: always
|
|
networks:
|
|
- networking
|
|
environment:
|
|
- USER_UID=1000
|
|
- USER_GID=1000
|
|
volumes:
|
|
- /docker/appdata/gitea:/data
|
|
- /memex:/memex
|
|
- /etc/timezone:/etc/timezone:ro
|
|
- /etc/localtime:/etc/localtime:ro
|
|
ports:
|
|
- "3001:3000"
|
|
- "2222:22"
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.gitea.service=gitea
|
|
- traefik.http.routers.gitea.rule=Host(`git.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.gitea.entrypoints=tunnel
|
|
- traefik.http.routers.gitea.middlewares=security-headers@file,traefik-bouncer@file
|
|
- traefik.http.services.gitea.loadbalancer.server.scheme=http
|
|
- traefik.http.services.gitea.loadbalancer.server.port=3000
|
|
#+END_SRC
|
|
|
|
** Infrastructure Services
|
|
|
|
Core data and networking services that everything depends on.
|
|
|
|
*** Postgresql
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/postgresql.yaml
|
|
services:
|
|
postgresql:
|
|
image: docker.io/library/postgres:17
|
|
container_name: postgresql
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
user: ${PUID:?err}:${PGID:?err}
|
|
ports:
|
|
- ${POSTGRESQL_PORT:?err}:5432
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
|
start_period: 20s
|
|
interval: 30s
|
|
retries: 5
|
|
timeout: 5s
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/postgresql:/var/lib/postgresql/data
|
|
environment:
|
|
- TZ=${TIMEZONE:?err}
|
|
- POSTGRES_DB=${AUTHENTIK_DATABASE:?err}
|
|
- POSTGRES_USER=${POSTGRESQL_USERNAME:?err}
|
|
- POSTGRES_PASSWORD=${POSTGRESQL_PASSWORD:?err}
|
|
#+END_SRC
|
|
|
|
*** Valkey (Redis Alternative)
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/valkey.yaml
|
|
services:
|
|
valkey:
|
|
image: valkey/valkey:alpine
|
|
container_name: valkey
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
command: --save 60 1 --loglevel warning
|
|
user: ${PUID:?err}:${PGID:?err}
|
|
ports:
|
|
- ${VALKEY_PORT:?err}:6379
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
|
|
start_period: 20s
|
|
interval: 30s
|
|
retries: 5
|
|
timeout: 3s
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/valkey:/data
|
|
#+END_SRC
|
|
|
|
*** Unbound — DNS Resolver
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/unbound.yaml
|
|
services:
|
|
unbound:
|
|
image: mvance/unbound:latest
|
|
container_name: unbound
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
ports:
|
|
- 53:53/tcp
|
|
- 53:53/udp
|
|
volumes:
|
|
- /docker/appdata/unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
|
|
#+END_SRC
|
|
|
|
** Tube Archivist — YouTube Archiving
|
|
|
|
Tube Archivist downloads and indexes YouTube channels, playlists, and
|
|
videos. Full-text search, metadata browsing, subscription management.
|
|
|
|
The stack has three containers:
|
|
- =tubearchivist= (main app) — Django web UI on port 8000
|
|
- =tubearchivist-es= — Elasticsearch 8.17 for metadata + search
|
|
- =tubearchivist-redis= — Redis for Celery task queue
|
|
|
|
Tube Archivist does NOT need VPN routing (reaches YouTube directly).
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/tubearchivist.yaml
|
|
services:
|
|
tubearchivist:
|
|
image: bbilly1/tubearchivist:latest
|
|
container_name: tubearchivist
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
ports:
|
|
- ${WEBUI_PORT_TUBEARCHIVIST:-8000}:8000
|
|
environment:
|
|
- TZ=${TIMEZONE:?err}
|
|
- TA_USERNAME=${TA_USERNAME:?err}
|
|
- TA_PASSWORD=${TA_PASSWORD:?err}
|
|
- ES_URL=http://tubearchivist-es:9200
|
|
- REDIS_CON=redis://tubearchivist-redis:6379
|
|
- HOST_UID=${PUID:?err}
|
|
- HOST_GID=${PGID:?err}
|
|
- ELASTIC_PASSWORD=tubearchivist
|
|
- TA_HOST=tubearchivist.gharbeia.net
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/tubearchivist/media:/youtube
|
|
- ${FOLDER_FOR_DATA:?err}/tubearchivist/cache:/cache
|
|
depends_on:
|
|
tubearchivist-es:
|
|
condition: service_healthy
|
|
tubearchivist-redis:
|
|
condition: service_healthy
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.tubearchivist.service=tubearchivist
|
|
- traefik.http.routers.tubearchivist.rule=Host(`tubearchivist.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.tubearchivist.entrypoints=tunnel
|
|
- traefik.http.routers.tubearchivist.middlewares=authentik-forwardauth@file,security-headers@file,traefik-bouncer@file
|
|
- traefik.http.services.tubearchivist.loadbalancer.server.scheme=http
|
|
- traefik.http.services.tubearchivist.loadbalancer.server.port=8000
|
|
|
|
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
|
|
#+END_SRC
|
|
|
|
** Remaining Services
|
|
|
|
The following services follow the same pattern as those documented above.
|
|
Each is a YAML fragment in =/docker/compose/services/= with its container
|
|
definition, environment, volumes, and Traefik labels.
|
|
|
|
- =traefik.yaml= — Reverse proxy (documented above)
|
|
- =traefik-certs-dumper.yaml= — Export Let's Encrypt certs for other services
|
|
- =crowdsec.yaml= — Intrusion prevention (blocks malicious IPs via Traefik bouncer)
|
|
- =cloudflared.yaml= — Cloudflare Tunnel client
|
|
- =gharbeia-site.yaml= — Static website via nginx
|
|
- =homepage.yaml, homarr.yaml, heimdall.yaml= — Dashboard UIs
|
|
- =grafana.yaml, prometheus.yaml= — Monitoring stack
|
|
- =headscale.yaml, tailscale.yaml, headplane.yaml= — Wireguard mesh VPN
|
|
- =ddns-updater.yaml= — Dynamic DNS
|
|
- =portainer.yaml= — Docker GUI
|
|
- =guacamole.yaml, guacd.yaml= — Remote desktop gateway
|
|
- =unpackerr.yaml= — Archive extraction for *arr downloads
|
|
- =runner.yaml= — Gitea Actions runner
|
|
- =bazarr.yaml, flaresolverr.yaml= — Subtitle downloader, Cloudflare bypass
|
|
- =jellyfin.yaml, jellyseerr.yaml= — Media server + request manager
|
|
- =lazylibrarian.yaml, lidarr.yaml, mylar.yaml= — Ebook, music, comic managers
|
|
- =prowlarr.yaml, radarr.yaml, sonarr.yaml, whisparr.yaml= — *arr indexer + library managers
|
|
- =qbittorrent.yaml, sabnzbd.yaml= — Torrent and usenet clients
|
|
- =stash.yaml= — Adult content library manager
|
|
- =tdarr.yaml, tdarr-node.yaml= — Media transcoding automation
|
|
- =tubearchivist.yaml= — YouTube archiving (Tube Archivist)
|
|
- =audiobookshelf.yaml= — Audiobook and podcast server
|
|
|
|
* .env Configuration
|
|
|
|
The =.env= file at =/docker/compose/.env= holds all variable values.
|
|
The =:?err= suffix on every variable ensures missing values fail fast.
|
|
|
|
Key variables:
|
|
- =DOCKER_SUBNET= and =DOCKER_GATEWAY= define the Docker bridge network
|
|
- =CLOUDFLARE_DNS_ZONE= (=gharbeia.net=) is used in all Traefik routes
|
|
- =PUID= and =PGID= control file ownership (1000:1000)
|
|
- =TUNNEL_TOKEN= is the Cloudflare tunnel auth token (managed externally)
|
|
- =TA_USERNAME= and =TA_PASSWORD= — Tube Archivist admin credentials
|
|
|
|
* LOGBOOK
|
|
|
|
** [2026-05-16 Sat 22:45] Tube Archivist installed
|
|
- 3-container stack: tubearchivist, ES 8.17, Redis
|
|
- Traefik secureweb/tunnel/internal routers
|
|
- Static TA_HOST=tubearchivist.gharbeia.net, ELASTIC_PASSWORD=tubearchivist
|
|
- REDIS_CON connection string (newer TA uses this instead of REDIS_HOST+REDIS_PORT)
|
|
- ES 8.17 with path_repo and xpack.security.enabled=false
|
|
|
|
** [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow
|
|
- Root cause: Jellyfin's empty KnownProxies caused SSO plugin to use HTTP
|
|
base URL, breaking the JavaScript two-step auth flow (iframe/POST/redirect)
|
|
- Fix: Added 172.28.10.0/24 and 172.28.10.4 to Jellyfin's KnownProxies
|
|
- Created jellyfin_admin Authentik group + linked user amr to it
|
|
- Set EnableAuthorization=false in SSO-Auth plugin config
|
|
- Documented three authentication mechanisms: Forward Auth, OIDC/SSO, local users
|
|
- Fixed ASCII tree diagrams by wrapping in #+BEGIN_EXAMPLE blocks
|
|
- Fixed trees rendering issue: Unicode box-drawing chars must be inside
|
|
example blocks in org-mode, otherwise font rendering mangles them
|
|
|
|
** [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth
|
|
- Fixed Emacs org-loaddefs.el path in tangle-deploy
|
|
- Created Gitea access token for git operations
|
|
- Replaced Gitea Action workflow with systemd timer
|
|
- tangle-deploy now pulls, tangles, and restarts on a 5-minute timer
|
|
|
|
** [2026-05-15 Thu 06:50] Monolith split into modular compose
|
|
- 42 service fragments created under /docker/compose/services/
|
|
- Master docker-compose.yaml uses include: directives (43 services total)
|
|
- All service labels and env vars preserved from original monolith
|
|
- Compose validated with --env-file .env, all 43 services resolve
|
|
- Deployment verified: all containers running
|
|
- Orphaned unbound container absorbed into compose (was started manually)
|
|
|
|
** [2026-05-15 Thu 03:47] Literate infrastructure established
|
|
- infrastructure.org becomes the source of truth -- all config files are
|
|
tangle targets embedded as =#+BEGIN_SRC= blocks with absolute paths
|
|
- =tangle-deploy= script installed at =/usr/local/bin/tangle-deploy= on
|
|
production-1; run after git push to regenerate configs and restart services
|
|
- Gitea repo: =git@git.gharbeia.net:amr/infrastructure.git=
|
|
|
|
** [2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner
|
|
- Created internal entrypoint on port 8083 for service-to-service traffic
|
|
- Updated Gitea runner URL to use internal entrypoint
|
|
- Documented three-path architecture
|
|
|
|
** [2026-05-15 Thu 02:56] Static site and Error 1033 fix
|
|
- Added gharbeia-site nginx container for root domain
|
|
- Fixed CNAME record for bare domain pointing to correct tunnel
|
|
|
|
** [2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org
|
|
- Configured Jellyfin SSO-Auth plugin with Authentik OIDC
|
|
- Removed Forward Auth from Jellyfin Traefik labels
|
|
- Created infrastructure.org as source of truth
|
|
- Added Forward Auth to internal LAN routers
|