docker-compose.yml
Rama main · réplica de lectura del repositorio original.
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