Repository navigation
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
513 lines (494 loc) · 17 KB
/
Copy pathdocker-compose.yml
File metadata and controls
513 lines (494 loc) · 17 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
# host: server
# project: process — post-processing, analytics, *arr-tooling
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
# Phase 5: LSIO/PUID-based containers need these 5 caps to chown +
# drop privileges at startup.
x-lsio-caps: &lsio_caps
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
services:
tdarr:
<<: *hardened
cap_add: *lsio_caps
image: ghcr.io/haveagitgat/tdarr:2.81.01
container_name: tdarr
environment:
- PUID=999
- PGID=999
- TZ=Some/Region
- UMASK_SET=002
- serverIP=0.0.0.0
- serverPort=8266
- webUIPort=8265
- internalNode=true
- nodeName=server
volumes:
- /mnt/app/db/tdarr/server:/app/server
- /mnt/app/db/tdarr/configs:/app/configs
- /mnt/app/db/tdarr/logs:/app/logs
- /mnt/download/transcode/tdarr:/temp
- /mnt/data/media:/media
- /mnt/data2/media:/media2
- /mnt/data/user/Download:/download
ports:
- 8265:8265 # webUI port
# 8266 (server), 8267-8268 (internal node ports) intentionally NOT
# host-bound — internalNode=true serves all transcoding internally.
devices:
- /dev/dri:/dev/dri
networks:
- arr_egress
# Subarr stack — subtitle coordination (github.com/coaxk/subarr).
# subgen is the Whisper transcription worker (patched McCloudS/subgen,
# drop-in replacement for the old whisper-asr-webservice — Bazarr's
# whisperai provider points at subgen:9000). subarr orchestrates: reads
# Bazarr wanted lists, probes for genuinely-missing subs, queues jobs to
# subgen, triggers Bazarr rescans. CPU-only: faster-whisper/CTranslate2
# has no Intel iGPU backend, so the iGPU stays with Tdarr/Plex.
subgen:
<<: *hardened
# entrypoint chowns bind mounts + gosu-drops to PUID
cap_add: *lsio_caps
image: ghcr.io/coaxk/subarr-subgen:2026.05.3-r8
container_name: subgen
environment:
- PUID=999
- PGID=999
- TZ=Some/Region
- TRANSCRIBE_DEVICE=cpu
- WHISPER_MODEL=large-v3-turbo
# CPU transcription: one job at a time; Bazarr's synchronous provider
# requests bypass this limit by design, so 1 avoids three-way pile-ups.
- CONCURRENT_TRANSCRIPTIONS=1
volumes:
- /mnt/app/db/subgen/models:/subgen/models
- /mnt/app/db/subgen/cache:/cache
# Least-privilege: only the media subtrees, not the whole dataset. Paths
# match Bazarr (/mnt/data->/data, /mnt/data2->/data2) so transcription
# job paths resolve. subgen's entrypoint only chowns /subgen,/cache,/tmp.
- /mnt/data/media:/data/media
- /mnt/data2/media:/data2/media
networks:
- arr_control
- arr_egress # whisper model download (huggingface)
subarr:
<<: *hardened
# runs as root, app handles PUID/PGID for sidecar writes into
# plex-owned (999) media dirs
cap_add: *lsio_caps
image: ghcr.io/coaxk/subarr:2.3.1
container_name: subarr
depends_on:
- socket-proxy-subarr
environment:
- PUID=999
- PGID=999
- TZ=Some/Region
- UMASK=002
- SUBARR_PORT=9922
- SUBARR_DB_PATH=/config/subarr.db
- SUBGEN_URL=http://subgen:9000
- SUBGEN_CONTAINER=subgen
# Docker access via read-only socket-proxy (metadata GETs only,
# wizard auto-detect + monitor); raw docker.sock never mounted.
- DOCKER_HOST=tcp://socket-proxy-subarr:2375
- SUBARR_DOCKER_PROXY_URL=http://socket-proxy-subarr:2375
volumes:
- /mnt/app/db/subarr:/config
# Least-privilege: mount only the media subtrees, not the whole datasets.
# Paths match Bazarr (/mnt/data->/data, /mnt/data2->/data2) so subarr
# resolves Bazarr's wanted-list paths and writes sidecars there (999).
# Background: subarr 2.2.0's entrypoint ran `chown -R $PUID /data` with a
# top-level-only guard; with the whole /mnt/data mounted it recursively
# re-owned the dataset to 999, breaking Nextcloud + user's files. Fixed
# upstream in 2.2.1 (coaxk/subarr#369 / PR #370 — chowns only the DB dir,
# never /data). Narrowing here means even a regression can't reach
# anything but media (already 999-owned, so harmless).
- /mnt/data/media:/data/media
- /mnt/data2/media:/data2/media
ports:
- 9922:9922
networks:
- arr_control
- serve_plex_api # tautulli playback signal + plex refresh/audio hints
- process_subarr_internal
- arr_egress # HF model pulls (QE/VAD opt-ins) + port-publish NAT
# Read-only docker API proxy for subarr (same pattern as
# socket-proxy-homepage): container/network/image METADATA only, every
# write (POST/EXEC/DELETE/...) refused — a compromised subarr cannot
# pivot to host root. Trade-off: subarr's in-app subgen restart is
# disabled; restart subgen manually.
socket-proxy-subarr:
<<: *hardened
image: lscr.io/linuxserver/socket-proxy:3.4.0
container_name: socket-proxy-subarr
read_only: true
tmpfs:
- /run
environment:
- CONTAINERS=1 # GET container list/inspect/logs (wizard + log tail)
- NETWORKS=1 # GET /networks (wizard discovery)
- IMAGES=1 # GET image labels (version detection)
- INFO=1 # GET /info (reachability)
- PING=1
- VERSION=1
- POST=0 # no writes — blocks restart, create, kill, update
- ALLOW_START=0
- ALLOW_STOP=0
- ALLOW_RESTARTS=0
- EXEC=0
- DELETE=0
- VOLUMES=0
- BUILD=0
- COMMIT=0
- CONFIGS=0
- DISTRIBUTION=0
- EVENTS=0
- NODES=0
- PLUGINS=0
- SECRETS=0
- SERVICES=0
- SESSION=0
- SWARM=0
- SYSTEM=0
- TASKS=0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- process_subarr_internal
unpackerr:
<<: *hardened
image: golift/unpackerr:v0.16.1
container_name: unpackerr
user: 999:999
env_file:
- ../common/secrets/.runtime/media.env
environment:
- TZ=Some/Region
- UN_LOG_FILE=/logs/unpackerr.log
- UN_LOG_QUEUES=20m
# Unpackerr >=0.16 caps uncompressed bytes per top-level archive
# (defaults: Sonarr 20GB, Radarr 75GB, Lidarr 4GB). A capped item goes
# EXTRACTFAILED with no retry and nothing else in the stack can unrar
# a torrent, so the 4K/remux instances would strand releases. 0 = no
# cap. The fixed 5:1 ratio / 1000-file / 8-nested guards still apply,
# so a disk-fill bomb is bounded at 5x the download size.
- UN_SONARR_0_URL=http://sonarr:8989
- UN_SONARR_0_PROTOCOLS=torrent,usenet
- UN_SONARR_0_MAX_BYTES=0
- UN_SONARR_1_URL=http://sonarr4k:9090
- UN_SONARR_1_PROTOCOLS=torrent,usenet
- UN_SONARR_1_MAX_BYTES=0
- UN_RADARR_0_URL=http://radarr:7878
- UN_RADARR_0_PROTOCOLS=torrent,usenet
- UN_RADARR_0_MAX_BYTES=0
- UN_RADARR_1_URL=http://radarr4k:7979
- UN_RADARR_1_PROTOCOLS=torrent,usenet
- UN_RADARR_1_MAX_BYTES=0
- UN_LIDARR_0_URL=http://lidarr:8686
- UN_LIDARR_0_PROTOCOLS=torrent,usenet
- UN_LIDARR_0_MAX_BYTES=0
volumes:
- /mnt/download:/download
- /mnt/app/db/unpackerr/logs:/logs
networks:
- arr_control
kometa:
<<: *hardened
# cap_drop ALL strips root's DAC bypass; kometa rotates logs in
# /config/logs which is owned by plex:plex 0755 on the host, so root
# without DAC_OVERRIDE cannot create/rename files there (fails with
# `PermissionError: '/config/logs/meta-N.log'` on log rotation).
# Same class as the rclone DAC_READ_SEARCH fix, but kometa writes
# (rotation) so it needs DAC_OVERRIDE not the read-only variant.
cap_add:
- DAC_OVERRIDE
image: kometateam/kometa:v2.4.8
container_name: kometa
environment:
- KOMETA_CONFIG=/config/config.yml
- KOMETA_TIMES=10:00
volumes:
- /mnt/app/db/kometa:/config
# config.yml holds secrets — SOPS-managed, decrypted to .runtime and
# mounted over the /config dir mount. Writable (NOT :ro): Kometa
# persists default settings (e.g. auto_sort_hubs) back into config.yml
# during parse and hard-crashes on a read-only file. The .runtime copy
# is ephemeral (regenerated from SOPS on every deploy), so this write is
# non-destructive and never touches the encrypted source.
- ../common/secrets/.runtime/kometa-config.yml:/config/config.yml
- ./kometa/r:/r
- ./kometa/rc:/rc
- ./kometa/rc:/rf
- ./kometa/ov:/ov
networks:
- arr_control
- serve_plex_api
- arr_egress
kometa-ai:
<<: *hardened
# Entrypoint chowns state dirs and gosu-drops to PUID; with
# CLAUDE_BACKEND=cli it also downloads the Claude Code CLI on first start
cap_add: *lsio_caps
image: user/kometa-ai:0.5.9
container_name: kometa-ai
# Run once at startup (then hold the daily schedule) so a fresh deploy
# verifies itself instead of waiting for 03:00
command: ["--run-now"]
env_file:
# RADARR_API_KEY + CLAUDE_CODE_OAUTH_TOKEN (Claude subscription token)
- ../common/secrets/.runtime/kometa-ai.env
environment:
- RADARR_URL=http://radarr:7878
- CLAUDE_BACKEND=cli
- KOMETA_FIX_TAGS=false
# Soft cap on movies sent to Claude per run across all collections;
# paces backfill of new/changed collections so a large library can't
# exhaust the subscription window in one run (near-threshold re-evals
# and --force-refresh bypass it). 499 Existential members re-judged on
# the tightening deploy fit comfortably under this.
- MAX_EVALS_PER_RUN=2000 # backfill drained; steady-state cap (new-film + bounded near-threshold work only). Raise again only for a big re-eval.
- SMTP_SERVER=server.mylocal
- NOTIFICATION_RECIPIENTS=user@yourdomain.com
- NOTIFICATION_FROM=postfix@yourdomain.com
# Early-morning low-contention window: AFTER the 03:00 snapshot-backup
# container-stop and the 05:00 image updates, and before kometa's 10:00
# build — and past the late-night hours when the subscription 5h window is
# often in interactive use. (Deep-night 00:00-02:00 collides with late
# work sessions; 03:00-05:00 is blocked by backup + updates.)
- SCHEDULE_INTERVAL=1d
- SCHEDULE_START_TIME=06:00
- TZ=Some/Region
- PUID=999
- PGID=999
volumes:
- /mnt/app/db/kometa:/app/kometa-config:ro
- /mnt/app/db/kometa-ai/state:/app/state
- /mnt/app/db/kometa-ai/logs:/app/logs
healthcheck:
test: ["CMD", "python", "-m", "kometa_ai", "--health-check"]
interval: 5m
timeout: 30s
retries: 3
# First start downloads the Claude CLI
start_period: 5m
networks:
- arr_control # radarr
- arr_egress # claude.ai / api.anthropic.com
- nas_default # server.mylocal smtp (postfix host port 25)
recyclarr:
<<: *hardened
image: ghcr.io/recyclarr/recyclarr:8.6.0
container_name: recyclarr
environment:
- TZ=Some/Region
user: 999:999
volumes:
- /mnt/app/db/recyclarr:/config
networks:
- arr_control
- arr_egress # TRaSH-Guides repo + GitHub
fetcharr:
<<: *hardened
image: egg82/fetcharr:2.2.1
container_name: fetcharr
user: 999:999
# JVM app: image ships MaxRAMPercentage=75 + UseContainerSupport. Without a
# cgroup limit the JVM sizes its heap off the host's 125GiB (~94GiB max) and
# G1GC drifts RSS up to multiple GiB. Cap it so the heap stays ~384MB.
mem_limit: 512m
env_file:
- ../common/secrets/.runtime/media.env
environment:
- TZ=Some/Region
- LOG_MODE=info
- DATA_DIR=/data
- CACHE_DIR=/cache
- SEARCH_AMOUNT=5
- SEARCH_INTERVAL=6hours
- MONITORED_ONLY=true
- MISSING_ONLY=false
- USE_CUTOFF=true
- RADARR_0_URL=http://radarr:7878
- SONARR_0_URL=http://sonarr:8989
volumes:
- /mnt/app/db/fetcharr/data:/data
- /mnt/app/db/fetcharr/cache:/cache
networks:
- arr_control
tautulli:
<<: *hardened
# Tautulli image drops to the `tautulli` user via PUID/PGID; needs
# SETUID/SETGID. Bind mount perms benefit from DAC_OVERRIDE.
cap_add:
- SETUID
- SETGID
- DAC_OVERRIDE
image: tautulli/tautulli:v2.18.1
container_name: tautulli
environment:
- PUID=999
- PGID=999
- TZ=Some/Region
volumes:
- /mnt/app/db/tautulli/data:/config
ports:
- 8181:8181
networks:
- serve_plex_api
- arr_egress # plex.tv metadata + notifications
checkrr:
<<: *hardened
image: aetaric/checkrr:3.6.1
container_name: checkrr
ports:
- 8585:8585/tcp
volumes:
- /mnt/app/db/checkrr/checkrr.yaml:/etc/checkrr.yaml
- /mnt/app/db/checkrr:/config
- /mnt/data/media:/media
- /mnt/data2/media:/media2
networks:
- arr_control
- arr_egress
# Tracearr stack — Plex telemetry/analytics. Has its own internal
# network for app↔db↔redis. Only `tracearr` itself needs outbound
# network membership (receives Plex webhooks).
tracearr:
<<: *hardened
image: ghcr.io/connorgallopo/tracearr:1.4.28
container_name: tracearr
ports:
- "${PORT:-3000}:3000"
env_file:
- ../common/secrets/.runtime/rpt.env
environment:
- NODE_ENV=production
- PORT=3000
- HOST=0.0.0.0
- TZ=Some/Region
- DATABASE_URL=postgres://tracearr:${DB_PASSWORD:-tracearr}@tracearr-db:5432/tracearr
- REDIS_URL=redis://tracearr-redis:6379
- CORS_ORIGIN=${CORS_ORIGIN:-*}
- LOG_LEVEL=${LOG_LEVEL:-info}
depends_on:
tracearr-db:
condition: service_healthy
tracearr-redis:
condition: service_healthy
labels:
- "autoheal=true"
networks:
- process_tracearr_internal
- serve_plex_api # Plex sends webhooks here
- arr_egress
tracearr-db:
<<: *hardened
# Spilo entrypoint runs as root, manages /home/postgres/pgdata
# permissions, then drops to postgres user.
cap_add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
image: timescale/timescaledb-ha:pg18.1-ts2.25.0
container_name: tracearr-db
shm_size: 512mb
ulimits:
nofile:
soft: 65536
hard: 65536
command: postgres -c timescaledb.max_tuples_decompressed_per_dml_transaction=0 -c max_locks_per_transaction=4096 -c timescaledb.telemetry_level=off
environment:
- POSTGRES_USER=tracearr
- POSTGRES_PASSWORD=${DB_PASSWORD:-tracearr}
- POSTGRES_DB=tracearr
volumes:
- /mnt/app/db/tracearr/data:/home/postgres/pgdata/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tracearr"]
interval: 10s
timeout: 5s
retries: 5
networks:
- process_tracearr_internal
tracearr-redis:
<<: *hardened
# redis-alpine entrypoint uses `su-exec` to drop from root to redis user
# (SETUID/SETGID), and the AOF temp-file write fails without
# DAC_OVERRIDE on the host bind mount.
cap_add:
- SETUID
- SETGID
- DAC_OVERRIDE
image: redis:8-alpine
container_name: tracearr-redis
command: redis-server --appendonly yes
volumes:
- /mnt/app/db/tracearr/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- process_tracearr_internal
# plex-watch-sync: bidirectional watched/progress mirror between configured
# Plex accounts on the local server, scoped by Plex label (default `sync`).
# Source: github.com/user/plex-watch-sync
# Receives webhooks from Tautulli; calls Plex over serve_plex_api.
# Holds reconcile bookkeeping (seen-keys JSON) in /app/state. The state
# bind on the host must be chown 10001:10001 (the container's non-root
# uid) before first start; with that, no cap_add is needed.
plex-watch-sync:
<<: *hardened
image: ghcr.io/user/plex-watch-sync:0.5.4
container_name: plex-watch-sync
env_file:
- ../common/secrets/.runtime/plex-watch-sync.env
volumes:
- ./plex-watch-sync/config.yaml:/app/config.yaml:ro
- /mnt/app/db/plex-watch-sync:/app/state
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:38491/healthz', timeout=3).read()"]
interval: 30s
timeout: 5s
retries: 3
networks:
- serve_plex_api
- arr_egress # plex.tv outbound, for resolving friend server access tokens
networks:
nas_default:
external: true
arr_control:
external: true
arr_egress:
external: true
serve_plex_api:
external: true
process_tracearr_internal:
external: true
process_subarr_internal:
external: true