-
-
Notifications
You must be signed in to change notification settings - Fork 71
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
1328 lines (1290 loc) · 69.3 KB
/
Copy pathdocker-compose.yml
File metadata and controls
1328 lines (1290 loc) · 69.3 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
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Self-host for loopover (#980). Two ways to boot the `loopover` service — everything else in
# this compose file (profiles, env, health checks) is identical either way:
#
# RECOMMENDED: ./scripts/deploy-selfhost-image.sh # pulls the published image, no local build
# Building from source: docker compose up --build # for contributors / custom Dockerfile changes
#
# SECRETS: copy .env.selfhost.example → .env (the focused self-host starter) or .env.example → .env
# (the exhaustive reference) and fill in your GitHub App credentials and any optional secrets. The
# file is gitignored. Every value in either sample is a placeholder — never commit real secrets.
#
# PROFILES — activate optional services by passing --profile <name> (combine freely):
#
# (none) SQLite + Redis single-node stack (default — no flags needed)
# --profile postgres pgvector/pg16 shared database (multi-instance capable)
# --profile pgbouncer PgBouncer connection pooler in front of Postgres
# --profile qdrant Qdrant vector database for RAG
# --profile ollama Local Ollama AI backend
# --profile gpu GPU metrics exporter (nvidia-smi -> Prometheus -> Grafana), requires the NVIDIA
# Container Toolkit; combine with --profile ollama on a GPU host
# --profile visual-review Headless Chromium (browserless) for before/after PR screenshot capture
# --profile rees Review-enrichment service (REES) for heavier PR analysis, in-network only
# --profile litestream Continuous SQLite backup to S3/B2/R2 via Litestream
# --profile workflows n8n workflow automation (Slack/Teams/email fan-out, scheduled reports)
# --profile storage MinIO S3-compatible object store (Litestream destination, artifact blobs)
# --profile caddy Caddy HTTPS terminator with auto-TLS (set DOMAIN= in .env)
# --profile observability Prometheus + Alertmanager + Loki + Promtail + Grafana (pre-wired)
# --profile ams-observability Exports AMS (loopover-miner) attempt-log/prediction-ledger data to the
# AMS Attempt Log / Prediction Ledger Grafana datasources
# --profile tailscale Tailscale sidecar — access the stack via your tailnet
# --profile runners GitHub Actions self-hosted runner
# --profile backup Scheduled Postgres/SQLite + Qdrant-snapshot backups with freshness metrics
#
# Examples:
# ./scripts/deploy-selfhost-image.sh # SQLite + Redis, published image
# docker compose up --build # SQLite + Redis, build from source
# docker compose --profile postgres --profile caddy up -d # Postgres + HTTPS
# docker compose --profile observability up -d # metrics + logs + dashboards
# docker compose --profile rees up -d # local REES review enrichment
# docker compose --profile workflows up -d # n8n automation at :5678
# docker compose --profile storage up -d # MinIO S3 API :9000, console :9001
# docker compose --profile tailscale --profile runners up -d # tailnet + CI runners
#
# HOST CLOCK (#3811): containers share the HOST kernel clock, so this compose file cannot fix a
# drifting clock for you -- configure at least two independent NTP sources on the host itself
# (chrony/ntpd), not just one. A single dead NTP source silently drifts the clock with no local
# symptom until GitHub App JWT auth starts failing ("Bad credentials") once the drift exceeds the
# JWT's 60s skew tolerance. See docs/self-hosting/operations → "Host clock sync (NTP)" and the
# loopover_clock_skew_seconds Grafana panel/alert (--profile observability) for live drift.
# Bounded container logging (#audit-rate-headroom): every service below defaults to Docker's
# json-file driver, which has NO size cap on its own -- a long-running 24/7 stack can fill the
# host disk purely from log growth (the app, Postgres, Redis, and the CI runners are the biggest
# producers). This anchor caps each service to 3 rotated files of 10MB (30MB/service ceiling,
# ~600MB worst case across every service in this file) and is merged into every service via
# `<<: *default-logging`. Override per-service if you need more retained log history.
x-logging: &default-logging
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Self-hosted GitHub Actions runner temp env (#selfhost-runner-tmp): a container's plain /tmp lives in
# Docker's overlay/containerd snapshot storage, NOT any mounted volume -- high-volume CI scratch writes
# there (language toolchains, build caches, ad hoc `mktemp` calls) grow the HOST's Docker root storage
# directly and are invisible to volume-scoped cleanup (confirmed in production: unbounded overlay growth,
# disk pressure, and `docker system df` races against disappearing temp files). Every self-hosted-runner
# service -- the `runner` service below, merged in via `<<: *runner-tmp-env`, AND any additional runner
# service you add for a multi-runner deployment -- must point TMPDIR/TMP/TEMP at its own mounted
# runner-work-style volume the same way, so job temp writes always land on mounted storage instead of the
# container's writable layer. A second compose file (e.g. docker-compose.override.yml) CANNOT reference
# this anchor directly -- YAML anchors don't cross files -- so an override adding another runner service
# must redeclare this same x-runner-tmp-env block in its own file before merging it. See
# docker-compose.override.yml.example and the self-hosting operations docs for the full pattern.
x-runner-tmp-env: &runner-tmp-env
TMPDIR: /tmp/runner/tmp
TMP: /tmp/runner/tmp
TEMP: /tmp/runner/tmp
services:
# ── Core app (always runs) ─────────────────────────────────────────────────
loopover:
build:
context: .
args:
INSTALL_AI_CLIS: "${INSTALL_AI_CLIS:-true}"
INSTALL_VISUAL_REVIEW: "${INSTALL_VISUAL_REVIEW:-false}"
restart: unless-stopped
<<: *default-logging
ports:
# Remove this when using the caddy profile — Caddy becomes the public listener.
- "${PORT:-8787}:8787"
env_file:
- path: .env
required: false
environment:
PORT: "8787"
DATABASE_PATH: /data/loopover.sqlite
# Container-private per-repo config (#1390): point at a mounted dir holding {owner}__{repo}/.loopover.yml
# files layered over an optional root .loopover.yml global default, itself layered over an optional
# _shared/.loopover.yml cross-repo base for multi-repo operators (#1959) — a higher layer overrides only the
# keys it sets; anything it doesn't mention is inherited from the next layer down — see
# config/examples/README.md). Keeps each repo's policy OUT of the public GitHub repo so contributors can't
# game the rules. Empty/unmounted ⇒ repos use the public file / defaults.
LOOPOVER_REPO_CONFIG_DIR: "${LOOPOVER_REPO_CONFIG_DIR:-/config}"
# Claude Code usage telemetry → OTEL collector → Prometheus → Claude usage dashboard. OFF unless you set
# CLAUDE_CODE_ENABLE_TELEMETRY=1 in .env (requires --profile observability so the otel-collector is up).
CLAUDE_CODE_ENABLE_TELEMETRY: "${CLAUDE_CODE_ENABLE_TELEMETRY:-}"
OTEL_METRICS_EXPORTER: "${OTEL_METRICS_EXPORTER:-otlp}"
# Claude Code trace export (beta) → OTEL collector → Tempo. Empty by default; set OTEL_TRACES_EXPORTER=otlp
# in .env (with CLAUDE_CODE_ENABLE_TELEMETRY=1) to capture per-review spans.
OTEL_TRACES_EXPORTER: "${OTEL_TRACES_EXPORTER:-}"
OTEL_EXPORTER_OTLP_PROTOCOL: "${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318}"
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: "${OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE:-cumulative}"
OTEL_METRIC_EXPORT_INTERVAL: "${OTEL_METRIC_EXPORT_INTERVAL:-10000}"
# Uncomment the next two lines and activate --profile postgres to use Postgres:
# DATABASE_URL: postgres://loopover:${POSTGRES_PASSWORD:-CHANGEME}@postgres:5432/loopover
# PGVECTOR_ENABLED: "true"
# With --profile pgbouncer, route through the pooler instead of postgres directly:
# DATABASE_URL: postgres://loopover:${POSTGRES_PASSWORD:-CHANGEME}@pgbouncer:5432/loopover
# Required shared transient state for review correctness, webhook dedup, and rate limiting.
REDIS_URL: "${REDIS_URL:-redis://redis:6379}"
# Enables the GitHub GET-response cache when >0 (0 disables); not a per-entry TTL, see .env.example.
GITHUB_CACHE_TTL_SECONDS: "${GITHUB_CACHE_TTL_SECONDS:-20}"
# Native secret-file mounts (see the top-level `secrets:` block + secrets/README.md). Each points at
# the file Compose mounts below; src/selfhost/load-file-secrets.ts only reads it when the plain
# <NAME> variable above is unset, so an inline .env value always takes priority over these — purely
# additive, zero effect on an operator who has never touched secrets/.
GITHUB_APP_PRIVATE_KEY_FILE: "${GITHUB_APP_PRIVATE_KEY_FILE:-/run/secrets/github_app_private_key}"
GITHUB_WEBHOOK_SECRET_FILE: "${GITHUB_WEBHOOK_SECRET_FILE:-/run/secrets/github_webhook_secret}"
LOOPOVER_API_TOKEN_FILE: "${LOOPOVER_API_TOKEN_FILE:-/run/secrets/loopover_api_token}"
LOOPOVER_MCP_TOKEN_FILE: "${LOOPOVER_MCP_TOKEN_FILE:-/run/secrets/loopover_mcp_token}"
INTERNAL_JOB_TOKEN_FILE: "${INTERNAL_JOB_TOKEN_FILE:-/run/secrets/internal_job_token}"
SELFHOST_SETUP_TOKEN_FILE: "${SELFHOST_SETUP_TOKEN_FILE:-/run/secrets/selfhost_setup_token}"
TOKEN_ENCRYPTION_SECRET_FILE: "${TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/token_encryption_secret}"
DRAFT_TOKEN_ENCRYPTION_SECRET_FILE: "${DRAFT_TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/draft_token_encryption_secret}"
ORB_ENROLLMENT_SECRET_FILE: "${ORB_ENROLLMENT_SECRET_FILE:-/run/secrets/orb_enrollment_secret}"
PAGERDUTY_ROUTING_KEY_FILE: "${PAGERDUTY_ROUTING_KEY_FILE:-/run/secrets/pagerduty_routing_key}"
CLAUDE_CODE_OAUTH_TOKEN_FILE: "${CLAUDE_CODE_OAUTH_TOKEN_FILE:-/run/secrets/claude_code_oauth_token}"
# Uncomment for Qdrant RAG vector store (--profile qdrant):
# QDRANT_URL: http://qdrant:6333
# Uncomment for Ollama AI (--profile ollama):
# AI_PROVIDER: ollama
# OLLAMA_AI_BASE_URL: http://ollama:11434/v1
# BROKERED mode — use the central LoopOver Orb App instead of creating your own GitHub App. Install the
# Orb App on your repos + set ORB_ENROLLMENT_SECRET in .env (loaded above); the engine then brokers
# short-lived GitHub tokens from the Orb on demand (no own App private key). See .env.example.
# ORB_BROKER_URL: https://api.loopover.ai # override only for a private Orb deployment
volumes:
- loopover-data:/data
# Container-private per-repo config dir (read-only). Create ./loopover-config/{owner}__{repo}/.loopover.yml
# locally, optionally alongside a ./loopover-config/.loopover.yml global default that every per-repo file
# is deep-merged over (gitignored — never commit real policy; see config/examples/ for generic templates).
# Absent ⇒ Docker mounts an empty dir ⇒ defaults apply.
- ./loopover-config:/config:ro
# Mounted read-only at /run/secrets/<name> (Compose's default target). See the top-level `secrets:`
# block above and secrets/README.md.
#
# NOT uid/gid/mode-pinned (#secrets-uid-mismatch, confirmed against a real Compose v5.3.1 daemon, not
# just docs): standalone (non-Swarm) Compose secrets are a plain bind mount under the hood, and a bind
# mount cannot remap ownership -- Compose itself warns "secrets `uid`, `gid` and `mode` are not
# supported, they will be ignored" and silently no-ops them, so the in-container permissions are
# whatever the HOST file has. This is WHY scripts/selfhost-init-secrets.sh chmod 644s (not 600s) each
# placeholder -- see that script's own comment for the full reasoning (the Dockerfile's `USER node`
# runs as uid 1000/gid 1000, essentially never the deploying host user's own uid, so a host-owner-only
# file is unreadable to the container process; load-file-secrets.ts's readFileSync throws, surfaced as
# a Sentry-visible selfhost_secret_file_unreadable error -- first caught live on edge-nl-01).
secrets:
- github_app_private_key
- github_webhook_secret
- loopover_api_token
- loopover_mcp_token
- internal_job_token
- selfhost_setup_token
- token_encryption_secret
- draft_token_encryption_secret
- orb_enrollment_secret
- pagerduty_routing_key
- claude_code_oauth_token
depends_on:
redis:
condition: service_healthy
# Gate startup on a healthy Qdrant when --profile qdrant is active; required:false means the
# dependency is ignored when qdrant isn't started (default/other profiles). Belt-and-suspenders
# with the app-side waitForQdrant retry in src/server.ts (which also covers a mid-life restart).
qdrant:
condition: service_healthy
required: false
# Same pattern for --profile postgres / --profile pgbouncer: without this, compose starts loopover
# in parallel with a cold Postgres initdb instead of waiting for it, and the app's own waitForPostgres()
# fallback (~30s hard cap) commonly loses that race on a slow disk/first pull, crash-looping the app via
# restart: unless-stopped before Postgres is reachable. required:false keeps the default SQLite path
# (no profile active) unaffected.
postgres:
condition: service_healthy
required: false
pgbouncer:
condition: service_healthy
required: false
# Unlike postgres/qdrant above, this is NOT a crash-loop risk if lost: visual capture is used
# per-PR-event, long after boot, not at startup, and every capture call is already fail-safe
# (a network error just degrades to a placeholder, never a thrown error). This dependency exists
# purely so `docker compose --profile visual-review up` starts services in a sensible order.
browserless:
condition: service_healthy
required: false
# Same "start-order convenience only, not a crash-loop guard" reasoning as browserless above: REES
# enrichment runs per-PR-review, long after boot, and is already fully fail-safe end to end (any
# timeout/error just means the review proceeds without the brief -- src/review/enrichment-wire.ts).
# required:false keeps every deployment not using --profile rees unaffected.
rees:
condition: service_healthy
required: false
# Sane defaults so a runaway optional service (below) can't OOM-kill or starve the core review pipeline;
# override via .env for a bigger/smaller host (#1828).
deploy:
resources:
limits:
memory: "${LOOPOVER_MEM_LIMIT:-2g}"
healthcheck:
# Probe /ready (not /health): /health is a liveness stub that is 200 even when the DB is down,
# whereas /ready returns 503 until the DB answers AND migrations are applied — so dependents that
# wait on `condition: service_healthy` only start once the app is truly serving. start_period 60s
# tolerates the Postgres cold start (waitForPostgres blocks up to 30s before the HTTP server binds).
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8787/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
# ── Redis (always on; required by the review runtime) ─────────────────────
# Shared transient review state: pending-CI first-seen stamps, webhook dedup/coalescing, rate limits, and
# short-lived GitHub caches. Losing it on restart is safe; the app requires it while running.
redis:
image: redis:7-alpine
restart: unless-stopped
<<: *default-logging
command:
- redis-server
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
- --save
- ""
- --appendonly
- "no"
deploy:
resources:
limits:
memory: "${REDIS_MEM_LIMIT:-512m}"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 5
# Redis has no native Prometheus endpoint (unlike Postgres/Qdrant) -- the official sidecar exporter is the
# standard way to get visibility into an always-on, required backend that otherwise has zero metrics today.
# Gated on --profile observability (not its own profile): Redis itself always runs, but nothing needs its
# metrics unless the observability stack is up.
redis-exporter:
image: oliver006/redis_exporter:v1.79.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on:
redis:
condition: service_healthy
environment:
REDIS_ADDR: "redis://redis:6379"
expose:
- "9121"
deploy:
resources:
limits:
memory: "${REDIS_EXPORTER_MEM_LIMIT:-64m}"
# ── Postgres (--profile postgres | --profile pgbouncer) ───────────────────
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
<<: *default-logging
profiles: ["postgres", "pgbouncer"]
environment:
POSTGRES_USER: loopover
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-CHANGEME}
POSTGRES_DB: loopover
volumes:
- loopover-pg:/var/lib/postgresql/data
deploy:
resources:
limits:
memory: "${POSTGRES_MEM_LIMIT:-2g}"
# start_period (#3898): pg_isready's failed-probe count applies from the very first check unless a
# start_period grace is set. A genuinely cold initdb (first pull, slow disk, low-memory VPS, creating
# the pgvector extension) can plausibly take longer than the 5x10s=50s this healthcheck otherwise
# allows before Docker marks the service unhealthy and fails every downstream service_healthy gate.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U loopover"]
interval: 10s
timeout: 5s
start_period: 20s
retries: 5
# ── PgBouncer (--profile pgbouncer) ───────────────────────────────────────
# Transaction-mode pooler — allows hundreds of app clients with a small PG connection cap.
# Set DATABASE_URL in loopover to: postgres://loopover:<pw>@pgbouncer:5432/loopover
pgbouncer:
image: edoburu/pgbouncer:v1.25.2-p0
restart: unless-stopped
<<: *default-logging
profiles: ["pgbouncer"]
depends_on:
postgres:
condition: service_healthy
environment:
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: loopover
DB_USER: loopover
DB_PASSWORD: ${POSTGRES_PASSWORD:-CHANGEME}
LISTEN_PORT: "5432"
POOL_MODE: transaction
MAX_CLIENT_CONN: "200"
DEFAULT_POOL_SIZE: "20"
AUTH_TYPE: md5
# The image ships the full postgresql-client toolchain (Alpine-based), so the same probe postgres itself
# uses works here: pg_isready only checks that the server accepts the startup packet, not that auth
# succeeds, so it reports "accepting connections" even before the upstream Postgres is reachable through
# the pooler — exactly the pooler-layer liveness signal loopover's depends_on needs.
healthcheck:
test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-p", "5432"]
interval: 10s
timeout: 5s
start_period: 15s
retries: 5
# Postgres internals exporter. Starts with the Postgres profiles so Prometheus can scrape it when
# observability is enabled, without starting against a missing DB on SQLite-only installs.
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.20.0
restart: unless-stopped
<<: *default-logging
profiles: ["postgres", "pgbouncer"]
depends_on:
postgres:
condition: service_healthy
environment:
DATA_SOURCE_URI: "postgres:5432/loopover?sslmode=disable"
DATA_SOURCE_USER: loopover
DATA_SOURCE_PASS: ${POSTGRES_PASSWORD:-CHANGEME}
expose:
- "9187"
# ── Qdrant (--profile qdrant) ─────────────────────────────────────────────
# Dedicated vector database for RAG — replaces the built-in sqlite-vec / pgvector when
# QDRANT_URL=http://qdrant:6333 is set. Scales to millions of vectors with ANN search.
# REST API: http://localhost:6333 gRPC: localhost:6334 Dashboard: http://localhost:6333/dashboard
qdrant:
image: qdrant/qdrant:v1.18.2
restart: unless-stopped
<<: *default-logging
profiles: ["qdrant"]
# Ports are bound to LOOPBACK (127.0.0.1), not 0.0.0.0: the app reaches Qdrant over the internal
# docker network (QDRANT_URL=http://qdrant:6333), so the host mapping exists only for the local
# dashboard/debugging. The local container runs UNAUTHENTICATED, and it holds the RAG embeddings +
# PR context — never expose it beyond localhost without setting QDRANT_API_KEY (honored by
# qdrant-vectorize.ts) plus a firewall rule. SSH-forward the port for remote dashboard access. This
# matches the in-network-only posture of Prometheus/Loki/Alertmanager above.
ports:
- "127.0.0.1:6333:6333" # REST API + Web UI (localhost only)
- "127.0.0.1:6334:6334" # gRPC (localhost only)
volumes:
- qdrant-data:/qdrant/storage
deploy:
resources:
limits:
memory: "${QDRANT_MEM_LIMIT:-2g}"
# The image is Debian (bash present) but ships no curl/wget/nc. bash's /dev/tcp pseudo-device speaks
# HTTP without an external binary, so we hit Qdrant's k8s-style /readyz probe (200 once it accepts
# traffic). This lets the app's depends_on gate on a serving Qdrant. /readyz is unauthenticated even
# when QDRANT_API_KEY is set, so no secret is needed here.
healthcheck:
test:
- CMD
- bash
- -c
- 'exec 3<>/dev/tcp/127.0.0.1/6333 && printf "GET /readyz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3 && grep -q "200 OK" <&3'
interval: 10s
timeout: 5s
start_period: 15s
retries: 5
# ── Ollama (--profile ollama) ──────────────────────────────────────────────
# After `docker compose --profile ollama up -d`, pull a model:
# docker compose exec ollama ollama pull llama3.2
# Then set AI_PROVIDER=ollama and OLLAMA_AI_BASE_URL=http://ollama:11434/v1 in .env.
#
# GPU hosts: request the device via a local override (gitignored), e.g. docker-compose.local-gpu.yml:
# services:
# ollama:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# A device reservation only takes effect on container CREATION -- `docker compose restart ollama` reuses
# the existing container's already-baked-in config and will NOT pick it up, even after adding the file to
# COMPOSE_FILE. Confirmed in production (#gittensory-4327): the override existed for days while the
# running container's `docker inspect --format '{{json .HostConfig.DeviceRequests}}'` stayed `null`, and
# `ollama ps` kept reporting 100% CPU. Use `docker compose up -d --force-recreate ollama` (or any change
# that forces a recreate) after adding or editing a device reservation, then re-check `ollama ps` for
# "100% GPU" and/or `docker inspect`'s DeviceRequests to confirm it actually took.
ollama:
image: ollama/ollama:0.30.10
restart: unless-stopped
<<: *default-logging
profiles: ["ollama"]
volumes:
- ollama-models:/root/.ollama
# The image ships the ollama CLI but no curl/wget, so probe the daemon through it directly instead of a
# raw HTTP request: `ollama list` only succeeds once the server is accepting API calls.
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
start_period: 15s
retries: 5
# Concurrency/residency tuning for a shared embed+vision GPU deployment (#4327/#4335). Defaults sized
# for a single 24GB-class card running bge-m3 (embeddings) alongside one vision model concurrently --
# NUM_PARALLEL=2 keeps worst-case VRAM (resident weights + per-slot KV cache) well under 24GB; raising
# it on the vision model without also raising MAX_LOADED_MODELS/testing risks per-slot KV cache
# exhausting VRAM under burst load (latency collapse, not a hard OOM crash -- Ollama backpressures via
# its request queue instead). MAX_LOADED_MODELS pins exactly {embed model, vision model} resident so a
# stray third pull mid-burst can't force an unload/reload cycle on either one you actually need.
environment:
OLLAMA_NUM_PARALLEL: "${OLLAMA_NUM_PARALLEL:-2}"
OLLAMA_MAX_LOADED_MODELS: "${OLLAMA_MAX_LOADED_MODELS:-2}"
# Reviews land in bursts, not constant load -- keep both models warm across typical gaps between
# bursts instead of Ollama's 5m default forcing a cold-start reload every time.
OLLAMA_KEEP_ALIVE: "${OLLAMA_KEEP_ALIVE:-30m}"
# bge-m3's real context window is 8192 tokens, but Ollama's own default batch size (2048) is well
# below that -- confirmed in production logs ("input (N tokens) is too large to process") on RAG
# chunks up to ~4991 tokens, each one silently dropped from that PR's review context
# (rag_embed_batch_degraded, src/review/rag.ts's per-item fallback). The embed request itself
# (src/selfhost/ai.ts) has no way to set this per-call -- it's server-side only.
OLLAMA_NUM_BATCH: "${OLLAMA_NUM_BATCH:-8192}"
# Ollama can load multi-GB models into memory; raised from the single-embedder-era default (8g) now that
# a vision model (#4335) shares this container's memory ceiling alongside bge-m3 -- still capped so a
# runaway pull can't exhaust host RAM and take down the core review pipeline (#1828).
deploy:
resources:
limits:
memory: "${OLLAMA_MEM_LIMIT:-20g}"
# ── GPU metrics exporter (--profile gpu, #4367) ─────────────────────────────
# Shells to `nvidia-smi` via the NVIDIA Container Toolkit -- DCGM is built for datacenter fleets and is
# the wrong fit for a single consumer/workstation-class card like the one this deployment targets. The
# device reservation below only takes effect once this profile is actually activated, so a host with no
# GPU (and no --profile gpu) never evaluates it.
gpu-exporter:
image: utkuozdemir/nvidia_gpu_exporter:1.3.2
restart: unless-stopped
<<: *default-logging
profiles: ["gpu"]
environment:
# The exporter only shells out to nvidia-smi -- it never runs CUDA workloads itself, so the
# lightest capability tier is sufficient (matches the upstream project's own recommended config).
NVIDIA_DRIVER_CAPABILITIES: utility
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
expose:
- "9835"
# ── Host + container resource metrics (--profile observability) ────────────
# node-exporter: host-level CPU/memory/disk/network -- previously invisible in Grafana (docker stats-only).
# Read-only bind mounts of the HOST's own /proc, /sys, / -- the standard, widely-used node-exporter Docker
# recipe. Deliberately NOT network_mode: host or privileged: true (this repo's established posture favors
# isolated bridge networking over broader host access; a bind-mounted host /proc/net/dev already reflects
# the real host network interfaces without needing host networking).
node-exporter:
image: prom/node-exporter:v1.9.1
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--path.rootfs=/rootfs"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
expose:
- "9100"
deploy:
resources:
limits:
memory: "${NODE_EXPORTER_MEM_LIMIT:-128m}"
# cAdvisor: per-container CPU/memory/network/disk-io -- which container is actually using the resources it's
# been given. Deliberately does NOT mount /var/run/docker.sock: this repo's own docker-proxy service exists
# specifically because even a READ-ONLY docker.sock bind mount doesn't limit what Docker API calls a
# container can make (the read-only flag only applies to the socket's filesystem entry, not the API surface
# behind it) -- see the docker-proxy service's own comment below for the same reasoning. Running cAdvisor
# on cgroupfs/rootfs alone (no Docker API access) gives real per-container resource metrics at the cost of
# friendlier labels (containers show by cgroup path/id rather than their docker-compose service name). A
# self-hoster who wants richer labels can add a docker.sock mount via their own docker-compose.override.yml.
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.2
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
volumes:
- /:/rootfs:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
expose:
- "8080"
deploy:
resources:
limits:
memory: "${CADVISOR_MEM_LIMIT:-256m}"
# ── Browserless / visual review (--profile visual-review) ──────────────────
# Headless Chromium for automated before/after PR screenshot capture (src/review/visual/**).
# Set BOTH of these in .env (browserless always requires a token; there is no way around setting
# it in two places — the server reads TOKEN below, the app embeds the same value in the client URL):
# BROWSERLESS_TOKEN=<a-strong-random-value>
# BROWSER_WS_ENDPOINT=ws://browserless:3000?token=<the-same-value>
# Then set LOOPOVER_REVIEW_SCREENSHOTS=true. Official release images already have puppeteer-core
# installed (release-selfhost.yml builds with --build-arg INSTALL_VISUAL_REVIEW=true) — a rebuild is
# only needed for a LOCAL/custom image build from this Dockerfile directly (see Dockerfile). Unset/
# default = fully inert, no container, no screenshots, no error — this whole feature is opt-in end to end.
browserless:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
<<: *default-logging
profiles: ["visual-review"]
environment:
# Deliberately a SOFT default (":-", not ":?") -- docker compose interpolates every service's env
# vars for the WHOLE FILE even when a service's profile isn't active, so a hard-required var here
# would break `docker compose up` for every self-hoster NOT using visual review. If left unset,
# browserless auto-generates its own random token per boot (see its startup log) that will never
# match whatever you hardcoded into BROWSER_WS_ENDPOINT below -- captures then just fail closed
# (graceful placeholder, not a crash) instead of authenticating. Set BROWSERLESS_TOKEN explicitly.
TOKEN: ${BROWSERLESS_TOKEN:-}
# Self-host reviews PRs one at a time in practice; keep this low so a stray retry storm can't
# spin up a dozen Chromium tabs. Raise via env if you genuinely need more parallel captures.
CONCURRENT: "${BROWSERLESS_CONCURRENT:-2}"
QUEUED: "${BROWSERLESS_QUEUED:-4}"
TIMEOUT: "${BROWSERLESS_TIMEOUT_MS:-30000}"
# Never published to the host — reached only over the internal docker network at ws://browserless:3000,
# same in-network-only posture as postgres/redis/qdrant.
expose:
- "3000"
# Chrome needs real shared memory or it crashes under load writing to /tmp instead of /dev/shm.
# Docker's 64 MiB default is far too small for this; 2g matches browserless's own documented minimum.
shm_size: "2g"
deploy:
resources:
limits:
memory: "${BROWSERLESS_MEM_LIMIT:-2g}"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/docs"]
interval: 10s
timeout: 5s
start_period: 15s
retries: 5
# browserless's own /metrics?token=<token> endpoint returns JSON (a rolling history of 5-minute-window
# snapshots), not Prometheus text -- this sidecar polls it and re-exposes the newest snapshot's fields as
# real Prometheus gauges (#5368), same "translate non-Prometheus output" pattern as backup-exporter above.
# Gated on the same --profile visual-review as browserless itself; meaningless without it running.
browserless-exporter:
image: alpine:3.20
restart: unless-stopped
<<: *default-logging
profiles: ["visual-review"]
depends_on:
browserless:
condition: service_healthy
environment:
BROWSERLESS_METRICS_URL: "http://browserless:3000/metrics"
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN:-}"
volumes:
# Directory mount, not a single-file one -- same #reporting-exporter-stale-bind-mount rationale as
# the backup-exporter service above.
- ./scripts:/scripts:ro
expose:
- "9102"
command:
- /bin/sh
- -c
- "apk add --no-cache jq busybox-extras >/dev/null 2>&1 && sh /scripts/browserless-metrics.sh"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9102/metrics | grep -q '^browserless_exporter_last_scrape_success'"]
interval: 30s
retries: 5
deploy:
resources:
limits:
memory: "${BROWSERLESS_EXPORTER_MEM_LIMIT:-64m}"
# ── REES / review-enrichment (--profile rees) ──────────────────────────────
# Self-hosted alternative to pointing REES_URL at a managed/external instance (see the self-hosting docs).
# REES runs the heavier PR analysis (dependency CVEs, secret scan, license, EOL runtime, lockfile drift, IaC
# misconfig, ...) that the no-checkout `claude --print` reviewer can't do inline; the engine splices its
# brief into the AI review prompt and treats any REES timeout/error as "no brief" and proceeds regardless
# (src/review/enrichment-wire.ts) -- this whole service is optional end to end, same as browserless above.
# After `docker compose --profile rees up -d`, set in .env: LOOPOVER_REVIEW_ENRICHMENT=true,
# REES_URL=http://rees:8080, and a freshly generated REES_SHARED_SECRET -- do not reuse a secret from any
# other REES instance (e.g. a managed Railway deployment) you also point at (#1667).
rees:
build:
context: review-enrichment
restart: unless-stopped
<<: *default-logging
profiles: ["rees"]
# Deliberately NOT `env_file: .env` like the loopover service above: that would also hand this
# analyzer service every unrelated secret in .env, and REES reads the same SENTRY_DSN key name the main
# app uses (src/selfhost/sentry.ts) -- blanket-forwarding .env would silently point REES's error reporting
# at the main app's Sentry project instead of a dedicated one. Add REES-specific Sentry vars via
# docker-compose.override.yml if you want REES error reporting (see review-enrichment/README.md).
environment:
PORT: "8080"
# Soft default, not ":?required" -- compose interpolates every service's env vars for the whole file
# even when --profile rees isn't active, so a hard-required var here would break `docker compose up`
# for every self-hoster not using this profile (same reasoning as BROWSERLESS_TOKEN below). Unset means
# REES fails closed (503) on every /v1/enrich call instead of running unauthenticated.
REES_SHARED_SECRET: ${REES_SHARED_SECRET:-}
# Never published to the host -- reached only over the internal docker network at http://rees:8080, same
# in-network-only posture as postgres/redis/qdrant/browserless.
expose:
- "8080"
deploy:
resources:
limits:
memory: "${REES_MEM_LIMIT:-512m}"
# /health and /ready are both unconditional no-ops (REES has no DB/queue to probe), so unlike the
# loopover service above there's no deeper readiness signal to prefer here; /health also matches
# Railway's own healthcheckPath for this service. `node -e fetch(...)` avoids assuming curl/wget exist --
# review-enrichment/Dockerfile's runtime stage is node:22-slim, which installs neither.
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
# ── Litestream (--profile litestream) ─────────────────────────────────────
# Continuous WAL backup of the SQLite DB to S3/B2/R2. Copy litestream.yml.example
# → litestream.yml and fill in your bucket. Set LITESTREAM_* secrets in .env.
litestream:
image: litestream/litestream:0.5.12
restart: unless-stopped
<<: *default-logging
profiles: ["litestream"]
command: replicate
depends_on:
loopover:
condition: service_healthy
volumes:
- loopover-data:/data
- ./litestream.yml:/etc/litestream.yml:ro
environment:
LITESTREAM_ACCESS_KEY_ID: ${LITESTREAM_ACCESS_KEY_ID:-}
LITESTREAM_SECRET_ACCESS_KEY: ${LITESTREAM_SECRET_ACCESS_KEY:-}
LITESTREAM_ENDPOINT: ${LITESTREAM_ENDPOINT:-}
LITESTREAM_REGION: ${LITESTREAM_REGION:-us-east-1}
# ── n8n workflow automation (--profile workflows) ─────────────────────────
# Optional no-code automation: webhook fan-out (Slack/Teams/email), scheduled gate summaries,
# GitHub issue auto-triage from review signals. Import bundled templates from n8n/workflows/
# via http://localhost:5678/workflows/import. Set N8N_WEBHOOK_URL to your public URL when
# receiving webhooks from outside the host. Zero impact on the review engine when inactive.
n8n:
image: n8nio/n8n:2.31.0
restart: unless-stopped
<<: *default-logging
profiles: ["workflows"]
ports:
- "5678:5678"
volumes:
- n8n-data:/home/node/.n8n
environment:
N8N_BASIC_AUTH_ACTIVE: "true"
N8N_BASIC_AUTH_USER: ${N8N_USER:-admin}
# Soft default, not ":?required" -- compose interpolates every service's env vars for the whole file
# even when --profile workflows isn't active, so a hard-required var here would break `docker compose up`
# for every self-hoster not using this profile (same reasoning as BROWSERLESS_TOKEN / REES_SHARED_SECRET).
# The entrypoint below fails closed at container start when the profile is active but N8N_PASSWORD is unset.
N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:-}
WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://localhost:5678}
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:-}
GENERIC_TIMEZONE: ${TZ:-UTC}
deploy:
resources:
limits:
memory: "${N8N_MEM_LIMIT:-512m}"
entrypoint:
- /bin/sh
- -ec
- |
if [ -z "$${N8N_BASIC_AUTH_PASSWORD:-}" ]; then
echo >&2 "Set N8N_PASSWORD in .env before using --profile workflows."
exit 1
fi
exec /docker-entrypoint.sh
# ── MinIO S3-compatible object storage (--profile storage) ──────────────
# Local blob store: Litestream backup destination (set LITESTREAM_ENDPOINT=http://minio:9000),
# visual-review screenshot artifacts, Orb event archive. Console UI at http://localhost:9001.
# S3 API: http://minio:9000 from other containers, http://localhost:9000 from the host.
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
restart: unless-stopped
<<: *default-logging
profiles: ["storage"]
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
environment:
# Soft defaults for the same whole-file interpolation reason as N8N_PASSWORD above. MinIO itself
# refuses to start without credentials when this profile is active -- fail closed, not open.
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-}
command: server /data --console-address ":9001"
deploy:
resources:
limits:
memory: "${MINIO_MEM_LIMIT:-1g}"
# No healthcheck: minio/minio is scratch/distroless (no shell, curl, wget, or mc), so an
# in-container probe cannot run -- same constraint as grafana/loki above. Readiness is observable
# at GET /minio/health/live on :9000 once the server binds.
# ── Caddy (--profile caddy) ────────────────────────────────────────────────
# Auto-TLS via Let's Encrypt. Set DOMAIN=reviews.yourcompany.com in .env, then
# remove the plain `ports:` entry from the loopover service above.
caddy:
image: caddy:2-alpine
restart: unless-stopped
<<: *default-logging
profiles: ["caddy"]
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 QUIC
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
DOMAIN: ${DOMAIN:-localhost}
depends_on:
loopover:
condition: service_healthy
# ── Observability (--profile observability) ────────────────────────────────
# Prometheus scrapes /metrics; Alertmanager routes alerts; Loki+Promtail collect logs;
# Grafana visualises metrics AND logs. Grafana UI: http://localhost:3000 (admin / $GRAFANA_ADMIN_PASSWORD).
prometheus:
image: prom/prometheus:v3.12.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/rules:/etc/prometheus/rules:ro
- prometheus-data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
# 90d, not the 336h/14d of Loki or 168h/7d of Tempo below (#1828): that gap is deliberate, not an
# oversight — Prometheus's TSDB compresses numeric samples to ~1-2 bytes each after compaction, so
# metrics cost far less disk per retained day than Loki's raw log lines or Tempo's full span trees,
# and the self-hosting-operations docs' own capacity-planning guidance wants a multi-week/month
# trend window for metrics specifically. That said, 180d (six months) had no sizing rationale of
# its own — it silently replaced a deliberate 30d default when this became an override-able var in
# #1678 — so 90d is the considered number here: a full quarter of history for trend/capacity
# review, without defaulting a small self-host box to open-ended, unbounded-looking retention.
- "--storage.tsdb.retention.time=${PROMETHEUS_RETENTION_TIME:-90d}"
deploy:
resources:
limits:
memory: "${PROMETHEUS_MEM_LIMIT:-1g}"
# Routes Prometheus alerts to your notification channel. Ships SILENT: alerts go to a
# null receiver until you fill in a receiver in alertmanager/alertmanager.yml.
alertmanager:
image: prom/alertmanager:v0.33.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on: [prometheus]
expose:
- "9093" # in-network only; reach the UI/API via `docker compose port` or a tunnel
volumes:
# Mounts the whole host directory (not just the yml) so an operator's private overrides are visible
# inside the container at the same paths, with no docker-compose.yml edit needed per file added:
# a real receiver config (e.g. ./alertmanager/alertmanager.local, gitignored via the *.local rule
# above -- point docker-compose.override.yml's alertmanager `command:` at it) and/or a secret file a
# local config's webhook_url_file /etc/alertmanager/<name> references.
- ./alertmanager:/etc/alertmanager:ro
- alertmanager-data:/alertmanager
command:
- "--config.file=/etc/alertmanager/alertmanager.yml"
- "--storage.path=/alertmanager"
deploy:
resources:
limits:
memory: "${ALERTMANAGER_MEM_LIMIT:-256m}"
grafana:
image: grafana/grafana:13.1.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on:
prometheus:
condition: service_started
loki:
condition: service_started
reporting-exporter:
condition: service_healthy
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
# Maintainer dashboards query only the redacted reporting export, never the live app DB.
- grafana-reporting-data:/reporting:ro
environment:
# Compose interpolates every service even when --profile observability is inactive, so the required-password
# check lives in entrypoint below. Runtime still fails closed unless the operator sets GRAFANA_ADMIN_PASSWORD.
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-${GRAFANA_LOCAL_SMOKE_PASSWORD:-}}
GF_USERS_ALLOW_SIGN_UP: "false"
# grafana-sentry-datasource (#5369): first-party Grafana Labs plugin, confirmed Grafana-signed and
# installs cleanly on this pinned Grafana version (verified live, not assumed).
GF_INSTALL_PLUGINS: frser-sqlite-datasource,grafana-github-datasource,grafana-sentry-datasource
# Read-only fine-grained PAT for the GitHub data source provisioning ($GITHUB_TOKEN expansion). From .env.
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
deploy:
resources:
limits:
memory: "${GRAFANA_MEM_LIMIT:-512m}"
entrypoint:
- /bin/sh
- -ec
- |
if [ -z "$${GF_SECURITY_ADMIN_PASSWORD:-}" ]; then
echo >&2 "Set GRAFANA_ADMIN_PASSWORD in .env before using --profile observability, or set GRAFANA_LOCAL_SMOKE_PASSWORD for local-only smoke tests."
exit 1
fi
exec /run.sh
reporting-exporter:
image: alpine:3.20
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on:
loopover:
condition: service_healthy
volumes:
# The exporter needs live DB read access to build the redacted snapshot. Grafana does not get this mount.
- loopover-data:/appdb:ro
- grafana-reporting-data:/reporting
# A DIRECTORY bind mount, not a single-file one (#reporting-exporter-stale-bind-mount): a single-file
# bind mount pins the container to the file's INODE at container-creation time. `git pull` replaces a
# file by writing a new blob then renaming it over the old path -- the old inode still exists on disk
# until every reference (including this bind mount) is dropped, so the running container keeps reading
# the pre-pull script forever, silently, with no error. Confirmed live: a script change survived
# multiple `git pull`s with zero effect until the container was force-recreated. Directory bind mounts
# don't have this problem -- they resolve the path fresh on every access, exactly like dashboard
# provisioning below already does.
- ./scripts:/scripts:ro
environment:
# Default SQLite app DB path maps app /data/loopover.sqlite to exporter /appdb/loopover.sqlite.
# If you override DATABASE_PATH, set this to the matching /appdb/<file> path. When DATABASE_URL points at
# Postgres, the exporter reads Postgres with psql and still writes the same redacted SQLite reporting DB
# for Grafana. Set LOOPOVER_REPORTING_SOURCE_DATABASE_URL only if the reporting reader uses a different URL.
LOOPOVER_REPORTING_SOURCE_DB: "${LOOPOVER_REPORTING_SOURCE_DB:-/appdb/loopover.sqlite}"
LOOPOVER_REPORTING_SOURCE_DATABASE_URL: "${LOOPOVER_REPORTING_SOURCE_DATABASE_URL:-}"
DATABASE_URL: "${DATABASE_URL:-}"
LOOPOVER_REPORTING_DIR: /reporting
LOOPOVER_REPORTING_DB: "${LOOPOVER_REPORTING_DB:-/reporting/loopover-reporting.sqlite}"
GRAFANA_REPORTING_EXPORT_INTERVAL_SECONDS: "${GRAFANA_REPORTING_EXPORT_INTERVAL_SECONDS:-30}"
command:
- /bin/sh
- -c
- >-
apk add --no-cache sqlite postgresql16-client >/dev/null 2>&1 &&
while true; do
sh /scripts/export-grafana-reporting-db.sh || echo '[reporting] export failed';
interval="$${GRAFANA_REPORTING_EXPORT_INTERVAL_SECONDS:-30}";
case "$$interval" in ''|*[!0-9]*) interval=30;; esac;
sleep "$$interval";
done
healthcheck:
test:
[
"CMD-SHELL",
"db=\"$${LOOPOVER_REPORTING_DB:-/reporting/loopover-reporting.sqlite}\"; test -s \"$$db\" && sqlite3 \"$$db\" 'PRAGMA quick_check;' | grep -q '^ok$'",
]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
# Redacted AMS (loopover-miner) ledger export -- a DEDICATED profile, not bundled into `observability`: unlike
# the engine's own reporting-exporter above, this only does anything useful when a miner is ALSO running on this
# same host with its ledgers under LOOPOVER_MINER_CONFIG_DIR (an engine-only maintainer deployment has nothing
# for it to read). Grafana must never mount that directory directly -- attempt_log_events.reason/.payload_json
# are free-form and can carry arbitrary internal detail (#5184 follow-up / PR #5471's flagged confidentiality
# gap) -- so this exporter reads the live ledgers read-only and writes a redacted snapshot into the SAME shared
# `grafana-reporting-data:/reporting` volume the engine's own exporter already writes to; only Grafana's
# ams-ledgers.yml datasources (pointed at /reporting/ams-*.sqlite) ever read from there.
ams-reporting-exporter:
image: alpine:3.20
restart: unless-stopped
<<: *default-logging
profiles: ["ams-observability"]
volumes:
# Read-only live-ledger access for the exporter only -- Grafana does not get this mount (mirrors the
# /appdb:ro pattern above). Point this at the SAME host directory your miner uses as LOOPOVER_MINER_CONFIG_DIR.
# LAPTOP mode already writes there. FLEET mode (packages/loopover-miner/docker-compose.miner.yml) keeps state
# in a named volume, so it needs the opt-in bind-mount override + combined command -- see "Running fleet mode
# alongside ORB's ams-observability profile" in packages/loopover-miner/DEPLOYMENT.md (#5805).
- "${LOOPOVER_MINER_CONFIG_DIR:-~/.config/loopover-miner}:/ams-ledgers:ro"
- grafana-reporting-data:/reporting
# Directory bind mount, not a single-file one -- see reporting-exporter's own comment above for why
# (a single-file mount pins to the pre-`git pull` inode and silently never picks up a script update).
- ./scripts:/scripts:ro
environment:
LOOPOVER_AMS_ATTEMPT_LOG_SOURCE_DB: "${LOOPOVER_AMS_ATTEMPT_LOG_SOURCE_DB:-/ams-ledgers/attempt-log.sqlite3}"
LOOPOVER_AMS_PREDICTION_LEDGER_SOURCE_DB: "${LOOPOVER_AMS_PREDICTION_LEDGER_SOURCE_DB:-/ams-ledgers/prediction-ledger.sqlite3}"
LOOPOVER_REPORTING_DIR: /reporting
LOOPOVER_AMS_ATTEMPT_LOG_REPORTING_DB: "${LOOPOVER_AMS_ATTEMPT_LOG_REPORTING_DB:-/reporting/ams-attempt-log.sqlite}"
LOOPOVER_AMS_PREDICTION_LEDGER_REPORTING_DB: "${LOOPOVER_AMS_PREDICTION_LEDGER_REPORTING_DB:-/reporting/ams-prediction-ledger.sqlite}"
LOOPOVER_AMS_REPORTING_EXPORT_INTERVAL_SECONDS: "${LOOPOVER_AMS_REPORTING_EXPORT_INTERVAL_SECONDS:-30}"
command:
- /bin/sh
- -c
- >-
apk add --no-cache sqlite >/dev/null 2>&1 &&
while true; do
sh /scripts/export-ams-reporting-db.sh || echo '[ams-reporting] export failed';
interval="$${LOOPOVER_AMS_REPORTING_EXPORT_INTERVAL_SECONDS:-30}";
case "$$interval" in ''|*[!0-9]*) interval=30;; esac;
sleep "$$interval";
done
healthcheck:
test:
[
"CMD-SHELL",
"db=\"$${LOOPOVER_AMS_ATTEMPT_LOG_REPORTING_DB:-/reporting/ams-attempt-log.sqlite}\"; test -s \"$$db\" && sqlite3 \"$$db\" 'PRAGMA quick_check;' | grep -q '^ok$'",
]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
# ── Log pipeline (--profile observability) ─────────────────────────────────
# Loki stores logs; Promtail discovers every container in this compose project via the read-only
# docker-proxy (NOT a raw socket) and ships their logs to Loki. Browse in Grafana → Explore →
# Loki, e.g. {service="loopover"} | json | level="error".
loki:
image: grafana/loki:3.7.3
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
command: ["-config.file=/etc/loki/loki-config.yml"]
volumes:
- ./loki/loki-config.yml:/etc/loki/loki-config.yml:ro