Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
745 lines (722 loc) · 27 KB
/
Copy pathdocker-compose.yml
File metadata and controls
745 lines (722 loc) · 27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
# host: server
# project: monitor — observability + ops infrastructure
x-defaults: &defaults
restart: unless-stopped
security_opt:
- no-new-privileges:true
logging: &log
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Phase 5: cap_drop baseline. Consumers do `<<: *hardened` + inline
# `cap_add: [...]` for caps they need back. `cap_drop: [ALL]` removes
# the 14 default capabilities Docker grants.
x-hardened: &hardened
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
logging: *log
services:
postfix:
<<: *hardened
# Postfix master/qmgr/smtpd drop to the postfix user via setuid.
# NET_BIND_SERVICE for port 25 inside container.
cap_add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
- NET_BIND_SERVICE
image: boky/postfix:5.1.0
container_name: postfix
ports:
- 25:25/tcp
env_file:
- ../common/secrets/.runtime/sys-postfix.env
environment:
# Rewrite From: header for ANY submitting client (not just trusted) so
# sender_canonical_maps applies to messages relayed in via SMTP from
# the host MTA. Without this, Office 365 rejects with `SendAsDenied`
# because the authenticated identity (postfix@yourdomain.com) cannot
# SendAs the host's local user.
POSTFIX_local_header_rewrite_clients: "static:all"
volumes:
- /mnt/app/db/postfix/queue:/var/spool/postfix
- ./postfix/sender_canonical_maps:/etc/postfix/sender_canonical_maps:ro
networks:
- mon_egress
# edge_internal so Nextcloud, Grimmory, and other email-senders can
# reach postfix:25 over a shared network. Without this they'd need
# host-port (server.mylocal:25) which is clunkier.
- edge_internal
rclone:
<<: *hardened
# Phase 5: ran as `rcd` (remote control daemon) only — no in-container
# FUSE mounts, so SYS_ADMIN + SETPCAP dropped. /dev/fuse still bound
# in case a future RC API call invokes a mount; no FUSE caps granted.
image: rclone/rclone:1.75.0
container_name: rclone
cap_add:
# cap_drop:ALL stripped root's DAC bypass, so the backup failed on
# 0700/0750 source trees (user home, nextcloud www-data 0770).
# Read-only backup: restore the minimal read/traverse bypass only,
# NOT DAC_OVERRIDE (which would also grant write bypass).
- DAC_READ_SEARCH
ports:
# Bind to the DMZ NIC only. Reachable from OpenVPN clients via
# 10.0.0.3:5572; pfSense doesn't WAN-forward 5572.
- 10.0.0.3:5572:5572/tcp
environment:
# Use the SOPS-decrypted config (mounted below) instead of the plaintext
# /config/rclone/rclone.conf. Honored by the rcd daemon and docker-exec calls.
- RCLONE_CONFIG=/config/rclone.conf
env_file:
- ../common/secrets/.runtime/sys-rclone.env
volumes:
- ../common/secrets/.runtime/rclone-monitor.conf:/config/rclone.conf:ro
- /mnt/app/db/rclone:/config/rclone
- /mnt/app/db/rclone:/logs
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- /dev/fuse:/dev/fuse
- /mnt/data:/data
- /mnt/data2:/data2
- /mnt/app:/opt
- /tmp/rclone:/htmp
command: rcd -v --rc-web-gui --rc-web-gui-no-open-browser --rc-addr :5572 --transfers=3 --copy-links
networks:
- mon_egress
uptime-kuma:
<<: *hardened
# Image's entrypoint drops to a non-root user; SETUID/SETGID required.
# SQLite db writes need DAC_OVERRIDE because the bind mount's perms
# don't always match the in-container uid after the privilege drop.
# NET_RAW required for ICMP monitor type (raw socket for ping).
cap_add:
- SETUID
- SETGID
- DAC_OVERRIDE
- NET_RAW
image: louislam/uptime-kuma:2
container_name: uptime-kuma
ports:
- "10.0.0.3:3011:3001"
volumes:
- /mnt/app/db/uptime-kuma:/app/data
networks:
- mon_internal
- mon_egress
- edge_internal
# Global autoheal sidecar. Restarts containers whose docker healthcheck
# reports `unhealthy` for AUTOHEAL_INTERVAL seconds. Opt-in: only services
# carrying label `autoheal=true` are watched. Excluded by design: plex
# (slow startup, stateful), gluetun (recreate kills child net namespace),
# qbittorrent (state, ordering quirk), traefik (manual intervention
# preferred), tdarr (long-running jobs).
autoheal:
<<: *hardened
image: willfarrell/autoheal:1.2.0@sha256:31f580ef0279eaced5b38d631b08c474d70d8403c1c2fdd6ddcf2e879d5f3f7c
container_name: autoheal
environment:
- AUTOHEAL_CONTAINER_LABEL=autoheal
- AUTOHEAL_INTERVAL=30
- AUTOHEAL_START_PERIOD=60
- DOCKER_SOCK=/var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# autoheal talks to docker daemon via socket only — no network membership needed.
# Zabbix proxy (forwards server metrics to cloud-server's zabbix-server).
# Note: NNP intentionally NOT set on this service. Adding it caused ICMP
# host-availability monitoring (server, ruckusAP1, c1000) to fail.
# fping has cap_net_raw=ep file caps which NNP appears to break. The
# x-defaults anchor would set NNP, so we override security_opt: [].
zabbix-proxy:
<<: *hardened
# NNP-off retained — proxy invokes /usr/bin/fping (setuid bit) for
# ICMP host-availability monitors. NNP would block the setuid jump
# and ICMP host availability would silently stop reporting.
security_opt: []
# gosu drop to zabbix user + fping ICMP + secrets file reads.
cap_add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
- NET_RAW
image: zabbix/zabbix-proxy-mysql:ubuntu-latest
container_name: zabbix-proxy
ports:
- 10052:10051
networks:
zabbix:
ipv4_address: 172.220.0.5
mon_internal: {}
environment:
- ZBX_PROXYMODE=0
- ZBX_HOSTNAME=server-proxy
- ZBX_SERVER_HOST=10.100.0.2:10051
- ZBX_STATSALLOWEDIP=10.100.0.2,127.0.0.1
- DB_SERVER_HOST=172.220.0.6
- DB_SERVER_PORT=3306
- MYSQL_USER_FILE=/run/secrets/MYSQL_USER
- MYSQL_PASSWORD_FILE=/run/secrets/MYSQL_PASSWORD
- MYSQL_ROOT_PASSWORD_FILE=/run/secrets/MYSQL_ROOT_PASSWORD
- MYSQL_DATABASE=zabbix
- ZBX_CACHESIZE=256M
secrets:
- MYSQL_USER
- MYSQL_PASSWORD
- MYSQL_ROOT_USER
- MYSQL_ROOT_PASSWORD
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./proxy-config/zabbix_proxy_db_tls.conf:/etc/zabbix/zabbix_proxy_db_tls.conf:ro
monitor-mysql:
<<: *hardened
# 8.4 LTS since 2026-07-20: accounts migrated to caching_sha2_password
# (proxy connects with DBTLSConnect=required, so full sha2 auth is fine);
# the removed-in-8.4 options were dropped from the command block.
image: mysql:8.4
container_name: monitor-mysql
# SYS_NICE for io_uring scheduling priority; LSIO 5-cap for the
# gosu-style drop to the mysql user + initdb chown.
cap_add:
- SYS_NICE
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
ports:
- 3306:3306
networks:
zabbix:
ipv4_address: 172.220.0.6
command:
- mysqld
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_bin
- --disable-log-bin
volumes:
- /mnt/app/db/zabbixproxy/var/lib/mysql:/var/lib/mysql:rw
env_file:
- ./env_vars/.env_db_mysql
secrets:
- MYSQL_USER
- MYSQL_PASSWORD
- MYSQL_ROOT_USER
- MYSQL_ROOT_PASSWORD
# Note: NNP intentionally NOT set on zabbix-agent2. Privileged for host
# inspection; the x-defaults anchor would set NNP via security_opt, so
# we override to empty.
zabbix-agent2:
<<: *hardened
# NNP-off must be retained — agent uses /usr/bin/fping (setuid bit)
# for ICMP host-availability monitors. NNP breaks the setuid jump
# and ICMP items stop reporting.
security_opt: []
# Phase 5: replaced `privileged: true` with an explicit cap set.
# - SYS_PTRACE: agent discovers processes via /proc/*/status
# - DAC_READ_SEARCH: reads host /proc, /sys, /var/log over /hostfs
# - DAC_OVERRIDE: opens files across host UIDs (containers/etc)
# - NET_ADMIN + NET_RAW: ICMP fping monitors + raw socket
# - SYS_RAWIO: hwmon/sensors items
# - SETUID/SETGID: image-internal drop from root to zabbix user
# If any item breaks after this change, restore `privileged: true`
# temporarily and re-tighten with that item's strace output.
cap_add:
- SYS_PTRACE
- DAC_READ_SEARCH
- DAC_OVERRIDE
- NET_ADMIN
- NET_RAW
- SYS_RAWIO
- SETUID
- SETGID
image: zabbix/zabbix-agent2:alpine-latest
container_name: zabbix-agent2
environment:
- ZBX_HOSTNAME=server
- ZBX_SERVER_HOST=172.220.0.1
- ZBX_SERVER_PORT=10052
- ZBX_DEBUGLEVEL=3
ports:
- 10050:10050/tcp
- 10051:10051/tcp
- 31999:31999/tcp
networks:
- zabbix
volumes:
- /mnt/app/db/zabbixagent2/db:/var/lib/zabbix/buffer
- /mnt/app/db/zabbixagent2/agentd.d:/etc/zabbix/zabbix_agentd.d
- /mnt/app/db/zabbixagent2/sudoers:/etc/sudoers
- /mnt/app/home/user/compose/zabbix/agentscripts:/agentscripts:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/log:/var/log:ro
- /mnt:/hostfs/mnt:ro
- /mnt/app:/hostfs/mnt/app:ro
- /mnt/data:/hostfs/mnt/data:ro
- /mnt/download:/hostfs/mnt/download:ro
group_add:
- "999" # add zabbix user to docker group within container
organizr:
<<: *hardened
# LSIO s6-overlay init runs chown/chmod across /config to normalise
# perms on the host bind mount; needs CHOWN/FOWNER/DAC_OVERRIDE.
# SETUID/SETGID required to drop to the `abc` user before app start.
cap_add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
image: organizr/organizr:latest@sha256:1ce319d73cdfd2666ec7ef21e15907531fabc8a6f333c4ac61e2b2e9d2d162f5
container_name: organizr
ports:
- 9393:80
volumes:
- /mnt/app/db/organizr:/config
environment:
- PUID=999
- PGID=999
- TZ=Some/Region
# mon_egress (bridge) added so Docker can DNAT host:9393 → container.
networks:
- mon_egress
- mon_internal
- edge_internal
homepage:
<<: *hardened
image: ghcr.io/gethomepage/homepage:v1.13.2
container_name: homepage
depends_on:
- socket-proxy-homepage
# Widget API keys/passwords ({{HOMEPAGE_VAR_*}} in services.yaml) come from
# SOPS — homepage.sops.yaml decrypted to .runtime/homepage.env.
env_file:
- ../common/secrets/.runtime/homepage.env
environment:
- HOMEPAGE_ALLOWED_HOSTS=server.mylocal:3001
- HOMEPAGE_FILE_SHELFMARK_COOKIE=/app/config/.shelfmark-cookie
# Talk to the docker API via the socket-proxy (no direct socket mount).
# The proxy is restricted to read-only CONTAINERS + INFO endpoints.
- DOCKER_HOST=tcp://socket-proxy-homepage:2375
ports:
- 3001:3000
volumes:
- /mnt/app/db/homepage:/app/config
- /mnt/download:/download:ro
- /mnt/data/media:/data/media:ro
- /mnt/data2/media:/data2/media:ro
- /mnt/transcode:/transcode:ro
# mon_egress (bridge) added so Docker can DNAT host:3001 → container.
# Internal-only network sets lose port-publishing NAT.
networks:
- mon_egress
- mon_internal
- edge_internal
# Read-only docker API proxy for Homepage. Replaces the raw /var/run/docker.sock
# mount Homepage previously had — narrows what an attacker who compromised
# Homepage could enumerate (no images, no networks, no volumes, no env-var
# leakage from container inspect).
socket-proxy-homepage:
<<: *hardened
image: lscr.io/linuxserver/socket-proxy:3.4.0
container_name: socket-proxy-homepage
read_only: true
tmpfs:
- /run
environment:
- CONTAINERS=1
- INFO=1
- IMAGES=0
- NETWORKS=0
- VOLUMES=0
- SERVICES=0
- TASKS=0
- SECRETS=0
- CONFIGS=0
- PLUGINS=0
- SYSTEM=0
- DISTRIBUTION=0
- SESSION=0
- SWARM=0
- NODES=0
- EVENTS=0
- PING=1
- VERSION=1
- POST=0
- DELETE=0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- mon_internal
# Real-time docker log viewer. Bound to the DMZ NIC only (10.0.0.3) so
# the UI is reachable over LAN/OpenVPN but not WAN.
dozzle:
<<: *hardened
image: amir20/dozzle:v10.9.0
container_name: dozzle
environment:
- DOZZLE_NO_ANALYTICS=true
- DOZZLE_HOSTNAME=server
# Talk to the docker API via a dedicated read-only socket-proxy.
# The proxy exposes LOGS + EVENTS + CONTAINERS + INFO only — dozzle
# cannot create, modify, or destroy anything.
- DOCKER_HOST=tcp://socket-proxy-dozzle:2375
ports:
- "10.0.0.3:8090:8080"
depends_on:
- socket-proxy-dozzle
healthcheck:
test: ["CMD", "/dozzle", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "autoheal=true"
networks:
- mon_internal # reach socket-proxy-dozzle
- mon_egress # port-publishing NAT + the (disabled) analytics check
# Dedicated read-only docker API proxy for Dozzle. Separate from
# socket-proxy-homepage so the two consumers have isolated trust
# surfaces — homepage doesn't get LOGS, dozzle doesn't get to ask
# questions homepage doesn't.
socket-proxy-dozzle:
<<: *hardened
image: lscr.io/linuxserver/socket-proxy:3.4.0
container_name: socket-proxy-dozzle
read_only: true
tmpfs:
- /run
environment:
- CONTAINERS=1
- INFO=1
- LOGS=1
- EVENTS=1
- PING=1
- VERSION=1
- IMAGES=0
- NETWORKS=0
- VOLUMES=0
- SERVICES=0
- TASKS=0
- SECRETS=0
- CONFIGS=0
- PLUGINS=0
- SYSTEM=0
- DISTRIBUTION=0
- SESSION=0
- SWARM=0
- NODES=0
- POST=0
- DELETE=0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- mon_internal
# Scrutiny - SMART attribute history + trend dashboard. Omnibus image
# bundles collector + web + InfluxDB 2.x. ADDITIVE only: Multi-Report and
# Zabbix remain the alerting path; Scrutiny is for visual wear trending
# (boot SA400 ~30% life, app-pool sdf ~90% TBW are worth watching).
#
# Device access: drives are behind the SAS HBA/expander where /dev/sdX
# names reorder across reboots, so an explicit `devices:` list would go
# stale and silently monitor the wrong set. Grant the block-device cgroup
# classes instead (major 8 = sd*, 259 = nvme) + bind /dev so smartctl
# always sees the live set. Phase 5 posture kept: NO `privileged: true`.
#
# Least-priv caps: SYS_RAWIO only (ATA/SCSI SMART passthrough). SYS_ADMIN
# is deliberately omitted - it is only needed for the NVMe smart-log
# ioctl, so the 2 NVMe special/cache vdevs (young, healthy) will be
# absent from Scrutiny while every SATA/SAS drive incl. the wear ones
# works. Add SYS_ADMIN back only if NVMe coverage is wanted. If it fails
# to start under cap_drop ALL, check `docker logs scrutiny` and restore
# caps per the zabbix-agent2 method. Collector runs once daily.
#
# Internal only - no traefik/edge. Reachable at server:8089 via the
# mon_egress bridge (mon_internal is internal:true so it cannot publish
# a host port; nothing intra-project needs to reach Scrutiny).
scrutiny:
<<: *hardened
cap_add:
- SYS_RAWIO
# SYS_ADMIN: NVMe smart-log ioctl needs it (the 2 NVMe special/cache
# vdevs); SATA/SAS only need SYS_RAWIO.
- SYS_ADMIN
# The omnibus image ships /opt/scrutiny/web (and assets/) as mode
# 0644 DIRECTORIES - no search/execute bit - and relies on the
# container running as root with full DAC bypass to traverse them.
# `<<: *hardened` cap_drop ALL removes that, so the web server can
# readdir but cannot open index.html/*.js -> browser gets a raw file
# listing. DAC_READ_SEARCH restores read+traverse only (read-only;
# NOT DAC_OVERRIDE - config/influxdb are root-owned proper-mode bind
# mounts that already work).
- DAC_READ_SEARCH
image: ghcr.io/analogj/scrutiny:v0.9.2-omnibus
container_name: scrutiny
environment:
- COLLECTOR_CRON_SCHEDULE=0 0 * * *
- TZ=Some/Region
device_cgroup_rules:
# SCSI-disk majors: 8 = sda-sdp only; sdq+ rolls to 65, then 66-71
# and 128-135. Drives span the SAS expander across several majors,
# so grant the full SCSI-disk major set (still least-priv: specific
# block majors, not `privileged`). 259 = nvme/blkext kept for
# forward-compat, but NVMe SMART also needs SYS_ADMIN (intentionally
# omitted) so the NVMe controllers stay absent by design.
- "b 8:* rwm"
- "b 65:* rwm"
- "b 66:* rwm"
- "b 67:* rwm"
- "b 68:* rwm"
- "b 69:* rwm"
- "b 70:* rwm"
- "b 71:* rwm"
- "b 128:* rwm"
- "b 129:* rwm"
- "b 130:* rwm"
- "b 131:* rwm"
- "b 132:* rwm"
- "b 133:* rwm"
- "b 134:* rwm"
- "b 135:* rwm"
- "b 259:* rwm"
# NVMe SMART uses the controller CHARACTER device /dev/nvmeN
# (smartctl --device nvme), not the nvme0n1 block node. char major
# 245 = nvme, 244 = nvme-generic. CAVEAT: these are *dynamically*
# allocated majors (unlike the static SCSI block majors above) - if
# NVMe drives drop out of Scrutiny after a kernel/TrueNAS upgrade,
# re-check `grep nvme /proc/devices` and update these numbers.
- "c 244:* rwm"
- "c 245:* rwm"
volumes:
- /mnt/app/db/scrutiny/config:/opt/scrutiny/config
- /mnt/app/db/scrutiny/influxdb:/opt/scrutiny/influxdb
- /run/udev:/run/udev:ro
- /dev:/dev:ro
ports:
- 8089:8080
networks:
- mon_egress
# Beszel - lightweight server-monitoring hub (PocketBase web app + SQLite).
# Evaluated mainly for its live per-container CPU/mem/net view, complementing
# Zabbix (alerting), Scrutiny (SMART), Dozzle (logs), Uptime-Kuma (uptime).
#
# The hub connects OUT to beszel-agent over mon_internal (beszel-agent:45876),
# authenticating with the hub's own ed25519 public key. Internal only - bound
# to the DMZ NIC (10.0.0.3:8092) like dozzle/scrutiny/uptime-kuma; no WAN.
# 8092 chosen to avoid 8090 (dozzle), 8089 (scrutiny), 3001/3011/9393.
#
# Hardening: PocketBase binds 8090 (non-privileged) and owns /beszel_data as
# root via the bind mount, so cap_drop ALL with no caps should suffice. If
# first-run logs show permission errors on /beszel_data, restore CHOWN/
# DAC_OVERRIDE per the zabbix-agent2 triage method.
beszel:
<<: *hardened
image: henrygd/beszel:0
container_name: beszel
environment:
- APP_URL=http://server.mylocal:8092
ports:
- "10.0.0.3:8092:8090"
volumes:
- /mnt/app/db/beszel/beszel_data:/beszel_data
networks:
# mon_internal to reach beszel-agent; mon_egress (bridge) for host-port
# DNAT + outbound update checks (an internal-only net loses port publishing).
- mon_internal
- mon_egress
# Beszel agent - collects host + per-container stats for the hub. Runs in
# SSH-listen mode: it listens on 45876 (mon_internal) and the hub dials in,
# authenticating against KEY (the hub's ed25519 PUBLIC key, derived from
# /mnt/app/db/beszel/beszel_data/id_ed25519). A public key is not a secret -
# it's authorized_keys material - so it's inlined rather than SOPS-encrypted.
# No host port published; only the hub (same mon_internal) reaches it.
#
# Reads Docker via the dedicated read-only socket-proxy (DOCKER_HOST), not a
# raw socket mount - matches the homepage/dozzle posture. Container CPU/mem/net
# stats come from the docker API; host CPU/mem from the container's host-wide
# /proc. Host root-disk + temperature panels need a host-fs/`/sys` mount +
# FILESYSTEM - out of scope for this trial (the goal is container stats).
#
# Hardening: reads /proc and writes its root-owned data dir as root, listens
# on a non-privileged port, talks to the proxy over TCP - cap_drop ALL, no
# caps. If logs show permission errors, triage per the zabbix-agent2 method.
beszel-agent:
<<: *hardened
image: henrygd/beszel-agent:0
container_name: beszel-agent
depends_on:
- socket-proxy-beszel
environment:
- LISTEN=45876
- NETWORK=tcp
- KEY=ssh-ed25519 AAAA...changeme
# Read the docker API via the dedicated read-only proxy (no socket mount).
- DOCKER_HOST=tcp://socket-proxy-beszel:2375
# Headline the CPU package temp in the All-Systems table (default is the
# hottest sensor, which on this 23-drive NAS is always a drivetemp). All
# 41 sensors stay available in the system detail view - this only sets the
# summary sensor. Key string is Beszel's exact sensor id (verified against
# the hub's recorded system_stats; per-drive temps also live in Scrutiny).
- PRIMARY_SENSOR=coretemp_package_id_0
volumes:
- /mnt/app/db/beszel/agent_data:/var/lib/beszel-agent
# No /extra-filesystems disk panels: TrueNAS owns disk usage + I/O. ZFS
# nested datasets defeat Beszel's single-statfs-per-mount model (a pool
# root only refers a sliver of the pool's bytes) and per-pool I/O has no
# /proc/diskstats device, so the pool panels were misleading. The default
# root-disk gauge (docker overlay on the app pool) is left as-is.
# Temps work without any /sys mount (the container reads host hwmon).
networks:
- mon_internal
# Dedicated read-only docker API proxy for the Beszel agent. Scoped to the
# minimum Beszel polls: CONTAINERS (list + /containers/{id}/stats) + INFO,
# plus PING/VERSION. EVENTS left off - Beszel polls the container list on an
# interval, so it discovers new containers without the events stream; flip
# EVENTS=1 if newly-started containers are slow to appear.
socket-proxy-beszel:
<<: *hardened
image: lscr.io/linuxserver/socket-proxy:3.4.0
container_name: socket-proxy-beszel
read_only: true
tmpfs:
- /run
environment:
- CONTAINERS=1
- INFO=1
- PING=1
- VERSION=1
- IMAGES=0
- NETWORKS=0
- VOLUMES=0
- SERVICES=0
- TASKS=0
- SECRETS=0
- CONFIGS=0
- PLUGINS=0
- SYSTEM=0
- DISTRIBUTION=0
- SESSION=0
- SWARM=0
- NODES=0
- EVENTS=0
- POST=0
- DELETE=0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- mon_internal
# Live Prowlarr indexer usefulness report (ranks indexers by grabs / trend /
# query cost / per-app source; flags ones safe to disable). Read-only against
# Prowlarr. Source: github.com/user/prowlarr-indexer-report. UI at
# server.mylocal:9697. cap_drop:ALL is fine — non-root (uid 10001), binds
# an unprivileged port, writes nothing, reads no host files.
prowlarr-indexer-report:
<<: *hardened
image: ghcr.io/user/prowlarr-indexer-report:0.6.1
container_name: prowlarr-indexer-report
env_file:
# PROWLARR_API_KEY — decrypted from prowlarr-indexer-report.sops.yaml.
- ../common/secrets/.runtime/prowlarr-indexer-report.env
environment:
- PROWLARR_URL=http://server.mylocal:9696
# Browser-facing URL so the report can deep-link to Prowlarr's indexer list.
- PROWLARR_PUBLIC_URL=http://server.mylocal:9696
- WINDOW_DAYS=90
- REFRESH_INTERVAL_MINUTES=15
ports:
- 9697:8787
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
# mon_egress (bridge): publishes host:9697 and provides outbound to reach
# Prowlarr at server.mylocal:9696.
networks:
- mon_egress
# "Is anyone using the system right now?" for the two stacks that do NOT go
# through Plex/Tautulli: BookOrbit (ebooks) and RomM (retro games). Exists
# for the PRE-CHANGE GATE — check this before a reboot, a router upgrade, or
# anything else disruptive, the same way Tautulli get_activity is checked for
# Plex.
#
# Read-only by construction: a SELECT-only postgres role (svc_activity) and a
# RomM client token scoped to roms.user.read ONLY — verified 403 on /api/roms.
activity-monitor:
<<: *hardened
build: ./activity-monitor
image: activity-monitor:local
container_name: activity-monitor
env_file:
# BOOKORBIT_DB_PASSWORD + ROMM_TOKEN, from activity-monitor.sops.yaml.
- ../common/secrets/.runtime/activity-monitor.env
environment:
# RomM over its published host port rather than by container name, so
# this service does not need to join nas_default (same approach as
# prowlarr-indexer-report reaching Prowlarr).
- ROMM_URL=http://server.mylocal:8095
- BOOKORBIT_DB_HOST=bookorbit-postgres
# A page turn is the only thing that moves reading_progress.updated_at,
# so this must tolerate a slow reader sitting on one page. RomM is not
# subject to it — its own 90s Redis TTL already defines "active".
- READER_WINDOW_MIN=15
- REFRESH_SECONDS=30
ports:
- 9698:8788
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8788/healthz', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
# serve_books_bookorbit_db is internal:true — bookorbit-postgres publishes
# no host port, so joining it is the only way to reach the DB. mon_egress
# (bridge) is what restores port publishing and gives outbound to RomM:
# a container on internal-only networks silently loses published ports.
networks:
- mon_egress
- serve_books_bookorbit_db
secrets:
MYSQL_USER:
file: ./env_vars/.MYSQL_USER
MYSQL_PASSWORD:
file: ./env_vars/.MYSQL_PASSWORD
MYSQL_ROOT_USER:
file: ./env_vars/.MYSQL_ROOT_USER
MYSQL_ROOT_PASSWORD:
file: ./env_vars/.MYSQL_ROOT_PASSWORD
networks:
# External networks created by ensure_external_networks. mon_internal
# is internal:true (no egress); mon_egress and edge_internal carry
# outbound and ingress traffic respectively.
mon_internal:
external: true
mon_egress:
external: true
edge_internal:
external: true
# Owned by the serve-books project (internal:true). activity-monitor joins it
# read-only to reach bookorbit-postgres, which publishes no host port.
serve_books_bookorbit_db:
external: true
# zabbix is a project-internal network with custom IPAM. The 172.220.0.0/16
# subnet is referenced inline by zabbix-proxy (172.220.0.5), monitor-mysql
# (172.220.0.6), and zabbix-agent2 (which uses the 172.220.0.1 gateway as
# ZBX_SERVER_HOST). Stays project-scoped — compose prefixes the network
# name with the project (becomes `monitor_zabbix`).
zabbix:
ipam:
config:
- subnet: 172.220.0.0/16
gateway: 172.220.0.1