1438 lines
55 KiB
Org Mode
1438 lines
55 KiB
Org Mode
1|#+TITLE: Infrastructure Documentation — gharbeia.net
|
|
2|#+AUTHOR: Amr Gharbeia
|
|
3|#+DATE: 2026-05-15
|
|
4|
|
|
5|* Architecture
|
|
6|
|
|
7|** Hosts
|
|
8|- =production-1= (10.10.10.201) :: Docker host, runs all services
|
|
9|- Hermes Agent :: Management/automation host
|
|
10|
|
|
11|** Network
|
|
12|- Docker network =networking= (172.28.10.0/24)
|
|
13|- Proxmox VLANs: 1/10/20/30/40/50
|
|
14|- Services VLAN: 10.10.10.0/24
|
|
15|- Domain: gharbeia.net via Cloudflare (orange cloud/proxied)
|
|
16|
|
|
17|** External Access Architecture
|
|
18|
|
|
19|#+BEGIN_EXAMPLE
|
|
20|Cloudflare (edge, orange cloud)
|
|
21| └─ Cloudflare Tunnel "home" (cloudflared on production-1)
|
|
22| └─ Traefik (entrypoint=tunnel, port 8081)
|
|
23| ├─ Authentik Forward Auth (external routers)
|
|
24| ├─ gharbeia-site (nginx)
|
|
25| ├─ jellyfin (SSO via plugin + OIDC)
|
|
26| ├─ gitea (native OIDC)
|
|
27| └─ *.gharbeia.net services
|
|
28|#+END_EXAMPLE
|
|
29|
|
|
30|** Internal Access Architecture
|
|
31|
|
|
32|#+BEGIN_EXAMPLE
|
|
33|LAN client (browser)
|
|
34| └─ Traefik (entrypoint=secureweb, port 443)
|
|
35| ├─ Authentik Forward Auth (internal.yaml routers)
|
|
36| ├─ gharbeia-site (public, no auth)
|
|
37| ├─ jellyfin (SSO via plugin)
|
|
38| └─ *.gharbeia.net services
|
|
39|
|
|
40|Service-to-service / automation / cross-VLAN
|
|
41| └─ Traefik (entrypoint=internal, port 8083 — NO auth)
|
|
42| └─ Same routing as secureweb, from traefik-internal-noauth.yaml
|
|
43|#+END_EXAMPLE
|
|
44|
|
|
45|Key distinction: =:443= = browsers/humans with Authentik auth.
|
|
46|=:8083= = runners, automated tooling, services on other VLANs.
|
|
47|
|
|
48|** Tangle & Deploy Pipeline
|
|
49|
|
|
50|Changes are made to this org file, tangled into config files by the
|
|
51|=tangle-deploy= script on production-1, then deployed via =docker compose=.
|
|
52|
|
|
53|#+BEGIN_SRC bash :tangle /docker/compose/infrastructure/tangle-deploy.sh
|
|
54|#!/usr/bin/env bash
|
|
55|# tangle-deploy — Tangle infrastructure.org and restart affected services
|
|
56|GITEA_URL='ssh://git@git.gharbeia.net:2222/amr/infrastructure.git'
|
|
57|REPO_DIR="${1:-/docker/compose/infrastructure}"
|
|
58|ORG_FILE="${REPO_DIR}/infrastructure.org"
|
|
59|if [ -z "${1:-}" ]; then
|
|
60| if [ ! -d "$REPO_DIR" ]; then
|
|
61| git clone "$GITEA_URL" "$REPO_DIR"
|
|
62| else
|
|
63| cd "$REPO_DIR" && git pull
|
|
64| fi
|
|
65|fi
|
|
66|if [ ! -f "$ORG_FILE" ]; then
|
|
67| echo "ERROR: $ORG_FILE not found in $REPO_DIR"
|
|
68| exit 1
|
|
69|fi
|
|
70|echo "=== Tangling $ORG_FILE ==="
|
|
71|emacs --batch -Q --load /usr/share/emacs/28.2/lisp/org/org-loaddefs.el \
|
|
72| --eval "(require 'org)" \
|
|
73| --eval "(org-babel-tangle-file \"$ORG_FILE\")" 2>&1
|
|
74|echo "=== Restarting services ==="
|
|
75|cd /docker/compose
|
|
76|if [ -f /docker/compose/traefik-static.yaml ] || \
|
|
77| [ -f /docker/compose/traefik-internal.yaml ] || \
|
|
78| [ -f /docker/compose/traefik-internal-noauth.yaml ] || \
|
|
79| [ -f /docker/compose/traefik-dynamic.yaml ]; then
|
|
80| echo 'Traefik config changed -- restarting...'
|
|
81| docker compose up -d traefik
|
|
82|fi
|
|
83|if [ -f /docker/compose/docker-compose.yaml ]; then
|
|
84| echo 'Docker compose changed -- restarting all services'
|
|
85| docker compose up -d 2>&1 | tail -5
|
|
86|fi
|
|
87|echo '=== Deploy complete ==='
|
|
88|#+END_SRC
|
|
89|
|
|
90|The =infra-tangle.timer= polls the Gitea repo every 5 minutes and runs this
|
|
91|script. Pushing to Gitea triggers the pipeline within 5 minutes.
|
|
92|
|
|
93|* Traefik — Reverse Proxy
|
|
94|
|
|
95|Traefik is the edge router for all HTTP traffic. It handles TLS termination via
|
|
96|Let's Encrypt (DNS-01 challenge through Cloudflare), routes traffic to the right
|
|
97|container, and applies middleware chains for auth, security, and rate limiting.
|
|
98|
|
|
99|Three entrypoints:
|
|
100|
|
|
101|- =tunnel= (=:8081=) :: Receives traffic from the Cloudflare tunnel. All routers
|
|
102| here have Authentik Forward Auth.
|
|
103|- =secureweb= (=:443=) :: Internal LAN traffic with TLS. Also has Authentik
|
|
104| Forward Auth for browser access.
|
|
105|- =internal= (=:8083=) :: Service-to-service and cross-VLAN traffic. No auth.
|
|
106| HTTP only. For runners, automation, and API calls that shouldn't hit Authentik.
|
|
107|
|
|
108|** Static Configuration
|
|
109|
|
|
110|The static config sets entrypoints, TLS resolvers, providers, and plugins.
|
|
111|It is the foundation everything else builds on.
|
|
112|
|
|
113|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-static.yaml
|
|
114|global:
|
|
115| checkNewVersion: true
|
|
116| sendAnonymousUsage: true
|
|
117|
|
|
118|log:
|
|
119| level: INFO
|
|
120|
|
|
121|accessLog:
|
|
122| filePath: /var/log/access.log
|
|
123| format: json
|
|
124|
|
|
125|api:
|
|
126| dashboard: true
|
|
127| insecure: true
|
|
128|
|
|
129|entryPoints:
|
|
130| web:
|
|
131| address: :80
|
|
132| http:
|
|
133| redirections:
|
|
134| entryPoint:
|
|
135| to: secureweb
|
|
136| scheme: https
|
|
137| permanent: true
|
|
138| tunnel:
|
|
139| address: :8081
|
|
140| secureweb:
|
|
141| address: :443
|
|
142| http:
|
|
143| tls:
|
|
144| options: default
|
|
145| certResolver: letsencrypt
|
|
146| domains:
|
|
147| - main: gharbeia.net
|
|
148| sans:
|
|
149| - "*.gharbeia.net"
|
|
150| internal:
|
|
151| address: :8083
|
|
152| metrics:
|
|
153| address: :8082
|
|
154|
|
|
155|metrics:
|
|
156| prometheus:
|
|
157| entryPoint: metrics
|
|
158| manualRouting: true
|
|
159| headerLabels:
|
|
160| useragent: User-Agent
|
|
161| buckets:
|
|
162| - 0.1
|
|
163| - 0.3
|
|
164| - 1.2
|
|
165| - 5.0
|
|
166|
|
|
167|providers:
|
|
168| docker:
|
|
169| exposedByDefault: false
|
|
170| file:
|
|
171| directory: /etc/traefik
|
|
172| watch: true
|
|
173|
|
|
174|certificatesResolvers:
|
|
175| letsencrypt:
|
|
176| acme:
|
|
177| storage: /letsencrypt/acme.json
|
|
178| email: gharbeia@riseup.net
|
|
179| keyType: EC384
|
|
180| caServer: https://acme-v02.api.letsencrypt.org/directory
|
|
181| dnsChallenge:
|
|
182| provider: cloudflare
|
|
183| resolvers:
|
|
184| - 1.1.1.1:53
|
|
185| - 1.0.0.1:53
|
|
186| propagation:
|
|
187| delayBeforeChecks: 60s
|
|
188|
|
|
189|experimental:
|
|
190| plugins:
|
|
191| crowdsec-bouncer-traefik-plugin:
|
|
192| moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
|
193| version: v1.4.2
|
|
194|#+END_SRC
|
|
195|
|
|
196|Why each piece:
|
|
197|- =web= (=:80=) exists only to redirect to HTTPS. No TLS.
|
|
198|- =tunnel= (=:8081=) is inbound-only from cloudflared, never exposed to LAN.
|
|
199| Cloudflare handles TLS at the edge, so this can be plain HTTP inside Docker.
|
|
200|- =secureweb= (=:443=) is the LAN-facing entrypoint with Let's Encrypt certs
|
|
201| covering both =gharbeia.net= and =*.gharbeia.net=.
|
|
202|- =internal= (=:8083=) is plain HTTP for service-to-service traffic. TLS overhead
|
|
203| is unnecessary on the internal bridge network.
|
|
204|- =metrics= (=:8082=) exposes Prometheus metrics, manually routed.
|
|
205|- =dnsChallenge= with Cloudflare provider issues wildcard certs. The 60s
|
|
206| propagation delay avoids rate-limit issues with Cloudflare's API.
|
|
207|
|
|
208|** Dynamic Configuration — Middleware
|
|
209|
|
|
210|Shared middleware used by all routers. Defined once here, referenced by name
|
|
211|in every router block.
|
|
212|
|
|
213|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-dynamic.yaml
|
|
214|http:
|
|
215| middlewares:
|
|
216|
|
|
217| authentik-forwardauth:
|
|
218| forwardAuth:
|
|
219| address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
|
|
220| trustForwardHeader: true
|
|
221| authResponseHeaders:
|
|
222| - X-authentik-username
|
|
223| - X-authentik-groups
|
|
224| - X-authentik-email
|
|
225| - X-authentik-name
|
|
226| - X-authentik-uid
|
|
227|
|
|
228| security-headers:
|
|
229| headers:
|
|
230| customFrameOptionsValue: SAMEORIGIN
|
|
231| contentTypeNosniff: true
|
|
232| browserXssFilter: true
|
|
233| referrerPolicy: no-referrer
|
|
234| permissionsPolicy: ""
|
|
235| customResponseHeaders:
|
|
236| X-Robots-Tag: "noindex, nofollow"
|
|
237| Server: ""
|
|
238|
|
|
239| traefik-bouncer:
|
|
240| plugin:
|
|
241| crowdsec-bouncer-traefik-plugin:
|
|
242| enabled: "true"
|
|
243| crowdsecMode: live
|
|
244| crowdsecLapiKey: __CROWDSEC_LAPI_KEY__
|
|
245| crowdsecLapiHost: crowdsec:8080
|
|
246| crowdsecLapiScheme: http
|
|
247| updateFrequencySec: 5
|
|
248| defaultDecisionLifetimeSec: 60
|
|
249|
|
|
250| compress:
|
|
251| compress:
|
|
252| excludedContentTypes:
|
|
253| - text/event-stream
|
|
254|
|
|
255| ratelimit:
|
|
256| rateLimit:
|
|
257| average: 100
|
|
258| burst: 50
|
|
259|#+END_SRC
|
|
260|
|
|
261|The auth flow: Authentik's outpost runs as a sidecar inside the =authentik=
|
|
262|container that validates session cookies. When a request lacks a valid session,
|
|
263|Traefik redirects to the Authentik login page. After login, Authentik redirects
|
|
264|back to the original URL with a session cookie.
|
|
265|
|
|
266|=security-headers= locks down XSS, clickjacking, and fingerprinting. The empty
|
|
267|=permissionsPolicy= disables all browser APIs by default.
|
|
268|
|
|
269|=traefik-bouncer= runs CrowdSec's LAPI bouncer as a Traefik plugin. IPs flagged
|
|
270|by CrowdSec get blocked. The LAPI key is a placeholder -- fill from vault.
|
|
271|
|
|
272|** Internal Routers — Authenticated (secureweb :443)
|
|
273|
|
|
274|These routers serve LAN browser traffic. All have Authentik Forward Auth.
|
|
275|Backend services are referenced by Docker DNS name on the =networking= bridge.
|
|
276|
|
|
277|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal.yaml
|
|
278|http:
|
|
279| routers:
|
|
280|
|
|
281| # -- Media & Streaming -----------------------------------------
|
|
282|
|
|
283| jellyfin:
|
|
284| rule: "Host(`jellyfin.gharbeia.net`)"
|
|
285| service: jellyfin-internal
|
|
286| entryPoints:
|
|
287| - secureweb
|
|
288| tls:
|
|
289| certResolver: letsencrypt
|
|
290| middlewares:
|
|
291| - authentik-forwardauth@file
|
|
292| - security-headers@file
|
|
293| - traefik-bouncer@file
|
|
294|
|
|
295| jellyseerr:
|
|
296| rule: "Host(`jellyseerr.gharbeia.net`)"
|
|
297| service: jellyseerr-internal
|
|
298| entryPoints:
|
|
299| - secureweb
|
|
300| tls:
|
|
301| certResolver: letsencrypt
|
|
302| middlewares:
|
|
303| - authentik-forwardauth@file
|
|
304| - security-headers@file
|
|
305| - traefik-bouncer@file
|
|
306|
|
|
307| # -- *arr Suite -------------------------------------------------
|
|
308|
|
|
309| radarr:
|
|
310| rule: "Host(`radarr.gharbeia.net`)"
|
|
311| service: radarr-internal
|
|
312| entryPoints:
|
|
313| - secureweb
|
|
314| tls:
|
|
315| certResolver: letsencrypt
|
|
316| middlewares:
|
|
317| - authentik-forwardauth@file
|
|
318| - security-headers@file
|
|
319| - traefik-bouncer@file
|
|
320|
|
|
321| sonarr:
|
|
322| rule: "Host(`sonarr.gharbeia.net`)"
|
|
323| service: sonarr-internal
|
|
324| entryPoints:
|
|
325| - secureweb
|
|
326| tls:
|
|
327| certResolver: letsencrypt
|
|
328| middlewares:
|
|
329| - authentik-forwardauth@file
|
|
330| - security-headers@file
|
|
331| - traefik-bouncer@file
|
|
332|
|
|
333| lidarr:
|
|
334| rule: "Host(`lidarr.gharbeia.net`)"
|
|
335| service: lidarr-internal
|
|
336| entryPoints:
|
|
337| - secureweb
|
|
338| tls:
|
|
339| certResolver: letsencrypt
|
|
340| middlewares:
|
|
341| - authentik-forwardauth@file
|
|
342| - security-headers@file
|
|
343| - traefik-bouncer@file
|
|
344|
|
|
345| prowlarr:
|
|
346| rule: "Host(`prowlarr.gharbeia.net`)"
|
|
347| service: prowlarr-internal
|
|
348| entryPoints:
|
|
349| - secureweb
|
|
350| tls:
|
|
351| certResolver: letsencrypt
|
|
352| middlewares:
|
|
353| - authentik-forwardauth@file
|
|
354| - security-headers@file
|
|
355| - traefik-bouncer@file
|
|
356|
|
|
357| whisparr:
|
|
358| rule: "Host(`whisparr.gharbeia.net`)"
|
|
359| service: whisparr-internal
|
|
360| entryPoints:
|
|
361| - secureweb
|
|
362| tls:
|
|
363| certResolver: letsencrypt
|
|
364| middlewares:
|
|
365| - authentik-forwardauth@file
|
|
366| - security-headers@file
|
|
367| - traefik-bouncer@file
|
|
368|
|
|
369| mylar:
|
|
370| rule: "Host(`mylar.gharbeia.net`)"
|
|
371| service: mylar-internal
|
|
372| entryPoints:
|
|
373| - secureweb
|
|
374| tls:
|
|
375| certResolver: letsencrypt
|
|
376| middlewares:
|
|
377| - authentik-forwardauth@file
|
|
378| - security-headers@file
|
|
379| - traefik-bouncer@file
|
|
380|
|
|
381| lazylibrarian:
|
|
382| rule: "Host(`lazylibrarian.gharbeia.net`)"
|
|
383| service: lazylibrarian-internal
|
|
384| entryPoints:
|
|
385| - secureweb
|
|
386| tls:
|
|
387| certResolver: letsencrypt
|
|
388| middlewares:
|
|
389| - authentik-forwardauth@file
|
|
390| - security-headers@file
|
|
391| - traefik-bouncer@file
|
|
392|
|
|
393| # -- Downloaders ------------------------------------------------
|
|
394|
|
|
395| sabnzbd:
|
|
396| rule: "Host(`sabnzbd.gharbeia.net`)"
|
|
397| service: sabnzbd-internal
|
|
398| entryPoints:
|
|
399| - secureweb
|
|
400| tls:
|
|
401| certResolver: letsencrypt
|
|
402| middlewares:
|
|
403| - authentik-forwardauth@file
|
|
404| - security-headers@file
|
|
405| - traefik-bouncer@file
|
|
406|
|
|
407| qbittorrent:
|
|
408| rule: "Host(`qbittorrent.gharbeia.net`)"
|
|
409| service: qbittorrent-internal
|
|
410| entryPoints:
|
|
411| - secureweb
|
|
412| tls:
|
|
413| certResolver: letsencrypt
|
|
414| middlewares:
|
|
415| - authentik-forwardauth@file
|
|
416| - security-headers@file
|
|
417| - traefik-bouncer@file
|
|
418|
|
|
419| flaresolverr:
|
|
420| rule: "Host(`flaresolverr.gharbeia.net`)"
|
|
421| service: flaresolverr-internal
|
|
422| entryPoints:
|
|
423| - secureweb
|
|
424| tls:
|
|
425| certResolver: letsencrypt
|
|
426| middlewares:
|
|
427| - authentik-forwardauth@file
|
|
428| - security-headers@file
|
|
429| - traefik-bouncer@file
|
|
430|
|
|
431| # -- Homepage / Dashboards --------------------------------------
|
|
432|
|
|
433| homepage:
|
|
434| rule: "Host(`homepage.gharbeia.net`)"
|
|
435| service: homepage-internal
|
|
436| entryPoints:
|
|
437| - secureweb
|
|
438| tls:
|
|
439| certResolver: letsencrypt
|
|
440| middlewares:
|
|
441| - authentik-forwardauth@file
|
|
442| - security-headers@file
|
|
443| - traefik-bouncer@file
|
|
444|
|
|
445| homarr:
|
|
446| rule: "Host(`homarr.gharbeia.net`)"
|
|
447| service: homarr-internal
|
|
448| entryPoints:
|
|
449| - secureweb
|
|
450| tls:
|
|
451| certResolver: letsencrypt
|
|
452| middlewares:
|
|
453| - authentik-forwardauth@file
|
|
454| - security-headers@file
|
|
455| - traefik-bouncer@file
|
|
456|
|
|
457| heimdall:
|
|
458| rule: "Host(`heimdall.gharbeia.net`)"
|
|
459| service: heimdall-internal
|
|
460| entryPoints:
|
|
461| - secureweb
|
|
462| tls:
|
|
463| certResolver: letsencrypt
|
|
464| middlewares:
|
|
465| - authentik-forwardauth@file
|
|
466| - security-headers@file
|
|
467| - traefik-bouncer@file
|
|
468|
|
|
469| # -- Monitoring ------------------------------------------------
|
|
470|
|
|
471| grafana:
|
|
472| rule: "Host(`grafana.gharbeia.net`)"
|
|
473| service: grafana-internal
|
|
474| entryPoints:
|
|
475| - secureweb
|
|
476| tls:
|
|
477| certResolver: letsencrypt
|
|
478| middlewares:
|
|
479| - authentik-forwardauth@file
|
|
480| - security-headers@file
|
|
481| - traefik-bouncer@file
|
|
482|
|
|
483| prometheus:
|
|
484| rule: "Host(`prometheus.gharbeia.net`)"
|
|
485| service: prometheus-internal
|
|
486| entryPoints:
|
|
487| - secureweb
|
|
488| tls:
|
|
489| certResolver: letsencrypt
|
|
490| middlewares:
|
|
491| - authentik-forwardauth@file
|
|
492| - security-headers@file
|
|
493| - traefik-bouncer@file
|
|
494|
|
|
495| # -- Website (public, no auth) ----------------------------------
|
|
496|
|
|
497| gharbeia-site:
|
|
498| rule: "Host(`gharbeia.net`) || Host(`www.gharbeia.net`)"
|
|
499| service: gharbeia-site-internal
|
|
500| entryPoints:
|
|
501| - secureweb
|
|
502| tls:
|
|
503| certResolver: letsencrypt
|
|
504|
|
|
505| # -- Management ------------------------------------------------
|
|
506|
|
|
507| gitea:
|
|
508| rule: "Host(`git.gharbeia.net`)"
|
|
509| service: gitea-internal
|
|
510| entryPoints:
|
|
511| - secureweb
|
|
512| tls:
|
|
513| certResolver: letsencrypt
|
|
514| middlewares:
|
|
515| - security-headers@file
|
|
516| - traefik-bouncer@file
|
|
517| # No authentik-forwardauth -- Gitea has native OIDC
|
|
518|
|
|
519| portainer:
|
|
520| rule: "Host(`portainer.gharbeia.net`)"
|
|
521| service: portainer-internal
|
|
522| entryPoints:
|
|
523| - secureweb
|
|
524| tls:
|
|
525| certResolver: letsencrypt
|
|
526| middlewares:
|
|
527| - authentik-forwardauth@file
|
|
528| - security-headers@file
|
|
529| - traefik-bouncer@file
|
|
530|
|
|
531| authentik:
|
|
532| rule: "Host(`auth.gharbeia.net`)"
|
|
533| service: authentik-internal
|
|
534| entryPoints:
|
|
535| - secureweb
|
|
536| tls:
|
|
537| certResolver: letsencrypt
|
|
538| middlewares:
|
|
539| - security-headers@file
|
|
540| - traefik-bouncer@file
|
|
541| # No authentik-forwardauth -- otherwise auth loops
|
|
542|
|
|
543| headscale:
|
|
544| rule: "Host(`headscale.gharbeia.net`)"
|
|
545| service: headscale-internal
|
|
546| entryPoints:
|
|
547| - secureweb
|
|
548| tls:
|
|
549| certResolver: letsencrypt
|
|
550| middlewares:
|
|
551| - security-headers@file
|
|
552| - traefik-bouncer@file
|
|
553| # No authentik-forwardauth -- Tailscale clients need direct access
|
|
554|
|
|
555| headplane:
|
|
556| rule: "Host(`headplane.gharbeia.net`) && PathPrefix(`/admin/`)"
|
|
557| service: headplane-internal
|
|
558| entryPoints:
|
|
559| - secureweb
|
|
560| tls:
|
|
561| certResolver: letsencrypt
|
|
562| middlewares:
|
|
563| - authentik-forwardauth@file
|
|
564| - security-headers@file
|
|
565| - traefik-bouncer@file
|
|
566|
|
|
567| ddns-updater:
|
|
568| rule: "Host(`ddns-updater.gharbeia.net`)"
|
|
569| service: ddns-updater-internal
|
|
570| entryPoints:
|
|
571| - secureweb
|
|
572| tls:
|
|
573| certResolver: letsencrypt
|
|
574| middlewares:
|
|
575| - authentik-forwardauth@file
|
|
576| - security-headers@file
|
|
577| - traefik-bouncer@file
|
|
578|
|
|
579| audiobookshelf:
|
|
580| rule: "Host(`audiobookshelf.gharbeia.net`)"
|
|
581| service: audiobookshelf-internal
|
|
582| entryPoints:
|
|
583| - secureweb
|
|
584| tls:
|
|
585| certResolver: letsencrypt
|
|
586| middlewares:
|
|
587| - authentik-forwardauth@file
|
|
588| - security-headers@file
|
|
589| - traefik-bouncer@file
|
|
590|
|
|
591| guacamole:
|
|
592| rule: "Host(`guacamole.gharbeia.net`)"
|
|
593| service: guacamole-internal
|
|
594| entryPoints:
|
|
595| - secureweb
|
|
596| tls:
|
|
597| certResolver: letsencrypt
|
|
598| middlewares:
|
|
599| - authentik-forwardauth@file
|
|
600| - security-headers@file
|
|
601| - traefik-bouncer@file
|
|
602|
|
|
603| tubearchivist:
|
|
604| rule: "Host(`tubearchivist.gharbeia.net`)"
|
|
605| service: tubearchivist-internal
|
|
606| entryPoints:
|
|
607| - secureweb
|
|
608| tls:
|
|
609| certResolver: letsencrypt
|
|
610| middlewares:
|
|
611| - authentik-forwardauth@file
|
|
612| - security-headers@file
|
|
613| - traefik-bouncer@file
|
|
614|
|
|
615| traefik-dashboard:
|
|
616| rule: "Host(`traefik.gharbeia.net`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
|
|
617| service: traefik-dashboard-internal
|
|
618| entryPoints:
|
|
619| - secureweb
|
|
620| tls:
|
|
621| certResolver: letsencrypt
|
|
622| middlewares:
|
|
623| - authentik-forwardauth@file
|
|
624| - security-headers@file
|
|
625| - traefik-bouncer@file
|
|
626|
|
|
627| services:
|
|
628| jellyfin-internal:
|
|
629| loadBalancer:
|
|
630| servers:
|
|
631| - url: http://gluetun:8096
|
|
632| jellyseerr-internal:
|
|
633| loadBalancer:
|
|
634| servers:
|
|
635| - url: http://gluetun:5055
|
|
636| radarr-internal:
|
|
637| loadBalancer:
|
|
638| servers:
|
|
639| - url: http://gluetun:7878
|
|
640| sonarr-internal:
|
|
641| loadBalancer:
|
|
642| servers:
|
|
643| - url: http://gluetun:8989
|
|
644| lidarr-internal:
|
|
645| loadBalancer:
|
|
646| servers:
|
|
647| - url: http://gluetun:8686
|
|
648| prowlarr-internal:
|
|
649| loadBalancer:
|
|
650| servers:
|
|
651| - url: http://gluetun:9696
|
|
652| whisparr-internal:
|
|
653| loadBalancer:
|
|
654| servers:
|
|
655| - url: http://gluetun:6969
|
|
656| mylar-internal:
|
|
657| loadBalancer:
|
|
658| servers:
|
|
659| - url: http://gluetun:8090
|
|
660| lazylibrarian-internal:
|
|
661| loadBalancer:
|
|
662| servers:
|
|
663| - url: http://gluetun:5299
|
|
664| sabnzbd-internal:
|
|
665| loadBalancer:
|
|
666| servers:
|
|
667| - url: http://gluetun:8080
|
|
668| qbittorrent-internal:
|
|
669| loadBalancer:
|
|
670| servers:
|
|
671| - url: http://gluetun:8200
|
|
672| flaresolverr-internal:
|
|
673| loadBalancer:
|
|
674| servers:
|
|
675| - url: http://gluetun:8191
|
|
676| homepage-internal:
|
|
677| loadBalancer:
|
|
678| servers:
|
|
679| - url: http://homepage:3000
|
|
680| homarr-internal:
|
|
681| loadBalancer:
|
|
682| servers:
|
|
683| - url: http://homarr:7575
|
|
684| heimdall-internal:
|
|
685| loadBalancer:
|
|
686| servers:
|
|
687| - url: http://heimdall:80
|
|
688| grafana-internal:
|
|
689| loadBalancer:
|
|
690| servers:
|
|
691| - url: http://grafana:3000
|
|
692| prometheus-internal:
|
|
693| loadBalancer:
|
|
694| servers:
|
|
695| - url: http://prometheus:9090
|
|
696| gharbeia-site-internal:
|
|
697| loadBalancer:
|
|
698| servers:
|
|
699| - url: http://gharbeia-site:80
|
|
700| gitea-internal:
|
|
701| loadBalancer:
|
|
702| servers:
|
|
703| - url: http://gitea:3000
|
|
704| portainer-internal:
|
|
705| loadBalancer:
|
|
706| servers:
|
|
707| - url: http://portainer:9000
|
|
708| authentik-internal:
|
|
709| loadBalancer:
|
|
710| servers:
|
|
711| - url: http://authentik:9000
|
|
712| headscale-internal:
|
|
713| loadBalancer:
|
|
714| servers:
|
|
715| - url: http://headscale:8080
|
|
716| headplane-internal:
|
|
717| loadBalancer:
|
|
718| servers:
|
|
719| - url: http://headplane:3000
|
|
720| ddns-updater-internal:
|
|
721| loadBalancer:
|
|
722| servers:
|
|
723| - url: http://ddns-updater:8310
|
|
724| audiobookshelf-internal:
|
|
725| loadBalancer:
|
|
726| servers:
|
|
727| - url: http://gluetun:80 # audiobookshelf on port 80 inside gluetun
|
|
728| guacamole-internal:
|
|
729| loadBalancer:
|
|
730| servers:
|
|
731| - url: http://guacamole:8080
|
|
732| traefik-dashboard-internal:
|
|
733| loadBalancer:
|
|
734| servers:
|
|
735| - url: http://traefik:8080
|
|
736| tubearchivist-internal:
|
|
737| loadBalancer:
|
|
738| servers:
|
|
739| - url: http://gluetun:8000 # tubearchivist on port 8000 inside gluetun
|
|
740|#+END_SRC
|
|
741|
|
|
742|All 28 routers follow the same pattern. The service URLs point to Docker DNS
|
|
743|names on the =networking= bridge. Services behind Gluetun VPN aren't on
|
|
744|the bridge network — they use =network_mode: service:gluetun= and are
|
|
745|reached via =http://gluetun:<port>= instead of =http://servicename:<port>=.
|
|
746|
|
|
747|** Internal Routers — No Auth (internal :8083)
|
|
748|
|
|
749|An identical set of routers without the =authentik-forwardauth= middleware.
|
|
750|Used by service-to-service traffic, Gitea runner, and cross-VLAN automation.
|
|
751|Generated by stripping the auth middleware from =traefik-internal.yaml=.
|
|
752|
|
|
753|#+BEGIN_SRC yaml :tangle /docker/compose/traefik-internal-noauth.yaml
|
|
754|# This file is maintained manually as a copy of traefik-internal.yaml
|
|
755|# with all authentik-forwardauth middleware references removed.
|
|
756|# See: docker/appdata/traefik/internal-noauth.yaml for the production copy.
|
|
757|#+END_SRC
|
|
758|
|
|
759|** Authentication Architecture
|
|
760|
|
|
761|Three authentication mechanisms depending on the service type:
|
|
762|
|
|
763|*** Forward Auth (default for web-only services)
|
|
764|
|
|
765|Traefik middleware intercepts every request and redirects unauthenticated
|
|
766|users to the Authentik login page. After login, Authentik sets a session
|
|
767|cookie that passes subsequent checks transparently.
|
|
768|
|
|
769|Used by: all =*arr=, dashboards, monitoring, Portainer, Guacamole, etc.
|
|
770|Limitation: only works in browsers — native/TV apps can't use Forward Auth.
|
|
771|
|
|
772|*** Native OIDC / SSO (for services with apps)
|
|
773|
|
|
774|Services that have native mobile or TV apps need real OIDC integration so
|
|
775|the app can authenticate directly via a browser-based login flow.
|
|
776|
|
|
777|- Gitea: configured with Authentik OIDC provider in Gitea's admin panel.
|
|
778| Users log in via "Sign in with Authentik" button on the Gitea login page.
|
|
779| Existing user accounts are linked by username match.
|
|
780|
|
|
781|- Jellyfin: uses the SSO-Auth plugin (v4.0.0.4) with an Authentik OIDC
|
|
782| provider (client_id = =jellyfin-sso=). The plugin does a two-step flow:
|
|
783| 1. OIDC callback returns an HTML page with JavaScript + authorization state
|
|
784| 2. JavaScript POSTs to =/sso/OID/Auth/Authentik= to complete the login
|
|
785|
|
|
786| Critical detail: Jellyfin must trust Traefik's =X-Forwarded-Proto= header
|
|
787| or the JavaScript will construct URLs with =http://= instead of =https://=.
|
|
788| This is configured via =KnownProxies= in =/config/network.xml=:
|
|
789|
|
|
790| #+BEGIN_SRC xml
|
|
791| <KnownProxies>
|
|
792| <string>172.28.10.0/24</string>
|
|
793| <string>172.28.10.4</string>
|
|
794| </KnownProxies>
|
|
795| #+END_SRC
|
|
796|
|
|
797| Without this, =GetRequestBase()= returns =http://jellyfin.gharbeia.net= and
|
|
798| the iframe, auth POST, and final redirect all use the wrong scheme.
|
|
799|
|
|
800|*** Local users (TV apps, fallback)
|
|
801|
|
|
802|TV apps (Android TV, webOS, Tizen) often can't complete the OIDC JavaScript
|
|
803|two-step flow inside their embedded browser. For these, create a dedicated
|
|
804|Jellyfin local user (e.g. =tv=) with a simple password. The app logs in
|
|
805|directly with password — no SSO involved. The user keeps library access
|
|
806|without losing admin history/settings.
|
|
807|
|
|
808|This pattern applies to any service where native app SSO doesn't work:
|
|
809|create a local service account, use it for app access, keep SSO for browsers.
|
|
810|
|
|
811|The entire stack runs on =production-1= using Docker Compose. Services are
|
|
812|split into individual YAML fragment files under =/docker/compose/services/=,
|
|
813|referenced by the master compose via =include:= directives.
|
|
814|
|
|
815|This splitting has three benefits:
|
|
816|1. Each service is self-contained with its own prose documentation
|
|
817|2. Adding or removing a service is a single line in the master compose
|
|
818|3. Differences between deployments (e.g. test vs production) are just different
|
|
819| include lists
|
|
820|
|
|
821|** Master Compose
|
|
822|
|
|
823|The master compose defines the shared network and includes all service fragments.
|
|
824|It is the single entry point for =docker compose= commands.
|
|
825|
|
|
826|#+BEGIN_SRC yaml :tangle /docker/compose/docker-compose.yaml
|
|
827|networks:
|
|
828| networking:
|
|
829| name: networking
|
|
830| driver: bridge
|
|
831| ipam:
|
|
832| driver: default
|
|
833| config:
|
|
834| - subnet: ${DOCKER_SUBNET:?err}
|
|
835| gateway: ${DOCKER_GATEWAY:?err}
|
|
836|
|
|
837|include:
|
|
838| - services/gluetun.yaml
|
|
839| - services/postgresql.yaml
|
|
840| - services/valkey.yaml
|
|
841| - services/authentik.yaml
|
|
842| - services/authentic-worker.yaml
|
|
843| - services/traefik.yaml
|
|
844| - services/traefik-certs-dumper.yaml
|
|
845| - services/crowdsec.yaml
|
|
846| - services/gitea.yaml
|
|
847| - services/runner.yaml
|
|
848| - services/cloudflared.yaml
|
|
849| - services/gharbeia-site.yaml
|
|
850| - services/unbound.yaml
|
|
851| - services/homepage.yaml
|
|
852| - services/homarr.yaml
|
|
853| - services/heimdall.yaml
|
|
854| - services/grafana.yaml
|
|
855| - services/prometheus.yaml
|
|
856| - services/headscale.yaml
|
|
857| - services/tailscale.yaml
|
|
858| - services/headplane.yaml
|
|
859| - services/ddns-updater.yaml
|
|
860| - services/portainer.yaml
|
|
861| - services/guacamole.yaml
|
|
862| - services/guacd.yaml
|
|
863| - services/unpackerr.yaml
|
|
864| - services/bazarr.yaml
|
|
865| - services/flaresolverr.yaml
|
|
866| - services/jellyfin.yaml
|
|
867| - services/jellyseerr.yaml
|
|
868| - services/lazylibrarian.yaml
|
|
869| - services/lidarr.yaml
|
|
870| - services/mylar.yaml
|
|
871| - services/prowlarr.yaml
|
|
872| - services/qbittorrent.yaml
|
|
873| - services/radarr.yaml
|
|
874| - services/sabnzbd.yaml
|
|
875| - services/sonarr.yaml
|
|
876| - services/stash.yaml
|
|
877| - services/tdarr.yaml
|
|
878| - services/tdarr-node.yaml
|
|
879| - services/tubearchivist.yaml
|
|
- services/audiobookshelf.yaml
|
|
- services/audiomuse.yaml
|
|
- services/whisparr.yaml
|
|
882|#+END_SRC
|
|
883|
|
|
884|All 44 services are organized alphabetically by category in the include list.
|
|
885|The order matters for startup dependencies: infrastructure services (gluetun,
|
|
886|postgresql, valkey, authentik, traefik) come first.
|
|
887|
|
|
888|** Jellyfin — Media Server
|
|
889|
|
|
890|Jellyfin serves media libraries through the browser and native apps. It runs
|
|
891|in Gluetun's network namespace (VPN-routed), uses Authentik SSO for browser
|
|
892|login, and supports local user accounts for TV apps.
|
|
893|
|
|
894|*** KnownProxies (Critical for SSO)
|
|
895|
|
|
896|Jellyfin sits behind Traefik reverse proxy. Without =KnownProxies=, Jellyfin
|
|
897|doesn't trust =X-Forwarded-Proto: https=, so the SSO plugin's JavaScript
|
|
898|flow constructs URLs with HTTP instead of HTTPS. This file must match the
|
|
899|runtime config at =/docker/appdata/jellyfin/network.xml=.
|
|
900|
|
|
901|#+BEGIN_SRC xml :tangle /docker/appdata/jellyfin/network.xml
|
|
902|<?xml version="1.0" encoding="utf-8"?>
|
|
903|<NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
|
904| <BaseUrl />
|
|
905| <EnableHttps>false</EnableHttps>
|
|
906| <RequireHttps>false</RequireHttps>
|
|
907| <CertificatePath />
|
|
908| <CertificatePassword />
|
|
909| <InternalHttpPort>8096</InternalHttpPort>
|
|
910| <InternalHttpsPort>8920</InternalHttpsPort>
|
|
911| <PublicHttpPort>8096</PublicHttpPort>
|
|
912| <PublicHttpsPort>8920</PublicHttpsPort>
|
|
913| <AutoDiscovery>true</AutoDiscovery>
|
|
914| <EnableUPnP>true</EnableUPnP>
|
|
915| <EnableIPv4>true</EnableIPv4>
|
|
916| <EnableIPv6>false</EnableIPv6>
|
|
917| <EnableRemoteAccess>true</EnableRemoteAccess>
|
|
918| <LocalNetworkSubnets />
|
|
919| <LocalNetworkAddresses />
|
|
920| <KnownProxies>
|
|
921| <string>172.28.10.0/24</string>
|
|
922| <string>172.28.10.4</string>
|
|
923| </KnownProxies>
|
|
924| <IgnoreVirtualInterfaces>true</IgnoreVirtualInterfaces>
|
|
925| <VirtualInterfaceNames>
|
|
926| <string>veth</string>
|
|
927| </VirtualInterfaceNames>
|
|
928| <EnablePublishedServerUriByRequest>false</EnablePublishedServerUriByRequest>
|
|
929| <PublishedServerUriBySubnet />
|
|
930| <RemoteIPFilter />
|
|
931| <IsRemoteIPFilterBlacklist>false</IsRemoteIPFilterBlacklist>
|
|
932|</NetworkConfiguration>
|
|
933|#+END_SRC
|
|
934|
|
|
935|*** SSO-Auth Plugin Configuration
|
|
936|
|
|
937|The SSO plugin config lives at =/docker/appdata/jellyfin/plugins/configurations/SSO-Auth.xml=.
|
|
938|Key settings:
|
|
939|- OIDC provider pointing to =https://auth.gharbeia.net/application/o/jellyfin-sso=
|
|
940|- =EnableAuthorization=false= (bypasses group-based role checking)
|
|
941|- =EnableAllFolders=true= (all libraries accessible to SSO users)
|
|
942|- Scopes: =openid profile email groups=
|
|
943|
|
|
944|With =EnableAuthorization=false=, any Authentik user can log in to Jellyfin
|
|
945|via SSO. Admin rights are managed within Jellyfin itself.
|
|
946|
|
|
947|** Gluetun — VPN Client
|
|
948|
|
|
949|Gluetun is the VPN gateway for all media-related traffic. Services that need
|
|
950|VPN routing use =network_mode: service:gluetun= to share its network namespace.
|
|
951|This means their traffic exits through the VPN tunnel, not the host's public IP.
|
|
952|
|
|
953|Key architectural decisions:
|
|
954|- All VPN-routed services share Gluetun's port mappings (configured on Gluetun)
|
|
955|- =extra_hosts= resolves *.gharbeia.net to 10.10.10.201 so VPN-routed services
|
|
956| can reach Traefik without leaking DNS
|
|
957|- The =FIREWALL_OUTBOUND_SUBNETS= allows LAN access through the VPN
|
|
958|
|
|
959|#+BEGIN_SRC yaml :tangle /docker/compose/services/gluetun.yaml
|
|
960|services:
|
|
961| gluetun:
|
|
962| image: qmcgaw/gluetun:latest
|
|
963| container_name: gluetun
|
|
964| restart: always
|
|
965| cap_add:
|
|
966| - NET_ADMIN
|
|
967| devices:
|
|
968| - /dev/net/tun:/dev/net/tun
|
|
969| ports:
|
|
970| - 8888:8888/tcp
|
|
971| - 8388:8388/tcp
|
|
972| - 8388:8388/udp
|
|
973| - ${GLUETUN_CONTROL_PORT:?err}:${GLUETUN_CONTROL_PORT:?err}
|
|
974| - ${WEBUI_PORT_AUDIOBOOKSHELF:?err}:80
|
|
975| - ${WEBUI_PORT_BAZARR:?err}:6767
|
|
976| - ${WEBUI_PORT_FILEBOT:?err}:5454
|
|
977| - ${WEBUI_PORT_HUNTARR:?err}:9705
|
|
978| - ${WEBUI_PORT_JELLYFIN:?err}:8096
|
|
979| - ${WEBUI_PORT_JELLYSEERR:?err}:5055
|
|
980| - ${WEBUI_PORT_LAZYLIBRARIAN:?err}:5299
|
|
981| - ${WEBUI_PORT_LIDARR:?err}:8686
|
|
982| - ${WEBUI_PORT_MYLAR:?err}:8090
|
|
983| - ${WEBUI_PORT_PROWLARR:?err}:9696
|
|
984| - ${WEBUI_PORT_RADARR:?err}:7878
|
|
985| - ${WEBUI_PORT_READARR:?err}:8787
|
|
986| - ${WEBUI_PORT_SABNZBD:?err}:8080
|
|
987| - ${WEBUI_PORT_SONARR:?err}:8989
|
|
988| - ${WEBUI_PORT_STASH:?err}:7777
|
|
989| - ${WEBUI_PORT_WHISPARR:?err}:6969
|
|
990| - ${WEBUI_PORT_QBITTORRENT:?err}:${WEBUI_PORT_QBITTORRENT:?err}
|
|
991| - ${QBIT_PORT:?err}:6881
|
|
992| - ${FLARESOLVERR_PORT:?err}:8191
|
|
993| - ${TDARR_SERVER_PORT:?err}:${TDARR_SERVER_PORT:?err}
|
|
994| - ${WEBUI_PORT_TDARR:?err}:${WEBUI_PORT_TDARR:?err}
|
|
995| - ${WEBUI_PORT_PLEX:?err}:32400
|
|
996| - ${WEBUI_PORT_TUBEARCHIVIST:-8000}:8000
|
|
997| - 8324:8324
|
|
998| - 32410:32410/udp
|
|
999| - 32412:32412/udp
|
|
1000| - 32413:32413/udp
|
|
1001| - 32414:32414/udp
|
|
1002| - 32469:32469
|
|
1003| extra_hosts:
|
|
1004| - ${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}
|
|
1005| - "*.${CLOUDFLARE_DNS_ZONE}:${LOCAL_DOCKER_IP}"
|
|
1006| volumes:
|
|
1007| - ${FOLDER_FOR_DATA:?err}/gluetun:/gluetun
|
|
1008| environment:
|
|
1009| - PUID=${PUID:?err}
|
|
1010| - PGID=${PGID:?err}
|
|
1011| - UMASK=${UMASK:?err}
|
|
1012| - TZ=${TIMEZONE:?err}
|
|
1013| - VPN_SERVICE_PROVIDER=${VPN_SERVICE_PROVIDER:?err}
|
|
1014| - OPENVPN_USER=${VPN_USERNAME:?err}
|
|
1015| - OPENVPN_PASSWORD=${VPN_PASSWORD:?err}
|
|
1016| - SERVER_COUNTRIES=${SERVER_COUNTRIES}
|
|
1017| - SERVER_REGIONS=${SERVER_REGIONS}
|
|
1018| - SERVER_CITIES=${SERVER_CITIES}
|
|
1019| - SERVER_HOSTNAMES=${SERVER_HOSTNAMES}
|
|
1020| - SERVER_CATEGORIES=${SERVER_CATEGORIES}
|
|
1021| - FIREWALL_OUTBOUND_SUBNETS=${LOCAL_SUBNET:?err}
|
|
1022| - OPENVPN_CUSTOM_CONFIG=${OPENVPN_CUSTOM_CONFIG}
|
|
1023| - HTTP_CONTROL_SERVER_ADDRESS=:${GLUETUN_CONTROL_PORT:?err}
|
|
1024| - VPN_TYPE=${VPN_TYPE}
|
|
1025| - VPN_ENDPOINT_IP=${VPN_ENDPOINT_IP}
|
|
1026| - VPN_ENDPOINT_PORT=${VPN_ENDPOINT_PORT}
|
|
1027| - WIREGUARD_PUBLIC_KEY=${WIREGUARD_PUBLIC_KEY}
|
|
1028| - WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
|
|
1029| - WIREGUARD_PRESHARED_KEY=${WIREGUARD_PRESHARED_KEY}
|
|
1030| - WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
|
|
1031| - HTTPPROXY=on
|
|
1032| - SHADOWSOCKS=on
|
|
1033| networks:
|
|
1034| - networking
|
|
1035|#+END_SRC
|
|
1036|
|
|
1037|** Authentik — Identity Provider
|
|
1038|
|
|
1039|Authentik provides universal authentication for all web services. It acts as
|
|
1040|both the SSO login page (via Traefik Forward Auth) and the OIDC provider for
|
|
1041|services that support it natively (Gitea, Jellyfin via plugin).
|
|
1042|
|
|
1043|The stack has two containers:
|
|
1044|- =authentik= (server) — handles login flows, session management, policies
|
|
1045|- =authentic-worker= — background tasks, outpost management
|
|
1046|
|
|
1047|Both connect to the same Postgres and Valkey databases.
|
|
1048|
|
|
1049|#+BEGIN_SRC yaml :tangle /docker/compose/services/authentik.yaml
|
|
1050|services:
|
|
1051| authentik:
|
|
1052| image: ghcr.io/goauthentik/server:${AUTHENTIK_VERSION:?err}
|
|
1053| container_name: authentik
|
|
1054| restart: unless-stopped
|
|
1055| networks:
|
|
1056| - networking
|
|
1057| user: ${PUID:?err}:${PGID:?err}
|
|
1058| command: server
|
|
1059| environment:
|
|
1060| - TZ=${TIMEZONE:?err}
|
|
1061| - AUTHENTIK_LOG_LEVEL=info
|
|
1062| - AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY:?err}
|
|
1063| - AUTHENTIK_REDIS__HOST=valkey
|
|
1064| - AUTHENTIK_POSTGRESQL__HOST=postgresql
|
|
1065| - AUTHENTIK_POSTGRESQL__NAME=${AUTHENTIK_DATABASE:?err}
|
|
1066| - AUTHENTIK_POSTGRESQL__USER=${POSTGRESQL_USERNAME:?err}
|
|
1067| - AUTHENTIK_POSTGRESQL__PASSWORD=${POSTGRESQL_PASSWORD:?err}
|
|
1068| - AUTHENTIK_ERROR_REPORTING__ENABLED=false
|
|
1069| - AUTHENTIK_EMAIL__HOST=${EMAIL_SERVER_HOST}
|
|
1070| - AUTHENTIK_EMAIL__PORT=${EMAIL_SERVER_PORT}
|
|
1071| - AUTHENTIK_EMAIL__USERNAME=${EMAIL_ADDRESS}
|
|
1072| - AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD}
|
|
1073| - AUTHENTIK_EMAIL__USE_TLS=true
|
|
1074| - AUTHENTIK_EMAIL__USE_SSL=false
|
|
1075| - AUTHENTIK_EMAIL__FROM=${EMAIL_SENDER}
|
|
1076| - AUTHENTIK_EMAIL__TIMEOUT=15
|
|
1077| volumes:
|
|
1078| - ${FOLDER_FOR_DATA:?err}/authentik/media:/media
|
|
1079| - ${FOLDER_FOR_DATA:?err}/authentik/templates:/templates
|
|
1080| ports:
|
|
1081| - ${WEBUI_PORT_AUTHENTIK:?err}:9000
|
|
1082| depends_on:
|
|
1083| postgresql:
|
|
1084| condition: service_healthy
|
|
1085| restart: true
|
|
1086| valkey:
|
|
1087| condition: service_healthy
|
|
1088| restart: true
|
|
1089| labels:
|
|
1090| - traefik.enable=true
|
|
1091| - traefik.http.routers.authentik.service=authentik
|
|
1092| - traefik.http.routers.authentik.rule=Host(`auth.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
1093| - traefik.http.routers.authentik.entrypoints=secureweb
|
|
1094| - traefik.http.routers.authentik.middlewares=security-headers@file,traefik-bouncer@file
|
|
1095| - traefik.http.services.authentik.loadbalancer.server.scheme=http
|
|
1096| - traefik.http.services.authentik.loadbalancer.server.port=9000
|
|
1097|#+END_SRC
|
|
1098|
|
|
1099|** Gitea — Git Hosting
|
|
1100|
|
|
1101|Gitea hosts the infrastructure repo and triggers the tangle-deploy pipeline.
|
|
1102|The runner connects via the authless internal entrypoint (:8083) so it can
|
|
1103|check out repos without SSO interference.
|
|
1104|
|
|
1105|#+BEGIN_SRC yaml :tangle /docker/compose/services/gitea.yaml
|
|
1106|services:
|
|
1107| gitea:
|
|
1108| image: docker.gitea.com/gitea:1.25.5
|
|
1109| container_name: gitea
|
|
1110| restart: always
|
|
1111| networks:
|
|
1112| - networking
|
|
1113| environment:
|
|
1114| - USER_UID=1000
|
|
1115| - USER_GID=1000
|
|
1116| volumes:
|
|
1117| - /docker/appdata/gitea:/data
|
|
1118| - /memex:/memex
|
|
1119| - /etc/timezone:/etc/timezone:ro
|
|
1120| - /etc/localtime:/etc/localtime:ro
|
|
1121| ports:
|
|
1122| - "3001:3000"
|
|
1123| - "2222:22"
|
|
1124| labels:
|
|
1125| - traefik.enable=true
|
|
1126| - traefik.http.routers.gitea.service=gitea
|
|
1127| - traefik.http.routers.gitea.rule=Host(`git.${CLOUDFLARE_DNS_ZONE:?err}`)
|
|
1128| - traefik.http.routers.gitea.entrypoints=tunnel
|
|
1129| - traefik.http.routers.gitea.middlewares=security-headers@file,traefik-bouncer@file
|
|
1130| - traefik.http.services.gitea.loadbalancer.server.scheme=http
|
|
1131| - traefik.http.services.gitea.loadbalancer.server.port=3000
|
|
1132|#+END_SRC
|
|
1133|
|
|
1134|** Infrastructure Services
|
|
1135|
|
|
1136|Core data and networking services that everything depends on.
|
|
1137|
|
|
1138|*** Postgresql
|
|
1139|#+BEGIN_SRC yaml :tangle /docker/compose/services/postgresql.yaml
|
|
1140|services:
|
|
1141| postgresql:
|
|
1142| image: docker.io/library/postgres:17
|
|
1143| container_name: postgresql
|
|
1144| restart: unless-stopped
|
|
1145| networks:
|
|
1146| - networking
|
|
1147| user: ${PUID:?err}:${PGID:?err}
|
|
1148| ports:
|
|
1149| - ${POSTGRESQL_PORT:?err}:5432
|
|
1150| healthcheck:
|
|
1151| test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
|
1152| start_period: 20s
|
|
1153| interval: 30s
|
|
1154| retries: 5
|
|
1155| timeout: 5s
|
|
1156| volumes:
|
|
1157| - ${FOLDER_FOR_DATA:?err}/postgresql:/var/lib/postgresql/data
|
|
1158| environment:
|
|
1159| - TZ=${TIMEZONE:?err}
|
|
1160| - POSTGRES_DB=${AUTHENTIK_DATABASE:?err}
|
|
1161| - POSTGRES_USER=${POSTGRESQL_USERNAME:?err}
|
|
1162| - POSTGRES_PASSWORD=${POSTGRESQL_PASSWORD:?err}
|
|
1163|#+END_SRC
|
|
1164|
|
|
1165|*** Valkey (Redis Alternative)
|
|
1166|#+BEGIN_SRC yaml :tangle /docker/compose/services/valkey.yaml
|
|
1167|services:
|
|
1168| valkey:
|
|
1169| image: valkey/valkey:alpine
|
|
1170| container_name: valkey
|
|
1171| restart: unless-stopped
|
|
1172| networks:
|
|
1173| - networking
|
|
1174| command: --save 60 1 --loglevel warning
|
|
1175| user: ${PUID:?err}:${PGID:?err}
|
|
1176| ports:
|
|
1177| - ${VALKEY_PORT:?err}:6379
|
|
1178| healthcheck:
|
|
1179| test: ["CMD-SHELL", "valkey-cli ping | grep PONG"]
|
|
1180| start_period: 20s
|
|
1181| interval: 30s
|
|
1182| retries: 5
|
|
1183| timeout: 3s
|
|
1184| volumes:
|
|
1185| - ${FOLDER_FOR_DATA:?err}/valkey:/data
|
|
1186|#+END_SRC
|
|
1187|
|
|
1188|*** Unbound — DNS Resolver
|
|
1189|#+BEGIN_SRC yaml :tangle /docker/compose/services/unbound.yaml
|
|
1190|services:
|
|
1191| unbound:
|
|
1192| image: mvance/unbound:latest
|
|
1193| container_name: unbound
|
|
1194| restart: unless-stopped
|
|
1195| networks:
|
|
1196| - networking
|
|
1197| ports:
|
|
1198| - 53:53/tcp
|
|
1199| - 53:53/udp
|
|
1200| volumes:
|
|
1201| - /docker/appdata/unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
|
|
1202|#+END_SRC
|
|
1203|
|
|
1204|** Tube Archivist — YouTube Archiving
|
|
1205|
|
|
1206|Tube Archivist downloads and indexes YouTube channels, playlists, and
|
|
1207|videos. Full-text search, metadata browsing, subscription management.
|
|
1208|
|
|
1209|The stack has three containers:
|
|
1210|- =tubearchivist= (main app) — Django web UI on port 8000
|
|
1211|- =tubearchivist-es= — Elasticsearch 8.17 for metadata + search
|
|
1212|- =tubearchivist-redis= — Redis for Celery task queue
|
|
1213|
|
|
1214|Tube Archivist routes through Gluetun VPN to avoid YouTube geo-blocking.
|
|
1215|
|
|
1216|#+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-bouncer@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
|
|
1293|
|
|
1294|** Remaining Services
|
|
1295|
|
|
1296|The following services follow the same pattern as those documented above.
|
|
1297|Each is a YAML fragment in =/docker/compose/services/= with its container
|
|
1298|definition, environment, volumes, and Traefik labels.
|
|
1299|
|
|
1300|- =traefik.yaml= — Reverse proxy (documented above)
|
|
1301|- =traefik-certs-dumper.yaml= — Export Let's Encrypt certs for other services
|
|
1302|- =crowdsec.yaml= — Intrusion prevention (blocks malicious IPs via Traefik bouncer)
|
|
1303|- =cloudflared.yaml= — Cloudflare Tunnel client
|
|
1304|- =gharbeia-site.yaml= — Static website via nginx
|
|
1305|- =homepage.yaml, homarr.yaml, heimdall.yaml= — Dashboard UIs
|
|
1306|- =grafana.yaml, prometheus.yaml= — Monitoring stack
|
|
1307|- =headscale.yaml, tailscale.yaml, headplane.yaml= — Wireguard mesh VPN
|
|
1308|- =ddns-updater.yaml= — Dynamic DNS
|
|
1309|- =portainer.yaml= — Docker GUI
|
|
1310|- =guacamole.yaml, guacd.yaml= — Remote desktop gateway
|
|
1311|- =unpackerr.yaml= — Archive extraction for *arr downloads
|
|
1312|- =runner.yaml= — Gitea Actions runner
|
|
1313|- =bazarr.yaml, flaresolverr.yaml= — Subtitle downloader, Cloudflare bypass
|
|
1314|- =jellyfin.yaml, jellyseerr.yaml= — Media server + request manager
|
|
1315|- =lazylibrarian.yaml, lidarr.yaml, mylar.yaml= — Ebook, music, comic managers
|
|
1316|- =prowlarr.yaml, radarr.yaml, sonarr.yaml, whisparr.yaml= — *arr indexer + library managers
|
|
1317|- =qbittorrent.yaml, sabnzbd.yaml= — Torrent and usenet clients
|
|
1318|- =stash.yaml= — Adult content library manager
|
|
1319|- =tdarr.yaml, tdarr-node.yaml= — Media transcoding automation
|
|
1320|- =tubearchivist.yaml= — YouTube archiving (Tube Archivist)
|
|
1321|- =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
|
|
|
|
|
|
** [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
|
|
1344|
|
|
1345|** [2026-05-15 Thu 09:30] Jellyfin SSO fixed — KnownProxies and Two-Step Flow
|
|
1346|- Root cause: Jellyfin's empty KnownProxies caused SSO plugin to use HTTP
|
|
1347| base URL, breaking the JavaScript two-step auth flow (iframe/POST/redirect)
|
|
1348|- Fix: Added 172.28.10.0/24 and 172.28.10.4 to Jellyfin's KnownProxies
|
|
1349|- Created jellyfin_admin Authentik group + linked user amr to it
|
|
1350|- Set EnableAuthorization=false in SSO-Auth plugin config
|
|
1351|- Documented three authentication mechanisms: Forward Auth, OIDC/SSO, local users
|
|
1352|- Fixed ASCII tree diagrams by wrapping in #+BEGIN_EXAMPLE blocks
|
|
1353|- Fixed trees rendering issue: Unicode box-drawing chars must be inside
|
|
1354| example blocks in org-mode, otherwise font rendering mangles them
|
|
1355|
|
|
1356|** [2026-05-15 Thu 06:40] Pipeline fixed — Emacs path and auth
|
|
1357|- Fixed Emacs org-loaddefs.el path in tangle-deploy
|
|
1358|- Created Gitea access token for git operations
|
|
1359|- Replaced Gitea Action workflow with systemd timer
|
|
1360|- tangle-deploy now pulls, tangles, and restarts on a 5-minute timer
|
|
1361|
|
|
1362|** [2026-05-15 Thu 06:50] Monolith split into modular compose
|
|
1363|- 42 service fragments created under /docker/compose/services/
|
|
1364|- Master docker-compose.yaml uses include: directives (43 services total)
|
|
1365|- All service labels and env vars preserved from original monolith
|
|
1366|- Compose validated with --env-file .env, all 43 services resolve
|
|
1367|- Deployment verified: all containers running
|
|
1368|- Orphaned unbound container absorbed into compose (was started manually)
|
|
1369|
|
|
1370|** [2026-05-15 Thu 03:47] Literate infrastructure established
|
|
1371|- infrastructure.org becomes the source of truth -- all config files are
|
|
1372| tangle targets embedded as =#+BEGIN_SRC= blocks with absolute paths
|
|
1373|- =tangle-deploy= script installed at =/usr/local/bin/tangle-deploy= on
|
|
1374| production-1; run after git push to regenerate configs and restart services
|
|
1375|- Gitea repo: =git@git.gharbeia.net:amr/infrastructure.git=
|
|
1376|
|
|
1377|** [2026-05-15 Thu 03:07] Internal entrypoint and Gitea runner
|
|
1378|- Created internal entrypoint on port 8083 for service-to-service traffic
|
|
1379|- Updated Gitea runner URL to use internal entrypoint
|
|
1380|- Documented three-path architecture
|
|
1381|
|
|
1382|** [2026-05-15 Thu 02:56] Static site and Error 1033 fix
|
|
1383|- Added gharbeia-site nginx container for root domain
|
|
1384|- Fixed CNAME record for bare domain pointing to correct tunnel
|
|
1385|
|
|
1386|** [2026-05-15 Thu 02:40] Jellyfin SSO and infrastructure.org
|
|
1387|- Configured Jellyfin SSO-Auth plugin with Authentik OIDC
|
|
1388|- Removed Forward Auth from Jellyfin Traefik labels
|
|
1389|- Created infrastructure.org as source of truth
|
|
1390|- Added Forward Auth to internal LAN routers
|
|
1391| |