The OIDC auto-discovery uses internal Docker hostnames. The browser can't resolve authentik:9000, so CustomURLMapping.auth_url must be set to the public URL in Gitea's database. This change is manual (not tangled), so document the SQL and rationale.
1917 lines
64 KiB
Org Mode
1917 lines
64 KiB
Org Mode
:PROPERTIES:
|
|
:CREATED: [2026-05-11 Mon]
|
|
:END:
|
|
#+TITLE: Infrastructure Documentation — gharbeia.net
|
|
#+AUTHOR: Amr Gharbeia
|
|
#+DATE: 2026-05-15
|
|
|
|
* Architecture
|
|
|
|
** Hosts
|
|
- =production-1= (10.10.10.201) :: Docker host, runs all services
|
|
- Hermes Agent :: Management/automation host
|
|
|
|
** Network
|
|
- Docker network =networking= (172.28.10.0/24)
|
|
- Proxmox VLANs: 1/10/20/30/40/50
|
|
- Services VLAN: 10.10.10.0/24
|
|
- Domain: gharbeia.net via Cloudflare (orange cloud/proxied)
|
|
|
|
** External Access Architecture
|
|
|
|
#+BEGIN_EXAMPLE
|
|
Cloudflare (edge, orange cloud)
|
|
└─ Cloudflare Tunnel "home" (cloudflared on production-1)
|
|
└─ Traefik (entrypoint=tunnel, port 8081)
|
|
├─ Authentik Forward Auth (external routers)
|
|
├─ gharbeia-site (nginx)
|
|
├─ jellyfin (SSO via plugin + OIDC)
|
|
├─ gitea (native OIDC)
|
|
└─ *.gharbeia.net services
|
|
#+END_EXAMPLE
|
|
|
|
** Internal Access Architecture
|
|
|
|
#+BEGIN_EXAMPLE
|
|
LAN client (browser)
|
|
└─ Traefik (entrypoint=secureweb, port 443)
|
|
├─ Authentik Forward Auth (internal.yaml routers)
|
|
├─ gharbeia-site (public, no auth)
|
|
├─ jellyfin (SSO via plugin)
|
|
└─ *.gharbeia.net services
|
|
|
|
Service-to-service / automation / cross-VLAN
|
|
└─ Traefik (entrypoint=internal, port 8083 — NO auth)
|
|
└─ Same routing as secureweb, from traefik-internal-noauth.yaml
|
|
#+END_EXAMPLE
|
|
|
|
Key distinction: =:443= = browsers/humans with Authentik auth.
|
|
=:8083= = runners, automated tooling, services on other VLANs.
|
|
|
|
** Tangle & Deploy Pipeline
|
|
|
|
Changes are made to this org file, tangled into config files by the
|
|
=tangle-deploy= script on production-1, then deployed via =docker compose=.
|
|
|
|
#+BEGIN_SRC bash :tangle /docker/compose/infrastructure/tangle-deploy.sh
|
|
#!/usr/bin/env bash
|
|
# tangle-deploy — Tangle infrastructure.org and restart affected services
|
|
GITEA_URL='ssh://git@git.gharbeia.net:2222/amr/infrastructure.git'
|
|
REPO_DIR="${1:-/docker/compose/infrastructure}"
|
|
ORG_FILE="${REPO_DIR}/infrastructure.org"
|
|
if [ -z "${1:-}" ]; then
|
|
if [ ! -d "$REPO_DIR" ]; then
|
|
git clone "$GITEA_URL" "$REPO_DIR"
|
|
else
|
|
cd "$REPO_DIR" && git pull
|
|
fi
|
|
fi
|
|
if [ ! -f "$ORG_FILE" ]; then
|
|
echo "ERROR: $ORG_FILE not found in $REPO_DIR"
|
|
exit 1
|
|
fi
|
|
echo "=== Tangling $ORG_FILE ==="
|
|
emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \
|
|
--eval "(require 'org)" \
|
|
--eval "(require 'ob-shell)" \
|
|
--eval '(let ((org-confirm-babel-evaluate nil)) (org-babel-tangle-file "'"$ORG_FILE"'"))' 2>&1
|
|
echo "=== Restarting services ==="
|
|
cd /docker/compose
|
|
if [ -f /docker/appdata/traefik/traefik.yaml ] || \
|
|
[ -f /docker/appdata/traefik/internal.yaml ] || \
|
|
[ -f /docker/appdata/traefik/internal-noauth.yaml ] || \
|
|
[ -f /docker/appdata/traefik/dynamic.yaml ]; then
|
|
echo 'Traefik config changed -- restarting...'
|
|
docker compose up -d traefik
|
|
fi
|
|
if [ -f /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.
|
|
** Tangle Destination Convention
|
|
|
|
Each tangle target in this file must go to the directory where the
|
|
consuming process reads it. The convention is:
|
|
|
|
- Traefik config files (static, dynamic, internal, internal-noauth) go to
|
|
=/docker/appdata/traefik/= because that directory is bind-mounted into the
|
|
Traefik container at =/etc/traefik=.
|
|
- =docker-compose.yaml= and service fragments go to =/docker/compose/= because
|
|
docker compose reads from there.
|
|
- =tangle-deploy.sh= stays in =/docker/compose/infrastructure/= alongside this
|
|
file so the Git repo is self-contained.
|
|
|
|
Previously these paths pointed to =/docker/compose/= for the Traefik configs.
|
|
The tangle-deploy pipeline ran and claimed success, but the generated files were
|
|
never read by Traefik. The running configs in =/docker/appdata/traefik/= were
|
|
hand-edited copies that drifted from the org. This was discovered when adding
|
|
the =tunnel= entrypoint to the internal routers: the edit propagated to the
|
|
tangled file in =/docker/compose/=, but Traefik kept serving the stale
|
|
hand-edited copy in =/docker/appdata/traefik/=.
|
|
|
|
* Traefik — Reverse Proxy
|
|
|
|
Traefik is the edge router for all HTTP traffic. It handles TLS termination via
|
|
Let's Encrypt (DNS-01 challenge through Cloudflare), routes traffic to the right
|
|
container, and applies middleware chains for auth, security, and rate limiting.
|
|
|
|
Three entrypoints:
|
|
|
|
- =tunnel= (=:8081=) :: Receives traffic from the Cloudflare tunnel. All routers
|
|
here have Authentik Forward Auth.
|
|
- =secureweb= (=:443=) :: Internal LAN traffic with TLS. Also has Authentik
|
|
Forward Auth for browser access.
|
|
- =internal= (=:8083=) :: Service-to-service and cross-VLAN traffic. No auth.
|
|
HTTP only. For runners, automation, and API calls that shouldn't hit Authentik.
|
|
|
|
** Static Configuration
|
|
|
|
The static config sets entrypoints, TLS resolvers, providers, and plugins.
|
|
It is the foundation everything else builds on.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/appdata/traefik/traefik.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.
|
|
|
|
|
|
#+NAME: crowdsec_key
|
|
#+BEGIN_SRC sh :results output :exports none
|
|
printf "%s" "Xvx3UTjAdThkqtNuVhciWzEOJuBZoWH58KE+E7C3L6I"
|
|
#+END_SRC
|
|
#+BEGIN_SRC yaml :tangle /docker/appdata/traefik/dynamic.yaml :noweb yes
|
|
http:
|
|
middlewares:
|
|
|
|
authentik-forwardauth:
|
|
forwardAuth:
|
|
address: http://authentik:9000/outpost.goauthentik.io/auth/traefik
|
|
trustForwardHeader: true
|
|
authResponseHeaders:
|
|
- X-authentik-username
|
|
- X-authentik-groups
|
|
- X-authentik-email
|
|
- X-authentik-name
|
|
- X-authentik-uid
|
|
|
|
security-headers:
|
|
headers:
|
|
customFrameOptionsValue: SAMEORIGIN
|
|
contentTypeNosniff: true
|
|
browserXssFilter: true
|
|
referrerPolicy: no-referrer
|
|
permissionsPolicy: ""
|
|
customResponseHeaders:
|
|
X-Robots-Tag: "noindex, nofollow"
|
|
Server: ""
|
|
|
|
traefik-bouncer:
|
|
plugin:
|
|
crowdsec-bouncer-traefik-plugin:
|
|
enabled: "true"
|
|
crowdsecMode: live
|
|
crowdsecLapiKey: <<crowdsec_key()>>
|
|
crowdsecLapiHost: crowdsec:8080
|
|
crowdsecLapiScheme: http
|
|
updateFrequencySec: 5
|
|
defaultDecisionLifetimeSec: 60
|
|
|
|
compress:
|
|
compress:
|
|
excludedContentTypes:
|
|
- text/event-stream
|
|
|
|
ratelimit:
|
|
rateLimit:
|
|
average: 100
|
|
burst: 50
|
|
#+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/appdata/traefik/internal.yaml
|
|
http:
|
|
routers:
|
|
|
|
|
|
authentik-outpost:
|
|
rule: "HostRegexp(`.{1,}.gharbeia.net`) && PathPrefix(`/outpost.goauthentik.io/`)"
|
|
service: authentik-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- security-headers@file
|
|
|
|
# -- Media & Streaming -----------------------------------------
|
|
|
|
jellyfin:
|
|
rule: "Host(`jellyfin.gharbeia.net`)"
|
|
service: jellyfin-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
jellyseerr:
|
|
rule: "Host(`jellyseerr.gharbeia.net`)"
|
|
service: jellyseerr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
tdarr:
|
|
rule: "Host(`tdarr.gharbeia.net`)"
|
|
service: tdarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
stash:
|
|
rule: "Host(`stash.gharbeia.net`)"
|
|
service: stash-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- *arr Suite -------------------------------------------------
|
|
|
|
radarr:
|
|
rule: "Host(`radarr.gharbeia.net`)"
|
|
service: radarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
sonarr:
|
|
rule: "Host(`sonarr.gharbeia.net`)"
|
|
service: sonarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
lidarr:
|
|
rule: "Host(`lidarr.gharbeia.net`)"
|
|
service: lidarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
prowlarr:
|
|
rule: "Host(`prowlarr.gharbeia.net`)"
|
|
service: prowlarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
whisparr:
|
|
rule: "Host(`whisparr.gharbeia.net`)"
|
|
service: whisparr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
mylar:
|
|
rule: "Host(`mylar.gharbeia.net`)"
|
|
service: mylar-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
lazylibrarian:
|
|
rule: "Host(`lazylibrarian.gharbeia.net`)"
|
|
service: lazylibrarian-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
bazarr:
|
|
rule: "Host(`bazarr.gharbeia.net`)"
|
|
service: bazarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Downloaders ------------------------------------------------
|
|
|
|
sabnzbd:
|
|
rule: "Host(`sabnzbd.gharbeia.net`)"
|
|
service: sabnzbd-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
qbittorrent:
|
|
rule: "Host(`qbittorrent.gharbeia.net`)"
|
|
service: qbittorrent-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
flaresolverr:
|
|
rule: "Host(`flaresolverr.gharbeia.net`)"
|
|
service: flaresolverr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
unpackerr:
|
|
rule: "Host(`unpackerr.gharbeia.net`)"
|
|
service: unpackerr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Homepage / Dashboards --------------------------------------
|
|
|
|
homepage:
|
|
rule: "Host(`homepage.gharbeia.net`)"
|
|
service: homepage-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
homarr:
|
|
rule: "Host(`homarr.gharbeia.net`)"
|
|
service: homarr-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
heimdall:
|
|
rule: "Host(`heimdall.gharbeia.net`)"
|
|
service: heimdall-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Monitoring ------------------------------------------------
|
|
|
|
grafana:
|
|
rule: "Host(`grafana.gharbeia.net`)"
|
|
service: grafana-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
prometheus:
|
|
rule: "Host(`prometheus.gharbeia.net`)"
|
|
service: prometheus-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
# -- Website (public, no auth) ----------------------------------
|
|
|
|
gharbeia-site:
|
|
rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)"
|
|
service: gharbeia-site-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
|
|
# -- Brain Knowledge Base (private, behind Authentik) ------------
|
|
|
|
brain:
|
|
rule: "Host(`brain.gharbeia.net`)"
|
|
service: brain-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
|
|
# -- Management ------------------------------------------------
|
|
|
|
gitea:
|
|
rule: "Host(`git.gharbeia.net`)"
|
|
service: gitea-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
# No authentik-forwardauth -- Gitea has native OIDC
|
|
|
|
portainer:
|
|
rule: "Host(`portainer.gharbeia.net`)"
|
|
service: portainer-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
authentik:
|
|
rule: "Host(`auth.gharbeia.net`)"
|
|
service: authentik-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
# No authentik-forwardauth -- otherwise auth loops
|
|
|
|
headscale:
|
|
rule: "Host(`headscale.gharbeia.net`)"
|
|
service: headscale-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
# No authentik-forwardauth -- Tailscale clients need direct access
|
|
|
|
headplane:
|
|
rule: "Host(`headplane.gharbeia.net`) && PathPrefix(`/admin/`)"
|
|
service: headplane-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
ddns-updater:
|
|
rule: "Host(`ddns-updater.gharbeia.net`)"
|
|
service: ddns-updater-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
audiobookshelf:
|
|
rule: "Host(`audiobookshelf.gharbeia.net`)"
|
|
service: audiobookshelf-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
guacamole:
|
|
rule: "Host(`guacamole.gharbeia.net`)"
|
|
service: guacamole-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
tubearchivist:
|
|
rule: "Host(`tubearchivist.gharbeia.net`)"
|
|
service: tubearchivist-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
traefik-dashboard:
|
|
rule: "Host(`traefik.gharbeia.net`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
|
|
service: traefik-dashboard-internal
|
|
entryPoints:
|
|
- tunnel
|
|
- secureweb
|
|
middlewares:
|
|
- authentik-forwardauth@file
|
|
- security-headers@file
|
|
- traefik-bouncer@file
|
|
|
|
services:
|
|
jellyfin-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://jellyfin:8096
|
|
jellyseerr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://jellyseerr:5055
|
|
radarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://radarr:7878
|
|
sonarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://sonarr:8989
|
|
lidarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://lidarr:8686
|
|
prowlarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://prowlarr:9696
|
|
whisparr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://whisparr:6969
|
|
mylar-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://mylar:8090
|
|
lazylibrarian-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://lazylibrarian:5299
|
|
sabnzbd-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://gluetun:8080
|
|
qbittorrent-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://gluetun:8200
|
|
flaresolverr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://flaresolverr:8191
|
|
homepage-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://homepage:3000
|
|
homarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://homarr:7575
|
|
heimdall-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://heimdall:80
|
|
grafana-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://grafana:3000
|
|
prometheus-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://prometheus:9090
|
|
gharbeia-site-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://10.10.10.29:8083
|
|
brain-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: "http://10.10.10.29:8082"
|
|
gitea-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://gitea:3000
|
|
portainer-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://portainer:9000
|
|
authentik-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://authentik:9000
|
|
headscale-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://headscale:8080
|
|
headplane-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://headplane:3000
|
|
ddns-updater-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://ddns-updater:8310
|
|
audiobookshelf-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://audiobookshelf:80 # standalone, no longer behind gluetun on port 80 inside gluetun
|
|
guacamole-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://guacamole:8080
|
|
traefik-dashboard-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://traefik:8080
|
|
tubearchivist-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://tubearchivist:8000 # standalone, no longer behind gluetun on port 8000 inside gluetun
|
|
bazarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://bazarr:6767
|
|
tdarr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://tdarr:8265
|
|
stash-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://stash:7777
|
|
unpackerr-internal:
|
|
loadBalancer:
|
|
servers:
|
|
- url: http://unpackerr:5656
|
|
#+END_SRC
|
|
|
|
All 28 routers follow the same pattern. The service URLs point to Docker DNS
|
|
names on the =networking= bridge.
|
|
Services that need VPN routing (sabnzbd, qbittorrent) use
|
|
=network_mode: service:gluetun= and are reached via =http://gluetun:<port>=.
|
|
Services that DO NOT need the VPN (jellyfin, sonarr, radarr, etc.) run
|
|
standalone on the bridge and are reached via =http://servicename:<port>=.
|
|
This split was made because the VPN is a single point of failure — when
|
|
gluetun goes down, only actual downloaders are affected, not the library
|
|
management or streaming services.
|
|
** Internal Routers — No Auth (internal :8083)
|
|
|
|
An identical set of routers without the =authentik-forwardauth= middleware.
|
|
Used by service-to-service traffic, Gitea runner, and cross-VLAN automation.
|
|
Generated by stripping the auth middleware from =traefik-internal.yaml=.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/appdata/traefik/internal-noauth.yaml
|
|
# This file is maintained manually as a copy of traefik-internal.yaml
|
|
# with all authentik-forwardauth middleware references removed.
|
|
# See: docker/appdata/traefik/internal-noauth.yaml for the production copy.
|
|
#+END_SRC
|
|
|
|
** Authentication Architecture
|
|
|
|
Three authentication mechanisms depending on the service type:
|
|
|
|
*** Forward Auth (default for web-only services)
|
|
|
|
Traefik middleware intercepts every request and redirects unauthenticated
|
|
users to the Authentik login page. After login, Authentik sets a session
|
|
cookie that passes subsequent checks transparently.
|
|
|
|
Used by: all =*arr=, dashboards, monitoring, Portainer, Guacamole, etc.
|
|
Limitation: only works in browsers — native/TV apps can't use Forward Auth.
|
|
|
|
*** Native OIDC / SSO (for services with apps)
|
|
|
|
Services that have native mobile or TV apps need real OIDC integration so
|
|
the app can authenticate directly via a browser-based login flow.
|
|
|
|
- Gitea: configured with Authentik OIDC provider in Gitea's admin panel.
|
|
Users log in via "Sign in with Authentik" button on the Gitea login page.
|
|
Existing user accounts are linked by username match.
|
|
|
|
*Critical manual fix:* The OIDC auto-discovery URL uses the internal
|
|
Docker hostname (=http://authentik:9000/application/o/gitea-oidc/...=).
|
|
This is correct for Gitea's backend (token/userinfo endpoints) but
|
|
Authentik's discovery response returns =http://authentik:9000/...= for
|
|
=authorization_endpoint=, which the browser cannot resolve.
|
|
|
|
To fix: add =CustomURLMapping.auth_url= in Gitea's =login_source= table
|
|
so the redirect uses the public URL:
|
|
|
|
#+BEGIN_SRC sql :eval never
|
|
UPDATE login_source SET cfg = json_set(cfg, '$.CustomURLMapping',
|
|
json_object('auth_url', 'https://auth.gharbeia.net/application/o/gitea-oidc/authorize'))
|
|
WHERE id = 1;
|
|
#+END_SRC
|
|
|
|
This change is in Gitea's SQLite database at =/data/gitea/gitea.db=,
|
|
not managed by tangling. It survives container rebuilds because the
|
|
database volume persists. To verify: =SELECT json_extract(cfg,
|
|
'$.CustomURLMapping') FROM login_source WHERE id=1;= should show the
|
|
mapping. Restart Gitea after updating (=docker restart gitea=).
|
|
|
|
- Jellyfin: uses the SSO-Auth plugin (v4.0.0.4) with an Authentik OIDC
|
|
provider (client_id = =jellyfin-sso=). The plugin does a two-step flow:
|
|
1. OIDC callback returns an HTML page with JavaScript + authorization state
|
|
2. JavaScript POSTs to =/sso/OID/Auth/Authentik= to complete the login
|
|
|
|
Critical detail: Jellyfin must trust Traefik's =X-Forwarded-Proto= header
|
|
or the JavaScript will construct URLs with =http://= instead of =https://=.
|
|
This is configured via =KnownProxies= in =/config/network.xml=:
|
|
|
|
#+BEGIN_SRC xml
|
|
<KnownProxies>
|
|
<string>172.28.10.0/24</string>
|
|
<string>172.28.10.4</string>
|
|
</KnownProxies>
|
|
#+END_SRC
|
|
|
|
Without this, =GetRequestBase()= returns =http://jellyfin.gharbeia.net= and
|
|
the iframe, auth POST, and final redirect all use the wrong scheme.
|
|
|
|
*** Local users (TV apps, fallback)
|
|
|
|
TV apps (Android TV, webOS, Tizen) often can't complete the OIDC JavaScript
|
|
two-step flow inside their embedded browser. For these, create a dedicated
|
|
Jellyfin local user (e.g. =tv=) with a simple password. The app logs in
|
|
directly with password — no SSO involved. The user keeps library access
|
|
without losing admin history/settings.
|
|
|
|
This pattern applies to any service where native app SSO doesn't work:
|
|
create a local service account, use it for app access, keep SSO for browsers.
|
|
|
|
The entire stack runs on =production-1= using Docker Compose. Services are
|
|
split into individual YAML fragment files under =/docker/compose/services/=,
|
|
referenced by the master compose via =include:= directives.
|
|
|
|
This splitting has three benefits:
|
|
1. Each service is self-contained with its own prose documentation
|
|
2. Adding or removing a service is a single line in the master compose
|
|
3. Differences between deployments (e.g. test vs production) are just different
|
|
include lists
|
|
|
|
** Master Compose
|
|
|
|
The master compose defines the shared network and includes all service fragments.
|
|
It is the single entry point for =docker compose= commands.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/docker-compose.yaml
|
|
networks:
|
|
networking:
|
|
name: networking
|
|
driver: bridge
|
|
ipam:
|
|
driver: default
|
|
config:
|
|
- subnet: ${DOCKER_SUBNET:?err}
|
|
gateway: ${DOCKER_GATEWAY:?err}
|
|
|
|
include:
|
|
- services/gluetun.yaml
|
|
- services/postgresql.yaml
|
|
- services/valkey.yaml
|
|
- services/authentik.yaml
|
|
- services/authentic-worker.yaml
|
|
- services/traefik.yaml
|
|
- services/traefik-certs-dumper.yaml
|
|
- services/crowdsec.yaml
|
|
- services/gitea.yaml
|
|
- services/runner.yaml
|
|
- services/cloudflared.yaml
|
|
- services/gharbeia-site.yaml
|
|
- services/unbound.yaml
|
|
- services/homepage.yaml
|
|
- services/homarr.yaml
|
|
- services/heimdall.yaml
|
|
- services/grafana.yaml
|
|
- services/prometheus.yaml
|
|
- services/headscale.yaml
|
|
- services/tailscale.yaml
|
|
- services/headplane.yaml
|
|
- services/ddns-updater.yaml
|
|
- services/portainer.yaml
|
|
- services/guacamole.yaml
|
|
- services/guacd.yaml
|
|
- services/unpackerr.yaml
|
|
- services/bazarr.yaml
|
|
- services/flaresolverr.yaml
|
|
- services/jellyfin.yaml
|
|
- services/jellyseerr.yaml
|
|
- services/lazylibrarian.yaml
|
|
- services/lidarr.yaml
|
|
- services/mylar.yaml
|
|
- services/prowlarr.yaml
|
|
- services/qbittorrent.yaml
|
|
- services/radarr.yaml
|
|
- services/sabnzbd.yaml
|
|
- services/sonarr.yaml
|
|
- services/stash.yaml
|
|
- services/tdarr.yaml
|
|
- services/tdarr-node.yaml
|
|
- services/tubearchivist.yaml
|
|
- services/audiobookshelf.yaml
|
|
- services/audiomuse.yaml
|
|
- services/whisparr.yaml
|
|
#+END_SRC
|
|
|
|
All 44 services are organized alphabetically by category in the include list.
|
|
The order matters for startup dependencies: infrastructure services (gluetun,
|
|
postgresql, valkey, authentik, traefik) come first.
|
|
|
|
** Jellyfin — Media Server
|
|
|
|
Jellyfin serves media libraries through the browser and native apps. It runs
|
|
in Gluetun's network namespace (VPN-routed), uses Authentik SSO for browser
|
|
login, and supports local user accounts for TV apps.
|
|
|
|
*** KnownProxies (Critical for SSO)
|
|
|
|
Jellyfin sits behind Traefik reverse proxy. Without =KnownProxies=, Jellyfin
|
|
doesn't trust =X-Forwarded-Proto: https=, so the SSO plugin's JavaScript
|
|
flow constructs URLs with HTTP instead of HTTPS. This file must match the
|
|
runtime config at =/docker/appdata/jellyfin/network.xml=.
|
|
|
|
#+BEGIN_SRC xml :tangle /docker/appdata/jellyfin/network.xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
<BaseUrl />
|
|
<EnableHttps>false</EnableHttps>
|
|
<RequireHttps>false</RequireHttps>
|
|
<CertificatePath />
|
|
<CertificatePassword />
|
|
<InternalHttpPort>8096</InternalHttpPort>
|
|
<InternalHttpsPort>8920</InternalHttpsPort>
|
|
<PublicHttpPort>8096</PublicHttpPort>
|
|
<PublicHttpsPort>8920</PublicHttpsPort>
|
|
<AutoDiscovery>true</AutoDiscovery>
|
|
<EnableUPnP>true</EnableUPnP>
|
|
<EnableIPv4>true</EnableIPv4>
|
|
<EnableIPv6>false</EnableIPv6>
|
|
<EnableRemoteAccess>true</EnableRemoteAccess>
|
|
<LocalNetworkSubnets />
|
|
<LocalNetworkAddresses />
|
|
<KnownProxies>
|
|
<string>172.28.10.0/24</string>
|
|
<string>172.28.10.4</string>
|
|
</KnownProxies>
|
|
<IgnoreVirtualInterfaces>true</IgnoreVirtualInterfaces>
|
|
<VirtualInterfaceNames>
|
|
<string>veth</string>
|
|
</VirtualInterfaceNames>
|
|
<EnablePublishedServerUriByRequest>false</EnablePublishedServerUriByRequest>
|
|
<PublishedServerUriBySubnet />
|
|
<RemoteIPFilter />
|
|
<IsRemoteIPFilterBlacklist>false</IsRemoteIPFilterBlacklist>
|
|
</NetworkConfiguration>
|
|
#+END_SRC
|
|
|
|
*** SSO-Auth Plugin Configuration
|
|
|
|
The SSO plugin config lives at =/docker/appdata/jellyfin/plugins/configurations/SSO-Auth.xml=.
|
|
Key settings:
|
|
- OIDC provider pointing to =https://auth.gharbeia.net/application/o/jellyfin-sso=
|
|
- =EnableAuthorization=false= (bypasses group-based role checking)
|
|
- =EnableAllFolders=true= (all libraries accessible to SSO users)
|
|
- Scopes: =openid profile email groups=
|
|
|
|
With =EnableAuthorization=false=, any Authentik user can log in to Jellyfin
|
|
via SSO. Admin rights are managed within Jellyfin itself.
|
|
|
|
** Gluetun — VPN Client
|
|
|
|
Gluetun is the VPN gateway for download clients. Only qbittorrent (torrents)
|
|
and sabnzbd (usenet) actually need VPN routing to protect download privacy.
|
|
All other media services (jellyfin, sonarr, radarr, *arr suite, stash, etc.)
|
|
run standalone on the =networking= bridge without the VPN.
|
|
|
|
Originally, nearly every media service was routed through gluetun for
|
|
simplicity — all traffic behind one tunnel. This created a single point of
|
|
failure: when gluetun crashed or lost its VPN connection, the entire media
|
|
stack went down with it. Services like jellyfin (which never downloads
|
|
anything) had no business being behind the VPN.
|
|
|
|
After a gluetun crash-loop in June 2026 that took down the whole stack for
|
|
over an hour, we split the architecture: downloaders stay on the VPN, all
|
|
other services run standalone. This means a VPN outage only affects active
|
|
downloads, not library browsing, requests, or streaming.
|
|
|
|
Services that need
|
|
VPN routing use =network_mode: service:gluetun= to share its network namespace.
|
|
This means their traffic exits through the VPN tunnel, not the host's public IP.
|
|
|
|
Key architectural decisions:
|
|
- All VPN-routed services share Gluetun's port mappings (configured on Gluetun)
|
|
- =extra_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_SABNZBD:?err}:8080
|
|
- ${WEBUI_PORT_QBITTORRENT:?err}:${WEBUI_PORT_QBITTORRENT:?err}
|
|
- ${QBIT_PORT:?err}:6881
|
|
extra_hosts:
|
|
- ${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}
|
|
- "*.${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}"
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/gluetun:/gluetun
|
|
environment:
|
|
- PUID=${PUID:?err}
|
|
- PGID=${PGID:?err}
|
|
- UMASK=${UMASK:?err}
|
|
- TZ=${TIMEZONE:?err}
|
|
- VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER:?err}
|
|
- OPENVPN_USER=${VPN_USERNAME:?err}
|
|
- OPENVPN_PASSWORD=${VPN_PASSWORD:?err}
|
|
- SERVER_COUNTRIES=${SERVER_COUNTRIES}
|
|
- SERVER_REGIONS=${SERVER_REGIONS}
|
|
- SERVER_CITIES=${SERVER_CITIES}
|
|
- SERVER_HOSTNAMES=${SERVER_HOSTNAMES}
|
|
- SERVER_CATEGORIES=${SERVER_CATEGORIES}
|
|
- FIREWALL_OUTBOUND_SUBNETS=${LOCAL_SUBNET:?err}
|
|
- OPENVPN_CUSTOM_CONFIG=${OPENVPN_CUSTOM_CONFIG}
|
|
- HTTP_CONTROL_SERVER_ADDRESS=:${GLUETUN_CONTROL_PORT:?err}
|
|
- VPN_TYPE=${VPN_TYPE}
|
|
- VPN_ENDPOINT_IP=${VPN_ENDPOINT_IP}
|
|
- VPN_ENDPOINT_PORT=${VPN_ENDPOINT_PORT}
|
|
- WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY}
|
|
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
|
|
- WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY}
|
|
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
|
|
- HTTPPROXY=on
|
|
- SHADOWSOCKS=on
|
|
networks:
|
|
- networking
|
|
#+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-tunnel.rule=Host(`auth.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.authentik-tunnel.entrypoints=tunnel,secureweb
|
|
- traefik.http.routers.authentik-tunnel.middlewares=tunnel-headers@file,security-headers@file
|
|
- traefik.http.services.authentik.loadbalancer.server.scheme=http
|
|
- traefik.http.services.authentik.loadbalancer.server.port=9000
|
|
#+END_SRC
|
|
|
|
** Gitea — Git Hosting
|
|
|
|
Gitea hosts the infrastructure repo and personal projects at
|
|
=git.gharbeia.net=. SSH on port 2222, web UI via Traefik Tunnel.
|
|
|
|
Requires =GITEA__webhook__ALLOWED_HOST_LIST=private= to allow webhook
|
|
delivery to the auto-deploy listener on the Docker bridge gateway.
|
|
The runner connects via the authless internal entrypoint (:8083) so it can
|
|
|
|
#+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
|
|
- GITEA__webhook__ALLOWED_HOST_LIST=private
|
|
volumes:
|
|
- /docker/appdata/gitea:/data
|
|
- /memex:/memex
|
|
- /etc/timezone:/etc/timezone:ro
|
|
- /etc/localtime:/etc/localtime:ro
|
|
ports:
|
|
- "3001:3000"
|
|
- "2222:22"
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.gitea.service=gitea
|
|
- traefik.http.routers.gitea.rule=Host(`git.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.gitea.entrypoints=tunnel
|
|
- traefik.http.routers.gitea.middlewares=security-headers@file
|
|
- traefik.http.services.gitea.loadbalancer.server.scheme=http
|
|
- traefik.http.services.gitea.loadbalancer.server.port=3000
|
|
#+END_SRC
|
|
|
|
** Infrastructure Services
|
|
|
|
Core data and networking services that everything depends on.
|
|
|
|
*** Postgresql
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/postgresql.yaml
|
|
services:
|
|
postgresql:
|
|
image: docker.io/library/postgres:17
|
|
container_name: postgresql
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
user: ${PUID:?err}:${PGID:?err}
|
|
ports:
|
|
- ${POSTGRESQL_PORT:?err}:5432
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
|
start_period: 20s
|
|
interval: 30s
|
|
retries: 5
|
|
timeout: 5s
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/postgresql:/var/lib/postgresql/data
|
|
environment:
|
|
- TZ=${TIMEZONE:?err}
|
|
- POSTGRES_DB=${AUTHENTIK_DATABASE:?err}
|
|
- POSTGRES_USER=${POSTGRESQL_USERNAME:?err}
|
|
- POSTGRES_PASSWORD=${POSTGRESQL_PASSWORD:?err}
|
|
#+END_SRC
|
|
|
|
*** Valkey (Redis Alternative)
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/valkey.yaml
|
|
services:
|
|
valkey:
|
|
image: valkey/valkey:alpine
|
|
container_name: valkey
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
command: --save 60 1 --loglevel warning
|
|
user: ${PUID:?err}:${PGID:?err}
|
|
ports:
|
|
- ${VALKEY_PORT:?err}:6379
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
|
|
start_period: 20s
|
|
interval: 30s
|
|
retries: 5
|
|
timeout: 3s
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/valkey:/data
|
|
#+END_SRC
|
|
|
|
*** Unbound — DNS Resolver
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/unbound.yaml
|
|
services:
|
|
unbound:
|
|
image: mvance/unbound:latest
|
|
container_name: unbound
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
ports:
|
|
- 53:53/tcp
|
|
- 53:53/udp
|
|
volumes:
|
|
- /docker/appdata/unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
|
|
#+END_SRC
|
|
|
|
** Tube Archivist — YouTube Archiving
|
|
|
|
Tube Archivist downloads and indexes YouTube channels, playlists, and
|
|
videos. Full-text search, metadata browsing, subscription management.
|
|
|
|
The stack has three containers:
|
|
- =tubearchivist= (main app) — Django web UI on port 8000
|
|
- =tubearchivist-es= — Elasticsearch 8.17 for metadata + search
|
|
- =tubearchivist-redis= — Redis for Celery task queue
|
|
|
|
Tube Archivist routes through Gluetun VPN to avoid YouTube geo-blocking.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/tubearchivist.yaml
|
|
services:
|
|
tubearchivist:
|
|
image: bbilly1/tubearchivist:latest
|
|
container_name: tubearchivist
|
|
restart: unless-stopped
|
|
depends_on:
|
|
gluetun:
|
|
condition: service_healthy
|
|
restart: true
|
|
network_mode: service:gluetun
|
|
volumes:
|
|
- ${FOLDER_FOR_MORE:?err}/media/youtube:/youtube
|
|
- ${FOLDER_FOR_DATA:?err}/tubearchivist/cache:/cache
|
|
environment:
|
|
- TZ=${TIMEZONE:?err}
|
|
- TA_USERNAME=${TA_USERNAME:?err}
|
|
- TA_PASSWORD=${TA_PASSWORD:?err}
|
|
- ES_URL=http://tubearchivist-es:9200
|
|
- REDIS_CON=redis://tubearchivist-redis:6379
|
|
- TA_HOST=https://tubearchivist.gharbeia.net
|
|
- ELASTIC_PASSWORD=tubearchivist
|
|
- HOST_UID=${PUID:?err}
|
|
- HOST_GID=${PGID:?err}
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.tubearchivist.service=tubearchivist
|
|
- traefik.http.routers.tubearchivist.rule=Host(`tubearchivist.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.tubearchivist.entrypoints=tunnel
|
|
- traefik.http.routers.tubearchivist.middlewares=authentik-forwardauth@file,security-headers@file
|
|
- traefik.http.services.tubearchivist.loadbalancer.server.scheme=http
|
|
- traefik.http.services.tubearchivist.loadbalancer.server.port=8000
|
|
|
|
tubearchivist-es:
|
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
|
|
container_name: tubearchivist-es
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
environment:
|
|
- discovery.type=single-node
|
|
- ES_JAVA_OPTS=-Xms512m -Xmx512m
|
|
- xpack.security.enabled=false
|
|
- path.repo=/usr/share/elasticsearch/data/snapshot
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/tubearchivist/es:/usr/share/elasticsearch/data
|
|
healthcheck:
|
|
test: curl -s http://localhost:9200/_cluster/health | grep -vq '"status":"red"'
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
|
|
tubearchivist-redis:
|
|
image: redis:7-alpine
|
|
container_name: tubearchivist-redis
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
command: --save 60 1 --loglevel warning
|
|
volumes:
|
|
- ${FOLDER_FOR_DATA:?err}/tubearchivist/redis:/data
|
|
healthcheck:
|
|
test: redis-cli ping | grep PONG
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
#+END_SRC
|
|
** qBittorrent — Torrent Client
|
|
|
|
qBittorrent downloads via BitTorrent, routed through Gluetun VPN. Web UI
|
|
at port 8200. Theme.park styling via DOCKER_MODS. Uses Gluetun's network
|
|
namespace so other *arr services reach it at =gluetun:8200=.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/qbittorrent.yaml
|
|
services:
|
|
qbittorrent:
|
|
image: lscr.io/linuxserver/qbittorrent:latest
|
|
container_name: qbittorrent
|
|
restart: unless-stopped
|
|
depends_on:
|
|
gluetun:
|
|
condition: service_healthy
|
|
restart: true
|
|
healthcheck:
|
|
test: ["CMD", "sh", "-c", "cat /proc/net/route | grep -q tun0 || exit 1"]
|
|
interval: 30s
|
|
retries: 2
|
|
start_period: 10s
|
|
volumes:
|
|
- ${FOLDER_FOR_MEDIA:?err}:/library
|
|
- ${FOLDER_FOR_DATA:?err}/qbittorrent:/config
|
|
environment:
|
|
- PUID=${PUID:?err}
|
|
- PGID=${PGID:?err}
|
|
- UMASK=${UMASK:?err}
|
|
- TZ=${TIMEZONE:?err}
|
|
- WEBUI_PORT=${WEBUI_PORT_QBITTORRENT:?err}
|
|
- QBT_PROFILE=/config
|
|
- DOCKER_MODS=ghcr.io/themepark-dev/theme.park:qbittorrent
|
|
- TP_THEME=${TP_THEME:?err}
|
|
network_mode: service:gluetun
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.qbittorrent.service=qbittorrent
|
|
- traefik.http.routers.qbittorrent.rule=Host(`qbittorrent.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.qbittorrent.middlewares=authentik-forwardauth@file,security-headers@file
|
|
- traefik.http.services.qbittorrent.loadbalancer.server.scheme=http
|
|
- traefik.http.services.qbittorrent.loadbalancer.server.port=${WEBUI_PORT_QBITTORRENT:?err}
|
|
#+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
|
|
- =sabnzbd.yaml= — Usenet client
|
|
- =stash.yaml= — Adult content library manager
|
|
- =tdarr.yaml, tdarr-node.yaml= — Media transcoding automation
|
|
- =tubearchivist.yaml= — YouTube archiving (Tube Archivist)
|
|
- =audiobookshelf.yaml= — Audiobook and podcast server
|
|
|
|
** AudioMuse-AI — Sonic Playlist Generator
|
|
|
|
AudioMuse-AI performs sonic analysis on music files to auto-generate
|
|
playlists for Jellyfin. Runs as Flask app + RQ worker, connects to
|
|
existing PostgreSQL and Valkey. On the bridge network (not gluetun).
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/compose/services/audiomuse.yaml
|
|
services:
|
|
audiomuse-ai:
|
|
image: ghcr.io/neptunehub/audiomuse-ai:latest
|
|
container_name: audiomuse-ai
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
ports:
|
|
- ${WEBUI_PORT_AUDIOMUSE:-8005}:8000
|
|
environment:
|
|
SERVICE_TYPE: "flask"
|
|
TZ: ${TIMEZONE:?err}
|
|
POSTGRES_USER: ${POSTGRESQL_USERNAME:?err}
|
|
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?err}
|
|
POSTGRES_DB: audiomusedb
|
|
POSTGRES_HOST: postgresql
|
|
POSTGRES_PORT: "5432"
|
|
REDIS_URL: redis://valkey:6379/0
|
|
TEMP_DIR: /app/temp_audio
|
|
FRONTEND_PORT: "8000"
|
|
volumes:
|
|
- ${FOLDER_FOR_MEDIA:?err}:/library
|
|
- ${FOLDER_FOR_MORE:?err}:/more
|
|
- temp-audio-flask:/app/temp_audio
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.http.routers.audiomuse.service=audiomuse
|
|
- traefik.http.routers.audiomuse.rule=Host(`audiomuse.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
- traefik.http.routers.audiomuse.entrypoints=tunnel,web,secureweb
|
|
- traefik.http.routers.audiomuse.middlewares=authentik-forwardauth@file,security-headers@file
|
|
- traefik.http.services.audiomuse.loadbalancer.server.scheme=http
|
|
- traefik.http.services.audiomuse.loadbalancer.server.port=8000
|
|
|
|
audiomuse-worker:
|
|
image: ghcr.io/neptunehub/audiomuse-ai:latest
|
|
container_name: audiomuse-worker
|
|
restart: unless-stopped
|
|
networks:
|
|
- networking
|
|
environment:
|
|
SERVICE_TYPE: "worker"
|
|
TZ: ${TIMEZONE:?err}
|
|
POSTGRES_USER: ${POSTGRESQL_USERNAME:?err}
|
|
POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD:?err}
|
|
POSTGRES_DB: audiomusedb
|
|
POSTGRES_HOST: postgresql
|
|
POSTGRES_PORT: "5432"
|
|
REDIS_URL: redis://valkey:6379/0
|
|
TEMP_DIR: /app/temp_audio
|
|
volumes:
|
|
- ${FOLDER_FOR_MEDIA:?err}:/library
|
|
- ${FOLDER_FOR_MORE:?err}:/more
|
|
- temp-audio-worker:/app/temp_audio
|
|
|
|
volumes:
|
|
temp-audio-flask:
|
|
temp-audio-worker:
|
|
#+END_SRC
|
|
|
|
** Khaled Fahmy — WordPress Blog (khaledfahmy.org)
|
|
|
|
khaledfahmy.org is a WordPress site hosted on production-1 in a
|
|
three-container stack: nginx (khaledfahmy-web), PHP-FPM (khaledfahmy-php),
|
|
and MariaDB (khaledfahmy-db). All containers are on the =networking= bridge.
|
|
|
|
Architecture decisions:
|
|
|
|
- **Traffic flow**: Cloudflare Tunnel → Traefik (tunnel entrypoint :8081)
|
|
→ nginx → PHP-FPM. No direct LAN/internal access (no secureweb router).
|
|
All TLS is handled by Cloudflare; nginx sees plain HTTP internally.
|
|
|
|
- **HTTPS detection**: WordPress needs to know it's behind HTTPS even though
|
|
nginx receives plain HTTP from the tunnel. The tunnel-headers Traefik
|
|
middleware sets =X-Forwarded-Proto: https=, and the nginx config passes
|
|
this as a FastCGI param. wp-config.php also has =$_SERVER['HTTPS'] = 'on'=
|
|
as a fallback so =is_ssl()= returns true.
|
|
|
|
- **Loopback support**: WordPress site health tests make HTTP requests to
|
|
itself (=https://khaledfahmy.org/=). Since the site is accessed through
|
|
Cloudflare's external DNS, internal loopbacks would resolve to the public
|
|
IP and fail. The PHP container has a =/etc/hosts= entry pointing
|
|
=khaledfahmy.org= to the nginx container's internal IP, and nginx has a
|
|
self-signed SSL cert on port 443 so the loopback HTTPS connection succeeds.
|
|
|
|
- **PHP-FPM tuning**: =pm.max_children= was increased from 5 to 15 to
|
|
prevent worker exhaustion during loopback tests and cron runs. The
|
|
=www-data= user UID was changed from 82 to 33 to match the file ownership
|
|
on the mounted volume, fixing permission errors for uploads and updates.
|
|
|
|
- **Auto-updates**: =WP_AUTO_UPDATE_CORE= and =DISALLOW_FILE_MODS= constants
|
|
were removed from wp-config.php so WordPress can apply security updates
|
|
automatically. The =WP_DEBUG= constant is set to =false= in production
|
|
(was accidentally left on from the Docker env block).
|
|
|
|
- **File permissions**: The =/var/www/html= mount in the PHP container is
|
|
writable (no =ro=). The nginx container mount is read-only (=ro=).
|
|
|
|
- **Container UIDs**: Files on the host are owned by uid 33, which is the
|
|
standard www-data uid on Debian/Ubuntu. The Alpine-based PHP image has
|
|
www-data at uid 82 by default. The UID was remapped with =usermod= to
|
|
match, so the PHP-FPM worker (running as www-data) can write to
|
|
wp-content/uploads, upgrade-temp-backup, and other writable directories.
|
|
|
|
Compose YAML: =/docker/compose/services/khaledfahmy-site.yaml=
|
|
Nginx config: =/docker/appdata/khaledfahmy-site/nginx/wordpress.conf=
|
|
PHP ini: =/docker/appdata/khaledfahmy-site/php/php.ini=
|
|
wp-config: =/docker/appdata/khaledfahmy-site/html/wp-config.php=
|
|
|
|
** [2026-05-17 Sun 17:00] Tube Archivist: gluetun routing, CSRF fix, download path
|
|
- TA moved to network_mode: service:gluetun (port 8000 mapped on gluetun)
|
|
- TA_HOST changed to https:// prefix to fix CSRF on POST /api/playlist/
|
|
- REDIS_CON fixed (was corrupted with embedded Traefik labels)
|
|
- Download folder changed to ${FOLDER_FOR_MORE}/media/youtube
|
|
- Stash got ${FOLDER_FOR_MORE}:/more volume mount
|
|
- Jellyfin TubeArchivist plugin configured (http://localhost:8000, user amr)
|
|
- Backup files moved to /docker/compose/off/
|
|
- Critical: recreating gluetun orphans all network_mode: service:gluetun containers
|
|
(jellyfin, *arr, sabnzbd, etc.) — ALL must be recreated after any gluetun change
|
|
|
|
** [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow
|
|
- Root cause: Jellyfin's empty KnownProxies caused SSO plugin to use HTTP
|
|
base URL, breaking the JavaScript two-step auth flow (iframe/POST/redirect)
|
|
- Fix: Added 172.28.10.0/24 and 172.28.10.4 to Jellyfin's KnownProxies
|
|
- Created jellyfin_admin Authentik group + linked user amr to it
|
|
- Set EnableAuthorization=false in SSO-Auth plugin config
|
|
- Documented three authentication mechanisms: Forward Auth, OIDC/SSO, local users
|
|
- Fixed ASCII tree diagrams by wrapping in #+BEGIN_EXAMPLE blocks
|
|
- Fixed trees rendering issue: Unicode box-drawing chars must be inside
|
|
example blocks in org-mode, otherwise font rendering mangles them
|
|
|
|
** [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth
|
|
- Fixed Emacs org-loaddefs.el path in tangle-deploy
|
|
- Created Gitea access token for git operations
|
|
- Replaced Gitea Action workflow with systemd timer
|
|
- tangle-deploy now pulls, tangles, and restarts on a 5-minute timer
|
|
|
|
** [2026-05-15 Thu 06:50] Monolith split into modular compose
|
|
- 42 service fragments created under /docker/compose/services/
|
|
- Master docker-compose.yaml uses include: directives (43 services total)
|
|
- All service labels and env vars preserved from original monolith
|
|
- Compose validated with --env-file .env, all 43 services resolve
|
|
- Deployment verified: all containers running
|
|
- Orphaned unbound container absorbed into compose (was started manually)
|
|
|
|
** [2026-05-15 Thu 03:47] Literate infrastructure established
|
|
- infrastructure.org becomes the source of truth -- all config files are
|
|
tangle targets embedded as =#+BEGIN_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
|
|
|
|
** [2026-06-02 Tue] Gluetun port cleanup — only downloaders behind VPN
|
|
- Removed 20+ stale port mappings from gluetun (services migrated off VPN)
|
|
- Updated all internal router URLs: standalone services now referenced by
|
|
their own container names instead of http://gluetun:<port>
|
|
- Documented the architectural decision: only qbittorrent + sabnzbd need
|
|
VPN; media management, streaming, and indexing services run standalone
|
|
- Added =Khaled Fahmy= section documenting the WordPress stack config
|
|
|
|
** Tunnel Entrypoint — External Routes
|
|
|
|
Services that are not Docker containers (external machines, LXCs) need
|
|
file provider routes for the tunnel entrypoint. The backend at 10.10.10.29
|
|
is a separate LXC hosting the Hugo-generated brain site.
|
|
|
|
#+BEGIN_SRC yaml :tangle /docker/appdata/traefik/tunnel-routes.yaml
|
|
http:
|
|
routers:
|
|
brain-tunnel:
|
|
rule: "Host(`brain.gharbeia.net`)"
|
|
entryPoints:
|
|
- tunnel
|
|
service: brain-tunnel-svc
|
|
middlewares:
|
|
- tunnel-headers@file
|
|
|
|
services:
|
|
brain-tunnel-svc:
|
|
loadBalancer:
|
|
servers:
|
|
- url: "http://10.10.10.29:8082"
|
|
#+END_SRC
|
|
service: http://10.10.10.29:8082
|
|
middleware: tunnel-headers@file
|
|
|
|
* Environment Variables
|
|
|
|
Tangled to /docker/compose/.env. The CROWDSEC_LAPI_KEY is a noweb reference
|
|
to the shared crowdsec_key block, so it stays in sync with dynamic.yaml.
|
|
|
|
#+BEGIN_SRC dotenv :tangle /docker/compose/.env :noweb yes
|
|
## Auto-generated from infrastructure.org -- do not edit directly.
|
|
## Edit infrastructure.org and tangle to update.
|
|
|
|
#################################################################################
|
|
#################################################################################
|
|
#################################################################################
|
|
##
|
|
## Docker Compose Environment Variable file for Jellyfin / *ARR Media Stack
|
|
##
|
|
## Update any of the environment variables below as required.
|
|
##
|
|
## It is highly recommended Linux users set up a "docker" user, so the
|
|
## applications can access the local filesystem with this user's access
|
|
## privileges. Use PUID / PGID to map user access between the Docker apps
|
|
## and local filesystem.
|
|
##
|
|
## The MediaStack Guide is located at https://MediaStack.Guide
|
|
##
|
|
#################################################################################
|
|
#################################################################################
|
|
#################################################################################
|
|
|
|
###################################################
|
|
## add /dev/net/tun to LXC
|
|
## https://pve.proxmox.com/wiki/OpenVPN_in_LXC
|
|
###################################################
|
|
|
|
# Name of the project in Docker
|
|
COMPOSE_PROJECT_NAME=docker-production
|
|
COMPOSE_BAKE=true
|
|
|
|
# This is the network subnet which will be used inside the docker "media_network", change as required.
|
|
# LOCAL_SUBNET is your home network and is needed so the VPN client allows access to your home computers.
|
|
DOCKER_SUBNET=172.28.10.0/24
|
|
DOCKER_GATEWAY=172.28.10.1
|
|
LOCAL_SUBNET=10.10.10.0/24 # This is the IP Subnet used on your home network
|
|
LOCAL_DOCKER_IP=10.10.10.201 # This is the IP Address of your Docker computer
|
|
|
|
# Each of the "*ARR" applications have been configured so the theme can be changed to your needs.
|
|
# Refer to Theme Park for more info / options: https://docs.theme-park.dev/theme-options/aquamarine/
|
|
TP_THEME=nord
|
|
|
|
# If you intend to use Plex as your Media Server, then enter your Plex Claim
|
|
# information below, to link this Plex Media Server to your Plex account
|
|
# Access Plex claim at: https://account.plex.tv/en/claim
|
|
PLEX_CLAIM=claim-1234567890abcdef
|
|
|
|
# These are the folders on your local host computer / NAS running docker, they MUST exist
|
|
# and have correct permissions for PUID and PGUI prior to running the docker compose.
|
|
#
|
|
# Use the commands in the Guide to create all the sub-folders in each of these folders.
|
|
|
|
# Host Data Folders - Will accept Linux, Windows, NAS folders.
|
|
# Make sure these folders exists before running the "docker compose" command.
|
|
FOLDER_FOR_MEDIA=/library
|
|
# <-- Update for your folders - Synology Example: /volume1/media
|
|
FOLDER_FOR_DATA=/docker/appdata
|
|
# <-- Update for your folders - Synology Example: /volume1/docker/appdata
|
|
FOLDER_FOR_MORE=/more
|
|
# File access, date and time details for the containers / applications to use.
|
|
# Run "sudo id docker" on host computer to find PUID / PGID and update these to suit.
|
|
PUID=1000
|
|
PGID=1000
|
|
UMASK=0002
|
|
TIMEZONE=America/New_York
|
|
|
|
# Update your own Internet VPN provide details below
|
|
# Online documentation: https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers
|
|
VPN_TYPE=openvpn
|
|
VPN_SERVICE_PROVIDER=privado
|
|
VPN_USERNAME=nhmpxamumlrj
|
|
VPN_PASSWORD=ulm8kRtJdmFLAum3tEb
|
|
|
|
# You MUST provide at least one entry to the SERVER variables below, that supports your VPN provider's settings.
|
|
# If you want to add more than one entry per line, use comma separated values: "one,two,three" etc...
|
|
SERVER_COUNTRIES=Netherlands
|
|
SERVER_REGIONS=
|
|
SERVER_CITIES=
|
|
SERVER_HOSTNAMES=
|
|
SERVER_CATEGORIES=
|
|
|
|
# Fill in this item ONLY if you're using a custom OpenVPN configuration
|
|
# Should be inside gluetun data folder - Example: /gluetun/custom-openvpn.conf
|
|
# You can then edit it inside the FOLDER_FOR_DATA location for gluetun.
|
|
OPENVPN_CUSTOM_CONFIG=
|
|
GLUETUN_CONTROL_PORT=8320
|
|
|
|
# Fill in these items ONLY if you change VPN_TYPE to "wireguard"
|
|
VPN_ENDPOINT_IP=
|
|
VPN_ENDPOINT_PORT=
|
|
WIREGUARD_PUBLIC_KEY=
|
|
WIREGUARD_PRIVATE_KEY=
|
|
WIREGUARD_PRESHARED_KEY=
|
|
WIREGUARD_ADDRESSES=
|
|
|
|
# These are the ports used to access each of the applications in your web browser.
|
|
# You can safely change these if you need, but they can't conflict with other active ports.
|
|
QBIT_PORT=6881
|
|
FLARESOLVERR_PORT=8191
|
|
TDARR_SERVER_PORT=8266
|
|
|
|
# WebUI ports for internal access to applications
|
|
WEBUI_PORT_AUDIOBOOKSHELF=13378
|
|
WEBUI_PORT_AUTHENTIK=6080
|
|
WEBUI_PORT_BAZARR=6767
|
|
WEBUI_PORT_CHROMIUM=3650
|
|
WEBUI_PORT_DDNS_UPDATER=8310
|
|
WEBUI_PORT_FILEBOT=5454
|
|
WEBUI_PORT_GUACAMOLE=9200
|
|
WEBUI_PORT_GRAFANA=3800
|
|
WEBUI_PORT_HEADPLANE=3500
|
|
WEBUI_PORT_HEIMDALL=2080
|
|
WEBUI_PORT_HOMARR=3200
|
|
WEBUI_PORT_HOMEPAGE=3000
|
|
WEBUI_PORT_HUNTARR=9705
|
|
WEBUI_PORT_JELLYFIN=8096
|
|
WEBUI_PORT_JELLYSEERR=5055
|
|
WEBUI_PORT_LAZYLIBRARIAN=5299
|
|
WEBUI_PORT_LIDARR=8686
|
|
WEBUI_PORT_MYLAR=8090
|
|
WEBUI_PORT_PLEX=32400
|
|
WEBUI_PORT_PORTAINER=9000
|
|
WEBUI_PORT_PROMETHEUS=9090
|
|
WEBUI_PORT_PROWLARR=9696
|
|
WEBUI_PORT_QBITTORRENT=8200
|
|
WEBUI_PORT_RADARR=7878
|
|
WEBUI_PORT_READARR=8787
|
|
WEBUI_PORT_SABNZBD=8100
|
|
WEBUI_PORT_SONARR=8989
|
|
WEBUI_PORT_STASH=7777
|
|
WEBUI_PORT_TDARR=8265
|
|
WEBUI_PORT_TRAEFIK=8080
|
|
WEBUI_PORT_WHISPARR=6969
|
|
|
|
CHROMIUM_START_PAGE="https://github.com/geekau/mediastack/"
|
|
|
|
# Traefik is configured for Reverse Proxy. Set your Internet gateway to redirect incoming ports 80 and 443
|
|
# to the ports used below (using Docker IP Address), and they will be translated back to 80 and 443 by Traefik.
|
|
# Change these port numbers if you have conflicting services running on the Docker host computer.
|
|
# If ports 80 and 443 are already used, then adjust and redirect incoming ports to 5080 and 5443, or similar.
|
|
|
|
REVERSE_PROXY_PORT_HTTP=80
|
|
REVERSE_PROXY_PORT_HTTPS=443
|
|
|
|
# Traefik Configuration
|
|
# Your CloudFlare Account Email Address
|
|
CLOUDFLARE_EMAIL=gharbeia@riseup.net
|
|
# Your CloudFlare Registered Domain Name
|
|
CLOUDFLARE_DNS_ZONE=gharbeia.net
|
|
# Your CloudFlare Read / Write API Token
|
|
CLOUDFLARE_DNS_API_TOKEN=cfut_unDIAx2wqL2tm8OmcZWpzrQTRPPA5FlenlVfeL7Nf94c360b
|
|
# Headscale / Headplane / Tailscale VPN Wireguard Mesh Networking
|
|
# These port settings are only to change the internal port due to conflicts, Headscale, Tailscale and Headplane will
|
|
# all function normally using the default ports as they are routed through Traefik reverse proxy.
|
|
CONNECT_PORT_HEADSCALE=4080
|
|
METRICS_PORT_HEADSCALE=4090
|
|
|
|
CROWDSEC_PORT=9080
|
|
METRICS_PORT_TRAEFIK=8082
|
|
INTERNAL_PORT_TRAEFIK=8083
|
|
METRICS_PORT_UNPACKERR=5656
|
|
|
|
# The Tailscale Docker container is configured as an exit node inside your home network, so traffic can route securely
|
|
# across the Internet, and break out behind your home gateway / router.
|
|
# sudo docker exec -it headscale headscale users create exit-node
|
|
# sudo docker exec -it headscale headscale --user exit-node preauthkeys create
|
|
# NOTE: Headscale must be running before the commands can be executed, then update authkey below and restart Tailscale.
|
|
TAILSCALE_AUTHKEY=57332c4f29ef77727d3310a34d903ae83ccae4c6392460fc
|
|
# Connect to the following address to complete the initial setup of Authentik after first deployment:
|
|
# http://<DOCKER-IP-ADDRESS>:6080/if/flow/initial-setup/
|
|
|
|
# echo AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')
|
|
AUTHENTIK_SECRET_KEY=P)'K"7sF#>Ia+m-8jOyn6\]6S
|
|
AUTHENTIK_VERSION=2025.4.1
|
|
AUTHENTIK_ERROR_REPORTING__ENABLED=true
|
|
POSTGRESQL_PORT=5432
|
|
VALKEY_PORT=6379
|
|
|
|
# echo POSTGRESQL_PASSWORD=$(openssl rand -base64 60 | tr -d '\n')
|
|
POSTGRESQL_PASSWORD=1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
|
|
POSTGRESQL_USERNAME=library-postgresql
|
|
AUTHENTIK_DATABASE=library-authentik
|
|
GUACAMOLE_DATABASE=library-guacamole
|
|
GUACD_PORT=4822
|
|
|
|
# SMTP Host Emails are sent to
|
|
EMAIL_SERVER_HOST=smtp.fastmail.com
|
|
EMAIL_SERVER_PORT=25
|
|
# Optionally authenticate (don't add quotation marks to your password)
|
|
EMAIL_ADDRESS=amr@gharbeia.net
|
|
EMAIL_PASSWORD=3a58334x8a9r8x7e
|
|
# Use StartTLS
|
|
EMAIL_TLS=true
|
|
# Use SSL - StartTLS and SSL can't both be true
|
|
EMAIL_SSL=false
|
|
# Email address authentik will send from, should have a correct @domain.name
|
|
EMAIL_SENDER=authentik@gharbeia.net
|
|
|
|
#### Gitea
|
|
|
|
GITEA__database__DB_TYPE=sqlite3
|
|
GITEA__database__PATH=/data/gitea/gitea.db
|
|
GITEA__server__DOMAIN=10.10.10.201
|
|
GITEA__server__HTTP_PORT=3001
|
|
GITEA__server__SSH_PORT=2222
|
|
GITEA__server__ROOT_URL=http://10.10.10.201:3000/
|
|
GITEA__security__INSTALL_LOCK=true
|
|
GITEA__service__DISABLE_REGISTRATION=false
|
|
|
|
# ports:
|
|
# - "127.0.0.1:3000:3000"
|
|
# - "127.0.0.1:2222:22"
|
|
|
|
|
|
|
|
|
|
# Cloudflare Tunnel token for cloudflared
|
|
TUNNEL_TOKEN=eyJhIjoiYWY0Y2RkYWM0N2UwMDFmZDZkNWMyMGFjNmRkZGFkM2QiLCJ0IjoiYzI5Mjk1YzUtOTQ2YS00ZGRmLWJkZmUtN2VhZmNkNzRmYWEzIiwicyI6Ik5qQm1aVEV4TjJFdFptRTFPUzAwTjJWbUxUZ3pORE10TURKa1lqRXhNMlptT1RVNCJ9
|
|
|
|
# Tube Archivist
|
|
TA_USERNAME=admin
|
|
TA_PASSWORD=DsO1BPfMEXMJROG9NlgEslOd
|
|
WEBUI_PORT_AUDIOMUSE=8005
|
|
|
|
# khaledfahmy.org
|
|
KHALEDFAHMY_DB_ROOT_PASSWORD=kf_root_ch4ng3_m3
|
|
KHALEDFAHMY_DB_PASSWORD=t1)~Bt~1uwmwe?pq}sZj%b!t8
|
|
|
|
# Nancy Okail (WordPress)
|
|
NANCYOKAIL_DB_ROOT_PASSWORD=changeme_placeholder
|
|
NANCYOKAIL_DB_PASSWORD=changeme_placeholder
|
|
|
|
# Rim Naguib (WordPress)
|
|
RIMNAGUIB_DB_ROOT_PASSWORD=changeme_placeholder
|
|
RIMNAGUIB_DB_PASSWORD=changeme_placeholder
|
|
|
|
# Fishere (WordPress)
|
|
FISHERE_DB_ROOT_PASSWORD=changeme_placeholder
|
|
FISHERE_DB_PASSWORD=changeme_placeholder
|
|
CROWDSEC_LAPI_KEY=<<crowdsec_key()>>
|
|
|
|
#+END_SRC
|