name: panther-minor # -- Shared network ----------------------------------------------------------- networks: ai: driver: bridge # -- Persistent volumes ------------------------------------------------------- volumes: llama-cpp-comgr: sd-cpp-comgr: open-webui-data: prometheus-data: grafana-data: proxy-logs: services: # -- llama.cpp -------------------------------------------------------------- # OpenAI-compatible LLM inference, native RDNA 4 and ROCm 10 support llama-cpp: container_name: llama-cpp build: context: ./llama-cpp args: HOST_UID: '${HOST_UID}' HOST_GID: '${HOST_GID}' LLAMA_CPP_REPO: '${LLAMA_CPP_REPO}' LLAMA_CPP_REF: '${LLAMA_CPP_REF}' LLAMA_CPP_USER: 'llama-cpp' ROCM_ARCH: '${ROCM_ARCH}' restart: unless-stopped devices: - '/dev/kfd:/dev/kfd' - '/dev/dri:/dev/dri' group_add: - '${VIDEO_GID}' - '${RENDER_GID}' # GPU access is the two device nodes plus the video/render groups; `privileged` is not needed. seccomp is # unconfined per the ROCm Docker docs ("enables memory mapping"): the default profile blocks # mbind/set_mempolicy/get_mempolicy without SYS_NICE, which ROCm can use for host memory (n-cpu-moe experts). security_opt: - 'seccomp=unconfined' environment: HF_TOKEN: '${HF_TOKEN}' HIP_VISIBLE_DEVICES: '${ROCM_VISIBLE_DEVICES}' LLAMA_CPP_BATCH_SIZE: '${LLAMA_CPP_BATCH_SIZE}' LLAMA_CPP_CACHE_RAM: '${LLAMA_CPP_CACHE_RAM}' LLAMA_CPP_MODELS_MAX: '${LLAMA_CPP_MODELS_MAX}' LLAMA_CPP_PARALLEL: '${LLAMA_CPP_PARALLEL}' LLAMA_CPP_SLEEP_IDLE_SECONDS: '${LLAMA_CPP_SLEEP_IDLE_SECONDS}' LLAMA_CPP_UBATCH_SIZE: '${LLAMA_CPP_UBATCH_SIZE}' volumes: - 'llama-cpp-comgr:/home/llama-cpp/.cache/comgr' - './models/.huggingface:/home/llama-cpp/.cache/huggingface/hub' - './llama-cpp/preset.ini:/home/llama-cpp/.cache/huggingface/preset.ini:ro' ulimits: memlock: -1 # Top-level systemd slice, whose MemoryLow (set by `setup docker`) keeps the host-resident weights cached under # pressure from other workloads. A container under the default system.slice can't be protected: memory.low only # takes effect up to its ancestors' protection, and system.slice has none. No dash in the name: systemd reads a # dash as nesting (a-b.slice sits inside a.slice). See docs/llm.md#host-memory-protection. cgroup_parent: llm.slice expose: - 8000 networks: - ai healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:8000/health'] interval: 30s timeout: 10s retries: 5 start_period: 60s # -- llama.cpp manager (proxy + idle unload) ---------------------------------- # Activity-aware reverse proxy: records inference activity and unloads models # through llama-server when the cluster stays idle. llama-manager: container_name: llama-manager image: node:26-trixie-slim restart: unless-stopped command: ['node', '/app/main.js'] environment: IDLE_UNLOAD_RETRY_SECONDS: 15 LLAMA_CPP_SLEEP_IDLE_SECONDS: '${LLAMA_CPP_SLEEP_IDLE_SECONDS}' LLAMA_SERVER_URL: 'http://llama-cpp:8000' LOG_LEVEL: info PORT: 8000 # Inactivity ceiling for proxied inference streams (cold loads stream no # bytes for minutes); matches proxy_read_timeout in proxy/nginx.conf. PROXY_TIMEOUT_SECONDS: 900 # Control-plane bound only: /models and /models/unload fetches. UPSTREAM_TIMEOUT_SECONDS: 4 volumes: - './llama-cpp/manager.js:/app/main.js:ro' - './llama-cpp/models.js:/app/models.js:ro' expose: - 8000 networks: - ai healthcheck: test: [ 'CMD', 'node', '-e', "fetch('http://localhost:8000/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" ] interval: 30s timeout: 10s retries: 5 start_period: 60s # -- llama.cpp metrics exporter --------------------------------------------- llama-metrics-exporter: container_name: llama-metrics-exporter image: node:26-trixie-slim restart: unless-stopped command: ['node', '/app/main.js'] environment: CACHE_TTL_SECONDS: 5 LLAMA_SERVER_URL: 'http://llama-cpp:8000' MANAGER_URL: 'http://llama-manager:8000' LOG_LEVEL: info PORT: 9090 UPSTREAM_TIMEOUT_SECONDS: 4 volumes: - './llama-cpp/metrics-exporter.js:/app/main.js:ro' - './llama-cpp/models.js:/app/models.js:ro' expose: - 9090 networks: - ai healthcheck: test: [ 'CMD', 'node', '-e', "fetch('http://localhost:9090/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" ] interval: 30s timeout: 10s retries: 5 start_period: 60s depends_on: - llama-manager # -- stable-diffusion.cpp --------------------------------------------------- # OpenAI-compatible image generation (sd-server), native RDNA 4 and ROCm 10 support. # Pinned to a single GPU via HIP_VISIBLE_DEVICES so it does not contend with the LLMs. stable-diffusion-cpp: container_name: stable-diffusion-cpp build: context: ./stable-diffusion-cpp args: HOST_UID: '${HOST_UID}' HOST_GID: '${HOST_GID}' SD_CPP_REPO: '${SD_CPP_REPO}' SD_CPP_REF: '${SD_CPP_REF}' SD_CPP_USER: 'stable-diffusion-cpp' ROCM_ARCH: '${ROCM_ARCH}' restart: unless-stopped devices: - '/dev/kfd:/dev/kfd' - '/dev/dri:/dev/dri' group_add: - '${VIDEO_GID}' - '${RENDER_GID}' # Same GPU access as llama-cpp; see the comment there. --offload-to-cpu relies on ROCm host memory. security_opt: - 'seccomp=unconfined' environment: HIP_VISIBLE_DEVICES: '${SD_VISIBLE_DEVICES}' # Active text-to-image model, managed by `./bin/panther-minor models t2i load`. # sd-server loads exactly one model, so only one is ever resident. These # come straight from .env with no fallback, so .env is the single source # of truth: a missing value surfaces as a compose warning instead of a # silently wrong default (e.g. a stale unconditional diffusion model). SD_CPP_MODEL: '${SD_CPP_MODEL}' SD_CPP_DIFFUSION_MODEL: '${SD_CPP_DIFFUSION_MODEL}' SD_CPP_UNCOND_DIFFUSION_MODEL: '${SD_CPP_UNCOND_DIFFUSION_MODEL}' SD_CPP_LLM: '${SD_CPP_LLM}' SD_CPP_VAE: '${SD_CPP_VAE}' SD_CPP_MODEL_ARGS: '${SD_CPP_MODEL_ARGS}' volumes: - 'sd-cpp-comgr:/home/stable-diffusion-cpp/.cache/comgr' - './models/.huggingface:/home/stable-diffusion-cpp/.cache/huggingface/hub' ulimits: memlock: -1 expose: - 8000 networks: - ai healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:8000/v1/models'] interval: 30s timeout: 10s retries: 5 start_period: 120s # -- stable-diffusion.cpp manager (proxy + activity tracking) --------------- # Activity-aware reverse proxy in front of sd-server: records image activity and # exposes /status for the exporter. Uses a long proxy timeout because image # generation streams no bytes for tens of seconds. sd-manager: container_name: sd-manager image: node:26-trixie-slim restart: unless-stopped command: ['node', '/app/main.js'] environment: LOG_LEVEL: info PORT: 8000 PROXY_TIMEOUT_SECONDS: 900 SD_CPP_SLEEP_IDLE_SECONDS: '${SD_CPP_SLEEP_IDLE_SECONDS}' SD_SERVER_URL: 'http://stable-diffusion-cpp:8000' volumes: - './stable-diffusion-cpp/manager.js:/app/main.js:ro' - './stable-diffusion-cpp/models.js:/app/models.js:ro' expose: - 8000 networks: - ai healthcheck: test: [ 'CMD', 'node', '-e', "fetch('http://localhost:8000/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" ] interval: 30s timeout: 10s retries: 5 start_period: 60s depends_on: - stable-diffusion-cpp # -- stable-diffusion.cpp metrics exporter ---------------------------------- sd-metrics-exporter: container_name: sd-metrics-exporter image: node:26-trixie-slim restart: unless-stopped command: ['node', '/app/main.js'] environment: CACHE_TTL_SECONDS: 5 LOG_LEVEL: info MANAGER_URL: 'http://sd-manager:8000' PORT: 9090 SD_SERVER_URL: 'http://stable-diffusion-cpp:8000' UPSTREAM_TIMEOUT_SECONDS: 4 volumes: - './stable-diffusion-cpp/metrics-exporter.js:/app/main.js:ro' - './stable-diffusion-cpp/models.js:/app/models.js:ro' expose: - 9090 networks: - ai healthcheck: test: [ 'CMD', 'node', '-e', "fetch('http://localhost:9090/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" ] interval: 30s timeout: 10s retries: 5 start_period: 60s depends_on: - sd-manager # -- Open WebUI (chat interface) -------------------------------------------- open-webui: container_name: open-webui image: ghcr.io/open-webui/open-webui:v0.10.2 restart: unless-stopped environment: DEFAULT_MODELS: 'Qwen3.8-27B' ENABLE_EVALUATION_ARENA_MODELS: false ENABLE_IMAGE_GENERATION: true ENABLE_MODELS_CACHE: true ENABLE_OLLAMA_API: false ENABLE_PERSISTENT_CONFIG: true ENABLE_WEB_SEARCH: true IMAGE_GENERATION_ENGINE: 'openai' IMAGE_GENERATION_MODEL: 'default' IMAGE_SIZE: '1024x1024' IMAGES_OPENAI_API_BASE_URL: 'http://sd-manager:8000/v1' IMAGES_OPENAI_API_KEY: 'stable-diffusion-cpp' MODELS_CACHE_TTL: 3600 OPENAI_API_BASE_URL: 'http://llama-manager:8000/v1' OPENAI_API_KEY: 'llama-cpp' RAG_EMBEDDING_ENGINE: 'openai' RAG_EMBEDDING_MODEL: 'Qwen3-Embedding-0.6B' RAG_OPENAI_API_BASE_URL: 'http://llama-manager:8000/v1' RAG_OPENAI_API_KEY: 'llama-cpp' TASK_MODEL: 'Qwen3.5-2B' TASK_MODEL_EXTERNAL: 'Qwen3.5-2B' WEBUI_AUTH: false volumes: - 'open-webui-data:/app/backend/data' expose: - 8080 networks: - ai healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health'] interval: 30s timeout: 10s retries: 5 start_period: 60s depends_on: - llama-manager - sd-manager # -- AMD GPU metrics exporter ----------------------------------------------- amd-gpu-exporter: container_name: amd-gpu-exporter image: rocm/device-metrics-exporter:v1.5.1-beta.0 restart: unless-stopped devices: - '/dev/kfd:/dev/kfd' - '/dev/dri:/dev/dri' group_add: - '${VIDEO_GID}' - '${RENDER_GID}' expose: - 5000 networks: - ai # -- Host metrics (CPU, RAM, disk, network) --------------------------------- node-exporter: container_name: node-exporter image: prom/node-exporter:v1.12.1 restart: unless-stopped # Root (uid 0) is required to read RAPL energy_uj (CPU package power, mode 0400 root) under # /sys/class/powercap. Owner read needs no capabilities, so all are dropped: root-owned host files stay # readable through /rootfs, but other users' private files and signalling non-root processes do not. user: root cap_drop: - ALL security_opt: - 'no-new-privileges:true' pid: host volumes: - '/proc:/host/proc:ro' - '/sys:/host/sys:ro' - '/:/rootfs:ro' command: - '--path.procfs=/host/proc' - '--path.rootfs=/rootfs' - '--path.sysfs=/host/sys' - '--collector.hwmon' - '--collector.thermal_zone' - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' expose: - 9100 networks: - ai # -- Prometheus ------------------------------------------------------------- prometheus: container_name: prometheus image: prom/prometheus:v3.16.0 restart: unless-stopped volumes: - 'prometheus-data:/prometheus' - './monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro' - './monitoring/prometheus.rules.yml:/etc/prometheus/prometheus.rules.yml:ro' command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=30d' - '--web.enable-lifecycle' expose: - 9090 networks: - ai healthcheck: test: ['CMD-SHELL', 'wget -q -O- http://localhost:9090/-/healthy >/dev/null || exit 1'] interval: 30s timeout: 10s retries: 5 start_period: 60s depends_on: - node-exporter - amd-gpu-exporter - llama-metrics-exporter - sd-metrics-exporter # -- Grafana ---------------------------------------------------------------- grafana: container_name: grafana image: grafana/grafana-oss:13.0.2 restart: unless-stopped environment: GF_AUTH_ANONYMOUS_ENABLED: true GF_AUTH_ANONYMOUS_ORG_ROLE: Admin GF_AUTH_DISABLE_LOGIN_FORM: true GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/cockpit.json volumes: - 'grafana-data:/var/lib/grafana' - './monitoring/grafana/provisioning:/etc/grafana/provisioning:ro' - './monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro' expose: - 3000 networks: - ai healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health'] interval: 30s timeout: 10s retries: 5 start_period: 60s depends_on: - prometheus # -- Nginx proxy ------------------------------------------------------------ proxy: container_name: proxy image: nginx:1.31.8-trixie restart: unless-stopped # The only service that publishes ports, and the whole internet-facing # surface of the stack. Each port is published twice: on loopback (SSH # tunnels, host-local curl) and on this node's Tailscale address. # # Docker's published-port DNAT rules match the bound address, so an # address-scoped publish is what keeps these services off the public # internet. A firewall rule cannot do it: DNAT'd packets are forwarded to # the container and never traverse the INPUT chain UFW manages # (moby/moby#17496). # # BIND_ADDR is mandatory on purpose. Unset, the stack refuses to start # (loud, fixable) instead of falling back to 0.0.0.0 (silent exposure). ports: - '127.0.0.1:3000:3000' - '${BIND_ADDR:?run "sudo ./bin/panther-minor setup env" after "sudo tailscale up" to set BIND_ADDR}:3000:3000' - '127.0.0.1:4200:4200' - '${BIND_ADDR}:4200:4200' - '127.0.0.1:8000:8000' - '${BIND_ADDR}:8000:8000' - '127.0.0.1:8001:8001' - '${BIND_ADDR}:8001:8001' - '127.0.0.1:8080:8080' - '${BIND_ADDR}:8080:8080' - '127.0.0.1:9090:9090' - '${BIND_ADDR}:9090:9090' volumes: - 'proxy-logs:/var/log/nginx' - './proxy/nginx.conf:/etc/nginx/nginx.conf:ro' - './proxy/ssl:/etc/nginx/ssl:ro' networks: - ai depends_on: - llama-manager - sd-manager - open-webui - grafana - prometheus