35 KiB
Infrastructure Documentation — gharbeia.net
- Architecture
- Traefik — Reverse Proxy
- Docker Services
- .env Configuration
- LOGBOOK
- [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth
- [2026-05-15 Thu 06:50] Monolith split into modular compose
- [2026-05-15 Thu 03:47] Literate infrastructure established
- [2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner
- [2026-05-15 Thu 02:56] Static site and Error 1033 fix
- [2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org
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 bothgharbeia.netand*.gharbeia.net.internal(:8083) is plain HTTP for service-to-service traffic. TLS overhead is unnecessary on the internal bridge network.metrics(:8082) exposes Prometheus metrics, manually routed.dnsChallengewith Cloudflare provider issues wildcard certs. The 60s propagation delay avoids rate-limit issues with Cloudflare's API.
Dynamic Configuration — Middleware
Shared middleware used by all routers. Defined once here, referenced by name in every router block.
http:
middlewares:
authentik-forwardauth:
forwardAuth:
address: http://authentik-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:
- Each service is self-contained with its own prose documentation
- Adding or removing a service is a single line in the master compose
- Differences between deployments (e.g. test vs production) are just different include lists
Master Compose
The master compose defines the shared network and includes all service fragments.
It is the single entry point for docker compose commands.
networks:
networking:
name: networking
driver: bridge
ipam:
driver: default
config:
- subnet: ${DOCKER_SUBNET:?err}
gateway: ${DOCKER_GATEWAY:?err}
include:
- services/gluetun.yaml
- services/postgresql.yaml
- services/valkey.yaml
- services/authentik.yaml
- services/authentic-worker.yaml
- services/traefik.yaml
- services/traefik-certs-dumper.yaml
- services/crowdsec.yaml
- services/gitea.yaml
- services/runner.yaml
- services/cloudflared.yaml
- services/gharbeia-site.yaml
- services/unbound.yaml
- services/homepage.yaml
- services/homarr.yaml
- services/heimdall.yaml
- services/grafana.yaml
- services/prometheus.yaml
- services/headscale.yaml
- services/tailscale.yaml
- services/headplane.yaml
- services/ddns-updater.yaml
- services/portainer.yaml
- services/guacamole.yaml
- services/guacd.yaml
- services/unpackerr.yaml
- services/bazarr.yaml
- services/flaresolverr.yaml
- services/jellyfin.yaml
- services/jellyseerr.yaml
- services/lazylibrarian.yaml
- services/lidarr.yaml
- services/mylar.yaml
- services/prowlarr.yaml
- services/qbittorrent.yaml
- services/radarr.yaml
- services/sabnzbd.yaml
- services/sonarr.yaml
- services/stash.yaml
- services/tdarr.yaml
- services/tdarr-node.yaml
- services/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_hostsresolves *.gharbeia.net to 10.10.10.201 so VPN-routed services can reach Traefik without leaking DNS- The
FIREWALL_OUTBOUND_SUBNETSallows LAN access through the VPN
services:
gluetun:
image: qmcgaw/gluetun:latest
container_name: gluetun
restart: always
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
ports:
- 8888:8888/tcp
- 8388:8388/tcp
- 8388:8388/udp
- ${GLUETUN_CONTROL_PORT:?err}:${GLUETUN_CONTROL_PORT:?err}
- ${WEBUI_PORT_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, policiesauthentic-worker— background tasks, outpost management
Both connect to the same Postgres and Valkey databases.
services:
authentik:
image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:?err}
container_name: authentik
restart: unless-stopped
networks:
- networking
user: ${PUID:?err}:${PGID:?err}
command: server
environment:
- TZ=${TIMEZONE:?err}
- AUTHENTIK_LOG_LEVEL=info
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY:?err}
- AUTHENTIK_REDIS__HOST=valkey
- AUTHENTIK_POSTGRESQL__HOST=postgresql
- AUTHENTIK_POSTGRESQL__NAME=${AUTHENTIK_DATABASE:?err}
- AUTHENTIK_POSTGRESQL__USER=${POSTGRESQL_USERNAME:?err}
- AUTHENTIK_POSTGRESQL__PASSWORD=${POSTGRESQL_PASSWORD:?err}
- AUTHENTIK_ERROR_REPORTING__ENABLED=false
- AUTHENTIK_EMAIL__HOST=${EMAIL_SERVER_HOST}
- AUTHENTIK_EMAIL__PORT=${EMAIL_SERVER_PORT}
- AUTHENTIK_EMAIL__USERNAME=${EMAIL_ADDRESS}
- AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD}
- AUTHENTIK_EMAIL__USE_TLS=true
- AUTHENTIK_EMAIL__USE_SSL=false
- AUTHENTIK_EMAIL__FROM=${EMAIL_SENDER}
- AUTHENTIK_EMAIL__TIMEOUT=15
volumes:
- ${FOLDER_FOR_DATA:?err}/authentik/media:/media
- ${FOLDER_FOR_DATA:?err}/authentik/templates:/templates
ports:
- ${WEBUI_PORT_AUTHENTIK:?err}:9000
depends_on:
postgresql:
condition: service_healthy
restart: true
valkey:
condition: service_healthy
restart: true
labels:
- traefik.enable=true
- traefik.http.routers.authentik.service=authentik
- traefik.http.routers.authentik.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 servicescrowdsec.yaml— Intrusion prevention (blocks malicious IPs via Traefik bouncer)cloudflared.yaml— Cloudflare Tunnel clientgharbeia-site.yaml— Static website via nginxhomepage.yaml, homarr.yaml, heimdall.yaml— Dashboard UIsgrafana.yaml, prometheus.yaml— Monitoring stackheadscale.yaml, tailscale.yaml, headplane.yaml— Wireguard mesh VPNddns-updater.yaml— Dynamic DNSportainer.yaml— Docker GUIguacamole.yaml, guacd.yaml— Remote desktop gatewayunpackerr.yaml— Archive extraction for *arr downloadsrunner.yaml— Gitea Actions runnerbazarr.yaml, flaresolverr.yaml— Subtitle downloader, Cloudflare bypassjellyfin.yaml, jellyseerr.yaml— Media server + request managerlazylibrarian.yaml, lidarr.yaml, mylar.yaml— Ebook, music, comic managersprowlarr.yaml, radarr.yaml, sonarr.yaml, whisparr.yaml— *arr indexer + library managersqbittorrent.yaml, sabnzbd.yaml— Torrent and usenet clientsstash.yaml— Adult content library managertdarr.yaml, tdarr-node.yaml— Media transcoding automationaudiobookshelf.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_SUBNETandDOCKER_GATEWAYdefine the Docker bridge networkCLOUDFLARE_DNS_ZONE(gharbeia.net) is used in all Traefik routesPUIDandPGIDcontrol file ownership (1000:1000)TUNNEL_TOKENis 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_SRCblocks with absolute paths tangle-deployscript installed at/usr/local/bin/tangle-deployon production-1; run after git push to regenerate configs and restart services- Gitea repo:
git@git.gharbeia.net:amr/infrastructure.git
[2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner
- Created internal entrypoint on port 8083 for service-to-service traffic
- Updated Gitea runner URL to use internal entrypoint
- Documented three-path architecture
[2026-05-15 Thu 02:56] Static site and Error 1033 fix
- Added gharbeia-site nginx container for root domain
- Fixed CNAME record for bare domain pointing to correct tunnel
[2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org
- Configured Jellyfin SSO-Auth plugin with Authentik OIDC
- Removed Forward Auth from Jellyfin Traefik labels
- Created infrastructure.org as source of truth
- Added Forward Auth to internal LAN routers