Files
stack/compose.yml
kert 8b6c082b7f fix(conf,llm,compose): the DuckDB replica lives in data/replica/ (refs P48)
The llm container mounted the whole ./data directory read-only just to
read one snapshot. Snapshots now get their own directory, published with
mkdir -p, and compose mounts only ./data/replica — the primary databases
are no longer visible inside the container.
2026-09-08 23:23:59 -04:00

1085 lines
34 KiB
YAML

networks:
gateway:
name: gateway
driver: bridge
internal: false
ipam:
config:
- subnet: 192.168.5.0/24
storage:
name: storage
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.6.0/24
data:
name: data
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.7.0/24
observability:
name: observability
driver: bridge
internal: true
ipam:
config:
- subnet: 192.168.8.0/24
ci:
name: ci
driver: bridge
internal: false
ipam:
config:
- subnet: 192.168.9.0/24
hostpub:
# Docker cannot publish ports from a container whose networks are all
# internal. This non-internal bridge exists solely to carry loopback
# port publishes (e.g. postgres 127.0.0.1:5432 for host-side llm batch
# runs); no service-to-service traffic should use it.
name: hostpub
driver: bridge
internal: false
ipam:
config:
- subnet: 192.168.10.0/24
services:
coredns:
image: coredns/coredns:1.12.0
container_name: coredns
command: ["-conf", "/etc/coredns/Corefile"]
networks:
gateway:
ipv4_address: 192.168.5.53
volumes:
- ./infra/coredns/Corefile:/etc/coredns/Corefile:ro
- ./infra/coredns/hosts:/etc/coredns/hosts:ro
labels:
- "promtail=true"
restart: unless-stopped
traefik:
image: traefik:v3.3
container_name: traefik
networks:
- gateway
- observability
- storage
- ci
ports:
- "80:80"
- "443:443"
- "8081:8080"
volumes:
- ./infra/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./infra/traefik/dynamic:/etc/traefik/dynamic:ro
- ./infra/traefik/certs:/etc/traefik/certs:ro
- ./infra/traefik/plugins:/plugins-local:ro
environment:
- DOMAIN=${DOMAIN:-fhirworx.io}
- OTEL_SERVICE_NAME=traefik
- TRAEFIK_ACCESSLOG=true
- TRAEFIK_ACCESSLOG_FORMAT=json
- TRAEFIK_ACCESSLOG_FIELDS_DEFAULTMODE=keep
- TRAEFIK_ACCESSLOG_FIELDS_HEADERS_DEFAULTMODE=keep
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
rustfs:
image: ${IMAGE_PREFIX:-fhirworx}/rustfs:latest
pull_policy: if_not_present
container_name: rustfs
user: "10001:10001"
networks:
- storage
- ci
environment:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY}
volumes:
- rustfs_data:/data
- rustfs_logs:/logs
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
mc:
image: ${IMAGE_PREFIX:-fhirworx}/mc:${COMMIT_SHA:-latest}
pull_policy: if_not_present
container_name: mc
build:
context: infra/rustfs
dockerfile: ../images/mc.Dockerfile
networks:
- storage
environment:
- MC_HOST_local=http://${RUSTFS_ACCESS_KEY}:${RUSTFS_SECRET_KEY}@rustfs:9000
user: "10002:10002"
read_only: true
tmpfs:
- /home/mc/.mc:uid=10002,gid=10002
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
entrypoint: ["sleep", "infinity"]
depends_on:
- rustfs
restart: unless-stopped
postgres:
# Patched pgvector (AVX-512-free) built FROM the upstream mirror — the
# stock vector.so SIGILLs this Zen2 host on hnsw/ivfflat index builds (#580).
image: ${IMAGE_PREFIX:-fhirworx}/postgresql:pgvector
pull_policy: if_not_present
build:
context: .
dockerfile: infra/images/postgresql.Dockerfile
args:
BASE: ${IMAGE_PREFIX:-fhirworx}/postgresql:latest
container_name: postgres
networks:
- storage
- hostpub
ports:
# Loopback-only: host-side llm batch runs (indexer/tagger) need
# pgvector; everything else still uses the storage net.
- "127.0.0.1:5432:5432"
environment:
- POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-changeme}
- POSTGRESQL_DATABASE=gitea
volumes:
- postgres_data:/bitnami/postgresql
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
healthcheck:
# Gate dependents (git, nessie, polaris, api) on Postgres actually
# accepting connections, not just the container having started.
test: ["CMD-SHELL", "pg_isready -U postgres -d gitea"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
git:
build:
context: ./infra/gitea
dockerfile: Dockerfile
args:
GITEA_VERSION: v1.25.4
# BUILD_TAG bumps `?v=...` on every asset URL → busts browser/CDN
# cache. Use BUILD_TAG=$(date +%s) when invoking docker compose build,
# or the Dockerfile defaults to current epoch.
BUILD_TAG: "${BUILD_TAG:-}"
image: fhirworx/git:v1.25.4
container_name: git
networks:
- gateway
- storage
- ci
env_file:
# Written by `stack mail wire-git` (or `stack mail provision`) from
# the mail droplet's credentials cache. Marked optional so cold-start
# works before the mail droplet exists; once it does, re-running
# wire-git populates this file and `docker compose up -d git`
# picks it up.
- path: .state/git/mailer.env
required: false
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=postgres:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=git
- GITEA__database__PASSWD=${GITEA_DB_PASSWORD:-git_password}
- GITEA__storage__STORAGE_TYPE=minio
- GITEA__storage__MINIO_ENDPOINT=rustfs:9000
- GITEA__storage__MINIO_ACCESS_KEY_ID=${GITEA_S3_ACCESS_KEY:-git}
- GITEA__storage__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
- GITEA__storage__MINIO_BUCKET=gitea
- GITEA__storage__MINIO_USE_SSL=false
- GITEA__lfs__STORAGE_TYPE=minio
- GITEA__lfs__MINIO_ENDPOINT=rustfs:9000
- GITEA__lfs__MINIO_ACCESS_KEY_ID=${GITEA_S3_ACCESS_KEY:-git}
- GITEA__lfs__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
- GITEA__lfs__MINIO_BUCKET=gitea-lfs
- GITEA__lfs__MINIO_USE_SSL=false
- GITEA__packages__ENABLED=true
- GITEA__packages__STORAGE_TYPE=minio
- GITEA__packages__MINIO_ENDPOINT=rustfs:9000
- GITEA__packages__MINIO_ACCESS_KEY_ID=${GITEA_S3_ACCESS_KEY:-git}
- GITEA__packages__MINIO_SECRET_ACCESS_KEY=${GITEA_S3_SECRET_KEY}
- GITEA__packages__MINIO_BUCKET=gitea-packages
- GITEA__packages__MINIO_USE_SSL=false
- GITEA__server__DOMAIN=git.${DOMAIN:-fhirworx.io}
- GITEA__server__ROOT_URL=https://git.${DOMAIN:-fhirworx.io}/
- GITEA__server__SSH_DOMAIN=git.${DOMAIN:-fhirworx.io}
- GITEA__webhook__ALLOWED_HOST_LIST=ci.${DOMAIN:-fhirworx.io},${HOST_IP:-192.168.1.192},172.19.0.0/16
- GITEA__ui__THEMES=fhirworx,fhirworx-dark
- GITEA__ui__DEFAULT_THEME=fhirworx
volumes:
- gitea_data:/var/lib/gitea
- gitea_config:/etc/gitea
- ./infra/gitea/custom:/var/lib/gitea/custom
ports:
- "2222:2222"
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
rustfs:
condition: service_started
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
act-runner:
image: gitea/act_runner:latest
container_name: act-runner
networks:
- ci
- storage
environment:
- GITEA_INSTANCE_URL=http://git:3000
- GITEA_RUNNER_REGISTRATION_TOKEN=${ACT_RUNNER_TOKEN}
- GITEA_RUNNER_NAME=homelab-runner
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-latest
- CONFIG_FILE=/config.yaml
volumes:
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock
- act_runner_data:/data
- ./infra/act-runner/config.yaml:/config.yaml:ro
depends_on:
- git
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
notebooks:
image: ${IMAGE_PREFIX:-fhirworx}/notebooks:${HEAVY_TAG:-latest}
pull_policy: if_not_present
build:
context: .
dockerfile: infra/images/notebooks.Dockerfile
container_name: notebooks
networks:
- gateway
- data
- storage
environment:
- PYTHONPATH=/home/kert/src
- RUSTFS_ENDPOINT=http://rustfs:9000
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY}
# Read-only DuckLake catalog access — conf.connect.ducklake()
# reads reference data from the lake (M5, #514).
- DUCKLAKE_RO_PASSWORD=${DUCKLAKE_RO_PASSWORD}
- NESSIE_S3_ACCESS_KEY=${NESSIE_S3_ACCESS_KEY}
- NESSIE_S3_SECRET_KEY=${NESSIE_S3_SECRET_KEY}
- POLARIS_ROOT_SECRET=${POLARIS_ROOT_SECRET}
volumes:
- ./notebooks:/home/kert/notebooks
# Mount only user-editable config files, not the whole infra/marimo
# directory — the theme and source lives inside the baked image now.
- ./infra/marimo/marimo.toml:/home/kert/.config/marimo/marimo.toml:ro
- ./infra/marimo/snippets:/home/kert/.config/marimo/snippets:ro
- ./data:/home/kert/data
- ./data/zotero/data:/home/kert/zotero:ro
- ./src:/home/kert/src:ro
- ./assets:/home/kert/assets:ro
- ./stack.toml:/home/kert/stack.toml:ro
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
shm_size: "1g"
labels:
- "promtail=true"
restart: unless-stopped
ollama:
image: ollama/ollama:latest
container_name: ollama
networks:
- data
- hostpub # port publish requires a non-internal net (data is internal)
ports:
# Host + LAN: the rack's Ollama is one endpoint in the multi-host
# pool (rig 4090 / laptop 5080 serve the same API on the LAN).
- "11434:11434"
environment:
# Unload models after idle so the shared 3060 frees VRAM for
# notebooks/zotero.
- OLLAMA_KEEP_ALIVE=5m
volumes:
- ollama_models:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "ollama", "ls"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# Watches the notebooks service for errors (container logs via the
# docker socket + __marimo__/session snapshots) and files deduplicated,
# auto-closing Gitea issues via dev/scripts/nb_issue_filer.py. See
# docs/superpowers/specs/2026-07-09-notebook-quality-gates-design.md.
nb-watcher:
image: python:3.13-alpine
container_name: nb-watcher
# `gateway` to reach git:3000 for the issue API.
networks:
- gateway
environment:
- PYTHONUNBUFFERED=1
- GITEA_TOKEN=${GITEA_TOKEN}
- GITEA_API_BASE=http://git:3000/api/v1
- NB_POLL_S=${NB_POLL_S:-60}
volumes:
- ./dev/scripts:/scripts:ro
- ./notebooks:/notebooks:ro
- ./.state:/state
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock:ro
command: python /scripts/nb_watcher.py
depends_on:
notebooks:
condition: service_started
healthcheck:
test:
- "CMD-SHELL"
- "test -f /tmp/heartbeat && test $$(( $$(date +%s) - $$(stat -c %Y /tmp/heartbeat) )) -lt $$(( $${NB_POLL_S:-60} * 5 ))"
interval: 60s
timeout: 5s
retries: 3
start_period: 120s
security_opt:
- no-new-privileges:true
labels:
- "promtail=true"
restart: unless-stopped
zotero:
image: ${IMAGE_PREFIX:-fhirworx}/zotero:${HEAVY_TAG:-latest}
pull_policy: if_not_present
build:
context: .
dockerfile: infra/images/zotero.Dockerfile
container_name: zotero
networks:
- gateway
runtime: nvidia
stdin_open: true
tty: true
ports:
- "3478:3478"
- "3478:3478/udp"
volumes:
- ./data/zotero/data:/home/ubuntu/Zotero
- ./data/zotero/profiles/zotero:/home/ubuntu/.zotero
- ./data/zotero/profiles/mozilla:/home/ubuntu/.mozilla
- ./data:/home/ubuntu/data
tmpfs:
- /dev/shm:rw
environment:
- TZ=UTC
- DISPLAY_SIZEW=1920
- DISPLAY_SIZEH=1080
- DISPLAY_REFRESH=60
- DISPLAY_DPI=96
- DISPLAY_CDEPTH=24
- PASSWD=zotero
- KASMVNC_ENABLE=true
- SELKIES_ENABLE_BASIC_AUTH=false
# Skip the entrypoint's `dig @ns1.google.com TXT o-o.myaddr.l.google.com`
# public-IP probe — on networks where the resolver returns an
# edns0-client-subnet hint instead of a bare IP, the value lands in
# network.udp.public_ip and kasmvnc rejects it as invalid, leaving
# the UI in a FATAL restart loop. "auto" tells kasmvnc to figure it
# out itself.
- TURN_EXTERNAL_IP=auto
# OTLP tracing of the desktop-bridge lifecycle is OFF by default. To
# enable: add `observability` to `networks` below and uncomment the
# endpoint. Left off deliberately — zotero is a GPU desktop kept on
# `gateway` only; attaching it to `observability` widens its blast
# radius. The structured Loki logs + Grafana alert cover the same
# failure without crossing that isolation boundary.
# - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# Detects the blank-desktop failure (dead :20->:21 kasmxproxy bridge) that
# container/nginx/Xvnc liveness all miss. The image bakes an equivalent
# HEALTHCHECK; this override tunes timing for the GPU desktop's slow boot
# (first start may install the NVIDIA userspace driver).
healthcheck:
test: ["CMD", "/usr/local/bin/zotero-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
labels:
- "promtail=true"
restart: unless-stopped
webdav:
image: rclone/rclone:latest
container_name: webdav
networks:
- storage
- gateway
environment:
- RCLONE_CONFIG_S3_TYPE=s3
- RCLONE_CONFIG_S3_PROVIDER=Minio
- RCLONE_CONFIG_S3_ACCESS_KEY_ID=${RUSTFS_ACCESS_KEY}
- RCLONE_CONFIG_S3_SECRET_ACCESS_KEY=${RUSTFS_SECRET_KEY}
- RCLONE_CONFIG_S3_ENDPOINT=http://rustfs:9000
- RCLONE_CONFIG_S3_FORCE_PATH_STYLE=true
command:
- serve
- webdav
- "s3:zotero"
- --addr=:8080
- --user=${WEBDAV_USER:-zotero}
- --pass=${WEBDAV_PASS}
- --vfs-cache-mode=full
tmpfs:
- /tmp/rclone-cache
labels:
- "promtail=true"
restart: unless-stopped
depends_on:
- rustfs
nessie:
image: ghcr.io/projectnessie/nessie:latest
container_name: nessie
networks:
- gateway
- storage
- data
- observability
environment:
- NESSIE_VERSION_STORE_TYPE=JDBC
- QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgres:5432/nessie
- QUARKUS_DATASOURCE_USERNAME=nessie
- QUARKUS_DATASOURCE_PASSWORD=${NESSIE_DB_PASSWORD}
# Iceberg REST Catalog settings.
# default-warehouse is a NAME that must reference a defined
# warehouse; pointing it straight at the s3 URI leaves the REST
# endpoint 500ing with "Default warehouse ... is not defined".
- NESSIE_CATALOG_DEFAULT_WAREHOUSE=warehouse
- nessie.catalog.warehouses.warehouse.location=${S3_WAREHOUSE:-s3://lakehouse/}
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
# S3 credentials go through Nessie's secrets manager: the option
# takes a URN referencing quarkus config keys with .name/.secret.
# (The former ACCESS_KEY_ID/SECRET_ACCESS_KEY env style is ignored
# by current Nessie — "Missing access key and secret for STATIC
# authentication mode".)
- nessie.catalog.service.s3.default-options.access-key=urn:nessie-secret:quarkus:s3creds
- s3creds.name=${NESSIE_S3_ACCESS_KEY}
- s3creds.secret=${NESSIE_S3_SECRET_KEY}
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_PATH_STYLE_ACCESS=true
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=${S3_REGION:-us-east-1}
# OpenTelemetry tracing
- QUARKUS_OTEL_ENABLED=true
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
- QUARKUS_OTEL_SERVICE_NAME=nessie
depends_on:
postgres:
condition: service_healthy
rustfs:
condition: service_started
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
trino:
image: trinodb/trino:latest
container_name: trino
networks:
- gateway
- storage
- data
- observability
environment:
- NESSIE_S3_ACCESS_KEY=${NESSIE_S3_ACCESS_KEY}
- NESSIE_S3_SECRET_KEY=${NESSIE_S3_SECRET_KEY}
- NESSIE_API_URI=${NESSIE_API_URI:-http://nessie:19120/api/v2}
- S3_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
- S3_REGION=${S3_REGION:-us-east-1}
- S3_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
volumes:
- ./infra/trino/etc:/etc/trino:ro
depends_on:
- nessie
- rustfs
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
polaris:
image: apache/polaris:latest
container_name: polaris
networks:
- gateway
- storage
- data
- observability
environment:
# PostgreSQL backend
- POLARIS_PERSISTENCE_TYPE=relational-jdbc
- QUARKUS_DATASOURCE_DB_KIND=postgresql
- QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgres:5432/polaris
- QUARKUS_DATASOURCE_USERNAME=polaris
- QUARKUS_DATASOURCE_PASSWORD=${POLARIS_DB_PASSWORD}
# Enable Flyway migrations for schema creation
- QUARKUS_FLYWAY_MIGRATE_AT_START=true
# Bootstrap credentials: realm,clientId,clientSecret
- POLARIS_BOOTSTRAP_CREDENTIALS=default-realm,root,${POLARIS_ROOT_SECRET}
# S3 storage defaults
- AWS_ACCESS_KEY_ID=${POLARIS_S3_ACCESS_KEY}
- AWS_SECRET_ACCESS_KEY=${POLARIS_S3_SECRET_KEY}
- AWS_REGION=${S3_REGION:-us-east-1}
# OpenTelemetry
- QUARKUS_OTEL_ENABLED=true
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
- QUARKUS_OTEL_SERVICE_NAME=polaris
depends_on:
postgres:
condition: service_healthy
rustfs:
condition: service_started
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
dashboard:
image: nginx:alpine
container_name: dashboard
networks:
- gateway
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/index.html:/usr/share/nginx/html/index.html:ro
- ./assets/css/dashboard.css:/usr/share/nginx/html/dashboard.css:ro
- ./assets/css/inject.css:/usr/share/nginx/html/fhirworx.css:ro
- ./assets/icons/fav32.png:/usr/share/nginx/html/fav32.png:ro
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
docs:
image: ${IMAGE_PREFIX:-fhirworx}/docs:${COMMIT_SHA:-latest}
pull_policy: if_not_present
build:
context: .
dockerfile: infra/images/docs.Dockerfile
container_name: docs
networks:
- gateway
labels:
- "promtail=true"
restart: unless-stopped
api:
image: ${IMAGE_PREFIX:-fhirworx}/api:${COMMIT_SHA:-latest}
pull_policy: if_not_present
build:
context: .
dockerfile: infra/images/api.Dockerfile
container_name: api
networks:
- gateway
- storage
- data
- observability
environment:
- STACK_API_SECRET=${STACK_API_SECRET}
- GITEA_TOKEN=${GITEA_TOKEN}
# Postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
# S3 / RustFS
- NESSIE_S3_ACCESS_KEY=${NESSIE_S3_ACCESS_KEY}
- NESSIE_S3_SECRET_KEY=${NESSIE_S3_SECRET_KEY}
- S3_ENDPOINT=${S3_ENDPOINT:-http://rustfs:9000}
- S3_REGION=${S3_REGION:-us-east-1}
- S3_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
# OpenTelemetry
- OTEL_SERVICE_NAME=api
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
volumes:
- ./data:/app/data
depends_on:
postgres:
condition: service_healthy
rustfs:
condition: service_started
nessie:
condition: service_started
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# SSO-guarded RAG chat UI (P34). Routed at llm.fhirworx.io via Traefik's
# git-sso middleware ($reef in infra/traefik/dynamic/services.yml). Joins
# gateway (Traefik), storage (postgres:5432 / pgvector), and data
# (ollama:11434). Query-only — the batch indexer runs on the host.
llm:
image: ${IMAGE_PREFIX:-fhirworx}/llm:${COMMIT_SHA:-latest}
pull_policy: if_not_present
build:
context: .
dockerfile: infra/images/llm.Dockerfile
container_name: llm
networks:
- gateway
- storage
- data
- observability
volumes:
- ./data/replica:/app/data/replica:ro # DuckDB read replica only (directory mount survives replica re-publish)
environment:
- LLM_OLLAMA_HOSTS=${LLM_OLLAMA_HOSTS_IN_CONTAINER:-http://ollama:11434}
- LLM_PG_HOST=postgres
- LLM_DB_PASSWORD=${LLM_DB_PASSWORD}
- LLM_DUCKDB_REPLICA=/app/data/replica/aco.ro.duckdb
- OTEL_SERVICE_NAME=llm
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
depends_on:
postgres:
condition: service_healthy
ollama:
condition: service_started
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# IMAP → bib poller. Pulls UNSEEN mail from `cmsupdates@mail.fhirworx.io`
# every MAIL_POLL_INTERVAL seconds and upserts each as a Source item.
# Idempotent (server-side `\Seen` flag), so frequency is purely a
# latency knob — 600s = ~10min average from arrival → bib.
mail-poller:
image: ${IMAGE_PREFIX:-fhirworx}/api:${COMMIT_SHA:-latest}
pull_policy: if_not_present
container_name: mail-poller
# Needs `gateway` for outbound DNS + IMAPS to mail.fhirworx.io;
# `data` to share bib.sqlite with the api/lake services.
networks:
- gateway
- data
environment:
- MAIL_POLL_INTERVAL=${MAIL_POLL_INTERVAL:-600}
volumes:
- ./data:/app/data
- ./.state:/app/.state
# Mount source live so adding mailboxes / tweaking the poller
# doesn't require an image rebuild — the api image's baked venv
# provides interpreter + deps; --no-sync keeps `uv run` from
# going back to pypi.
- ./src:/app/src:ro
- ./pyproject.toml:/app/pyproject.toml:ro
command: >
sh -c 'while true; do
uv run --no-sync stack bib ingest-mail || true;
touch /tmp/heartbeat;
sleep $${MAIL_POLL_INTERVAL};
done'
# Override the inherited HEALTHCHECK from the api image (which
# probes http://localhost:8000/health — wrong for this loop-only
# container). Mark healthy when the poll loop has completed an
# iteration within the last 2 * MAIL_POLL_INTERVAL seconds.
healthcheck:
test:
- "CMD-SHELL"
- "test -f /tmp/heartbeat && test $$(( $$(date +%s) - $$(stat -c %Y /tmp/heartbeat) )) -lt $$(( $${MAIL_POLL_INTERVAL:-600} * 2 ))"
interval: 60s
timeout: 5s
retries: 3
start_period: 120s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# Observability Stack
loki:
image: grafana/loki:latest
container_name: loki
networks:
- observability
volumes:
- ./infra/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
healthcheck:
test: ["CMD", "/usr/bin/loki", "--version"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
tempo:
image: grafana/tempo:2.7.2
container_name: tempo
networks:
- observability
volumes:
- ./infra/tempo/tempo.yml:/etc/tempo/config.yaml:ro
- tempo_data:/var/tempo
command: -config.file=/etc/tempo/config.yaml
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3200/ready"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
nvidia-exporter:
image: nvcr.io/nvidia/k8s/dcgm-exporter:3.3.9-3.6.1-ubuntu22.04
# Prefixed: corwins.media compose also defines a "nvidia-exporter"
# container; Docker container names are global. Service DNS
# ("nvidia-exporter:9400") still resolves on the observability net.
container_name: stack-nvidia-exporter
networks:
- observability
runtime: nvidia
environment:
- DCGM_EXPORTER_NO_HOSTNAME=1
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
cap_add:
- SYS_ADMIN
labels:
- "promtail=true"
restart: unless-stopped
promtail:
image: grafana/promtail:latest
container_name: promtail
networks:
- observability
volumes:
- ./infra/loki/promtail-config.yml:/etc/promtail/config.yml:ro
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
group_add:
- "${DOCKER_GID:-985}"
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
container_name: otel-collector
networks:
- observability
volumes:
- ./infra/otel/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
- tempo
- loki
healthcheck:
test:
[
"CMD",
"/otelcol-contrib",
"validate",
"--config",
"file:/etc/otelcol-contrib/config.yaml",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: prometheus
networks:
- gateway
- observability
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./infra/prometheus/targets:/etc/prometheus/targets:ro
- prometheus_data:/prometheus
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.enable-lifecycle"
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: grafana
networks:
- gateway
- observability
# Theme injection: Grafana 12 is a heavy SPA, and Traefik's
# rewrite-body fires on the upstream HTML response but Grafana
# re-renders the head client-side. Patching index.html in place at
# startup guarantees the <link> survives. Runs as root so the patch
# can write the root-owned template; grafana-server itself doesn't
# require a specific UID.
user: "0:0"
entrypoint:
- /bin/sh
- -c
- |
# Inject base palette + grafana-specific overlay. Order matters:
# fhirworx.css first (palette), grafana.css second (overrides).
grep -q grafana-fhirworx.css /usr/share/grafana/public/views/index.html || \
sed -i 's|</head>|<link rel="stylesheet" type="text/css" href="/public/fhirworx.css"><link rel="stylesheet" type="text/css" href="/public/grafana-fhirworx.css"></head>|' /usr/share/grafana/public/views/index.html
exec /run.sh
env_file:
- path: .state/gitea/grafana.env
required: false
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_SERVER_ROOT_URL=https://grafana.${DOMAIN:-fhirworx.io}
- GF_AUTH_GENERIC_OAUTH_ENABLED=true
- GF_AUTH_GENERIC_OAUTH_NAME=Gitea
- GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email
- GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://git.${DOMAIN:-fhirworx.io}/login/oauth/authorize
- GF_AUTH_GENERIC_OAUTH_TOKEN_URL=http://git:3000/login/oauth/access_token
- GF_AUTH_GENERIC_OAUTH_API_URL=http://git:3000/api/v1/user
- GF_AUTH_GENERIC_OAUTH_ALLOW_SIGN_UP=true
- GF_AUTH_GENERIC_OAUTH_AUTO_LOGIN=false
- GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP=true
- GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=is_admin && 'GrafanaAdmin'
- GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN=true
# Strip Grafana's stock home-page cruft (news feed, "welcome",
# tutorials, update nags, telemetry). Our homelab-overview
# dashboard is the default landing page.
- GF_NEWS_NEWS_FEED_ENABLED=false
- GF_ANALYTICS_REPORTING_ENABLED=false
- GF_ANALYTICS_CHECK_FOR_UPDATES=false
- GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES=false
- GF_ANALYTICS_FEEDBACK_LINKS_ENABLED=false
- GF_HELP_ENABLED=false
- GF_PLUGINS_PUBLIC_KEY_RETRIEVAL_DISABLED=true
- GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/homelab-overview.json
# Light theme is the fhirworx baseline (cream background, deep
# navy primary). Theme overrides in grafana.css target the
# light-theme compiled CSS — dark would need a separate pass.
- GF_USERS_DEFAULT_THEME=light
volumes:
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
- ./assets/icons/favicon.svg:/usr/share/grafana/public/img/grafana_icon.svg:ro
- ./assets/icons/fav32.png:/usr/share/grafana/public/img/fav32.png:ro
- ./assets/icons/apple-touch-icon.png:/usr/share/grafana/public/img/apple-touch-icon.png:ro
- ./assets/css/inject.css:/usr/share/grafana/public/fhirworx.css:ro
- ./assets/css/grafana.css:/usr/share/grafana/public/grafana-fhirworx.css:ro
depends_on:
- loki
- tempo
- prometheus
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
# wire — ephemeral bootstrap container. Creates Gitea admin, OAuth2 app,
# oauth2-proxy credentials, tunnel config, DNS records. Idempotent.
# docker compose run --rm wire
wire:
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
container_name: wire
networks:
- gateway
- storage
working_dir: /app
env_file: .env
volumes:
- .:/app
- ${DOCKER_SOCK:-/run/user/1000/docker.sock}:/var/run/docker.sock:ro
environment:
- UV_PROJECT_ENVIRONMENT=/tmp/.venv
entrypoint: ["uv", "run", "python", "dev/scripts/bootstrap_sso.py"]
profiles: ["tools"]
security_opt:
- no-new-privileges:true
auth-handler:
image: nginx:alpine
container_name: auth-handler
networks:
- gateway
volumes:
- ./infra/oauth2-proxy/auth-handler.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- oauth2-proxy
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:latest
container_name: oauth2-proxy
networks:
- gateway
env_file:
- path: .state/gitea/oauth2-proxy.env
required: false
environment:
- OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180
- OAUTH2_PROXY_PROVIDER=oidc
- OAUTH2_PROXY_PROVIDER_DISPLAY_NAME=fhirworx
- OAUTH2_PROXY_OIDC_ISSUER_URL=https://git.${DOMAIN:-fhirworx.io}/
- OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true
- OAUTH2_PROXY_LOGIN_URL=https://git.${DOMAIN:-fhirworx.io}/login/oauth/authorize
- OAUTH2_PROXY_REDEEM_URL=http://git:3000/login/oauth/access_token
- OAUTH2_PROXY_OIDC_JWKS_URL=http://git:3000/login/oauth/keys
- OAUTH2_PROXY_INSECURE_OIDC_SKIP_ISSUER_VERIFICATION=true
- OAUTH2_PROXY_REDIRECT_URL=https://auth.${DOMAIN:-fhirworx.io}/oauth2/callback
- OAUTH2_PROXY_COOKIE_DOMAINS=.${DOMAIN:-fhirworx.io}
- OAUTH2_PROXY_WHITELIST_DOMAINS=.${DOMAIN:-fhirworx.io}
- OAUTH2_PROXY_COOKIE_SECURE=true
- OAUTH2_PROXY_COOKIE_SAMESITE=lax
- OAUTH2_PROXY_COOKIE_NAME=_fhirworx_auth
- OAUTH2_PROXY_SET_XAUTHREQUEST=true
- OAUTH2_PROXY_REVERSE_PROXY=true
- OAUTH2_PROXY_EMAIL_DOMAINS=*
- OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true
- OAUTH2_PROXY_CUSTOM_SIGN_IN_LOGO=-
depends_on:
- git
healthcheck:
test: ["CMD", "oauth2-proxy", "--version"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
labels:
- "promtail=true"
security_opt:
- no-new-privileges:true
restart: unless-stopped
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
command: tunnel --config /home/nonroot/.cloudflared/config.yml run
networks:
- gateway
volumes:
- ./infra/cloudflared:/home/nonroot/.cloudflared:ro
labels:
- "promtail=true"
restart: unless-stopped
volumes:
postgres_data:
rustfs_data:
rustfs_logs:
gitea_data:
gitea_config:
act_runner_data:
loki_data:
prometheus_data:
tempo_data:
grafana_data:
ollama_models: