Files
infrastructure/infrastructure.org

35 KiB
Raw Blame History

Infrastructure Documentation — gharbeia.net

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='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 ==='

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.

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

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.

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

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:

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

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.

# 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.

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.

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

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

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.

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

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.

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

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

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