The tangle-deploy.sh is tangled FROM infrastructure.org, but the org block was missing (require 'ob-shell) and org-confirm-babel-evaluate nil. This caused the CrowdSec noweb block (sh src) to evaluate as nil, making crowdsecLapiKey = nil in dynamic.yaml and blocking ALL traffic through the CrowdSec bouncer with 403. Fix the org block so the system is self-consistent: tangle always produces a correct tangle-deploy.sh that can load ob-shell and evaluate noweb blocks without confirmation prompts.
1896 lines
63 KiB
Org Mode
1896 lines
63 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.
|
|
|
|
- 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
|