#+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 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=. #+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='http://amr:tangle-deploy-2026@10.10.10.201:3001/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 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 #+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 * Docker Services 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/audiobookshelf.yaml - services/whisparr.yaml #+END_SRC All 43 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. ** 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 ** 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 - =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) * LOGBOOK ** [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