name: itworx-pulse # Unraid DockerMan reads these labels directly for Compose-managed containers. # FolderView3 provides the single application group; every contained service # keeps the same recognizable icon and opens the public Pulse UI when selected. x-unraid-labels: &unraid-labels net.unraid.docker.icon: ${PULSE_UNRAID_ICON_URL:-} net.unraid.docker.webui: ${PULSE_UNRAID_WEBUI_URL:-} net.unraid.docker.managed: composeman net.unraid.docker.shell: sh # Resource limits (finding: "no CPU/memory limits on any service") # --------------------------------------------------------------- # Deployment method is plain `docker compose -f deploy/compose.yaml ... up -d` # (see docs/operations/DEVELOPMENT_SETUP.md and DEPLOYMENT_UNRAID.md); this is # NOT Docker Swarm and there is no `docker stack deploy` anywhere in the repo. # `deploy.resources.reservations` is a swarm-only field and is silently # ignored by `docker compose up`; `deploy.resources.limits` support under # plain `up` varies by Compose CLI version and is therefore not a reliable # enforcement point on an operator's host we do not control. This file # already uses the non-swarm legacy resource keys (`pids_limit`), so the # same family (`cpus`, `mem_limit`, `mem_reservation`, `memswap_limit`) is # used here for consistency and guaranteed enforcement by the Compose CLI in # non-swarm mode, regardless of installed Compose version. # # Sizing rationale (see docs/architecture/SYSTEM_ARCHITECTURE.md §7 scale # targets: 1 host, 150 containers, 40 disks, 300 probes, 2,500 active # dashboard series, 10 concurrent users): # - pulse-postgres holds inventory/config/alerts/audit rows for that scale, # not raw time series (Prometheus stays external) -> generous but bounded. # - pulse-api serves 10 concurrent users and bounded query/WebSocket traffic. # - pulse-worker runs discovery/reconciliation/probes/alerts/notifications; # it is the service most exposed to "runaway probe loop" style incidents, # so its cap is deliberately tight relative to its risk. # - pulse-agent is a bounded read-only collector; pulse-web is static nginx. # - pulse-migrate is a short-lived one-shot job. # These are conservative starting points, not measured against the actual # host's free CPU/memory (M0 discovery in DEPLOYMENT_UNRAID.md §2 records # that separately per-host); re-tune after observing real usage. `memswap_limit` # equals `mem_limit` for every service so a service cannot spill onto host # swap and degrade the other ~80 containers on the shared Unraid host. services: pulse-postgres: labels: *unraid-labels build: context: .. dockerfile: deploy/postgres.Dockerfile image: itworx-pulse-postgres:17-hardened environment: POSTGRES_DB: ${PULSE_POSTGRES_DB:-pulse} POSTGRES_USER: ${PULSE_POSTGRES_USER:-pulse} POSTGRES_PASSWORD: ${PULSE_POSTGRES_PASSWORD:?PULSE_POSTGRES_PASSWORD must be set} volumes: - pulse-postgres-data:/var/lib/postgresql/data networks: [pulse-internal] healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped # Read-only exception check (threat model §5 "document and test each"): # the data directory is already the dedicated `pulse-postgres-data` # volume, so PGDATA writes are unaffected. Postgres additionally needs a # writable unix-socket directory (used by both the server and the # `pg_isready` healthcheck above, since no PGHOST is set) and a writable # /tmp for on-disk sort/temp files that fall outside PGDATA. Both are # provided via tmpfs below, matching the pattern of the other five # services. This must be verified during deployment smoke testing # (container starts, healthcheck goes healthy, migrations run # successfully) before this is relied on in production. read_only: true tmpfs: ["/tmp", "/var/run/postgresql"] security_opt: [no-new-privileges:true] cap_drop: [ALL] pids_limit: 256 cpus: "1.0" mem_limit: 768m mem_reservation: 256m memswap_limit: 768m pulse-api: labels: *unraid-labels build: context: .. dockerfile: deploy/api.Dockerfile args: PULSE_BUILD_VERSION: ${PULSE_BUILD_VERSION:-development} PULSE_BUILD_COMMIT: ${PULSE_BUILD_COMMIT:-unknown} PULSE_BUILD_TIME: ${PULSE_BUILD_TIME:-unknown} environment: PULSE_API_ADDR: 0.0.0.0:8081 PULSE_ENV: ${PULSE_ENV:-production} PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set} PULSE_AUTH_MODE: ${PULSE_AUTH_MODE:-oidc} PULSE_DEFAULT_LOCALE: ${PULSE_DEFAULT_LOCALE:-nl} PULSE_TIMEZONE: ${PULSE_TIMEZONE:-Europe/Brussels} PULSE_LOG_LEVEL: ${PULSE_LOG_LEVEL:-info} PULSE_BREAK_GLASS_ENABLED: ${PULSE_BREAK_GLASS_ENABLED:-false} PULSE_OIDC_ISSUER: ${PULSE_OIDC_ISSUER:-} PULSE_OIDC_CLIENT_ID: ${PULSE_OIDC_CLIENT_ID:-} PULSE_OIDC_CLIENT_SECRET: ${PULSE_OIDC_CLIENT_SECRET:-} PULSE_OIDC_REDIRECT_URL: ${PULSE_OIDC_REDIRECT_URL:-} PULSE_OIDC_GROUPS_CLAIM: ${PULSE_OIDC_GROUPS_CLAIM:-groups} PULSE_OIDC_ROLE_MAPPING: ${PULSE_OIDC_ROLE_MAPPING:-} PULSE_SESSION_IDLE_TTL: ${PULSE_SESSION_IDLE_TTL:-8h} PULSE_SESSION_ABSOLUTE_TTL: ${PULSE_SESSION_ABSOLUTE_TTL:-168h} # API-side metric query/live handlers use the same bounded source as the # worker alert evaluator. Without this explicit wiring the UI receives a # source-unavailable adapter even while worker alerts can query metrics. PULSE_PROMETHEUS_URL: ${PULSE_PROMETHEUS_URL:-} PULSE_PROMETHEUS_TIMEOUT: ${PULSE_PROMETHEUS_TIMEOUT:-10s} depends_on: pulse-migrate: {condition: service_completed_successfully} networks: [pulse-internal, pulse-edge] expose: ["8081"] healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8081/healthz >/dev/null"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped read_only: true tmpfs: ["/tmp"] security_opt: [no-new-privileges:true] cap_drop: [ALL] pids_limit: 256 cpus: "1.0" mem_limit: 512m mem_reservation: 128m memswap_limit: 512m pulse-worker: labels: *unraid-labels build: context: .. dockerfile: deploy/worker.Dockerfile environment: PULSE_ENV: ${PULSE_ENV:-production} PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set} # Non-secret public endpoints used to install the bounded, read-only # Pulse and Authentik service checks. Both targets still pass the probe # SSRF policy before any row is written and again at dial time. PULSE_PUBLIC_URL: ${PULSE_PUBLIC_URL:-} PULSE_OIDC_ISSUER: ${PULSE_OIDC_ISSUER:-} # Alert evaluation stays Disabled without a metric source, and container # discovery stays Disabled until a data_sources row exists to attribute # inventory to. Both are reported as Disabled with a reason rather than # silently absent, so an unconfigured capability is visible on the status # page instead of looking healthy. PULSE_PROMETHEUS_URL: ${PULSE_PROMETHEUS_URL:-} PULSE_CONTAINER_SOURCE_ID: ${PULSE_CONTAINER_SOURCE_ID:-} # Private/loopback CIDRs service probes may reach. Empty keeps all private # space blocked; link-local, multicast and cloud metadata stay blocked # regardless of this value. PULSE_PROBE_ALLOWED_NETWORKS: ${PULSE_PROBE_ALLOWED_NETWORKS:-} # Optional HTTPS webhook transport. The token is injected only into the # worker process and is represented in PostgreSQL by an opaque reference. PULSE_NOTIFICATION_WEBHOOK_URL: ${PULSE_NOTIFICATION_WEBHOOK_URL:-} PULSE_NOTIFICATION_WEBHOOK_TOKEN: ${PULSE_NOTIFICATION_WEBHOOK_TOKEN:-} PULSE_NOTIFICATION_WEBHOOK_TIMEOUT: ${PULSE_NOTIFICATION_WEBHOOK_TIMEOUT:-10s} PULSE_HEARTBEAT_FILE: ${PULSE_HEARTBEAT_FILE:-/tmp/healthy} depends_on: pulse-migrate: {condition: service_completed_successfully} # pulse-internal reaches PostgreSQL but deliberately has no public egress. # pulse-edge supplies outbound HTTPS/DNS for SSRF-bounded probes. The # worker has no listener, exposed port or published port on either network. networks: [pulse-internal, pulse-edge] # Heartbeat healthcheck contract: see # docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md and # deploy/healthcheck-heartbeat.sh. `kill -0 1` only proves the PID # exists, not that the scheduling loop is making progress; the script # below checks the freshness of a heartbeat file written by the process # itself and force-restarts the container (via `restart: unless-stopped`) # when it goes stale, because `docker compose up` does not restart a # merely-"unhealthy" container on its own. healthcheck: test: ["CMD", "/usr/local/bin/pulse-healthcheck.sh"] interval: 15s timeout: 5s start_period: 20s retries: 3 restart: unless-stopped read_only: true tmpfs: ["/tmp"] security_opt: [no-new-privileges:true] cap_drop: [ALL] pids_limit: 256 cpus: "1.0" mem_limit: 512m mem_reservation: 128m memswap_limit: 512m pulse-agent: labels: *unraid-labels build: context: .. dockerfile: deploy/agent.Dockerfile environment: PULSE_ENV: ${PULSE_ENV:-production} PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set} # Optional read-only Unraid GraphQL source. This is an API key, never a # Docker socket or a host-control credential; omit both values to keep # container telemetry explicitly Unknown. PULSE_UNRAID_URL: ${PULSE_UNRAID_URL:-} PULSE_UNRAID_API_TOKEN: ${PULSE_UNRAID_API_TOKEN:-} PULSE_UNRAID_CA_FILE: ${PULSE_UNRAID_CA_FILE:-} PULSE_AGENT_ID: ${PULSE_AGENT_ID:-pulse-agent} PULSE_AGENT_COLLECT_INTERVAL: ${PULSE_AGENT_COLLECT_INTERVAL:-10s} # The host's procfs and sysfs are bind mounted read-only below; the collector # reads nothing else. PULSE_AGENT_PROC_ROOT: /host/proc PULSE_AGENT_SYS_ROOT: /host/sys # /proc/sys/kernel/hostname is resolved through the reader's UTS namespace, so # inside the container it returns the container name. Name the host explicitly. PULSE_AGENT_HOST_NAME: ${PULSE_AGENT_HOST_NAME:-} # Filesystem capacity is opt-in: it needs a read-only host root mount, which is a # wider grant than /proc and /sys and is therefore an explicit operator decision. # Set PULSE_AGENT_FS_ROOT=/host/root and add "- /:/host/root:ro" below to enable. PULSE_AGENT_FS_ROOT: ${PULSE_AGENT_FS_ROOT:-} # Read-only, no-exec bind mounts of the host's kernel interfaces. This is the whole # of the agent's host access: no Docker socket (ADR-0005), no writable host path, # no extra capability — statfs(2) and reading procfs need none, and the container # keeps the read-only root filesystem, the dropped capabilities and no published ports. volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro depends_on: pulse-api: {condition: service_healthy} networks: [pulse-internal, pulse-collector] # See the heartbeat healthcheck contract note on pulse-worker above and # docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md. healthcheck: test: ["CMD", "/usr/local/bin/pulse-healthcheck.sh"] interval: 15s timeout: 5s start_period: 20s retries: 3 restart: unless-stopped read_only: true tmpfs: ["/tmp"] security_opt: [no-new-privileges:true] cap_drop: [ALL] pids_limit: 256 cpus: "0.5" mem_limit: 256m mem_reservation: 64m memswap_limit: 256m pulse-web: labels: *unraid-labels build: context: .. dockerfile: deploy/web.Dockerfile depends_on: pulse-api: {condition: service_healthy} networks: [pulse-edge] expose: ["8080"] healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped read_only: true tmpfs: ["/tmp:rw,noexec,nosuid", "/var/cache/nginx:rw,noexec,nosuid", "/var/run:rw,noexec,nosuid"] security_opt: [no-new-privileges:true] cap_drop: [ALL] pids_limit: 256 cpus: "0.5" mem_limit: 128m mem_reservation: 32m memswap_limit: 128m pulse-migrate: labels: *unraid-labels build: context: .. dockerfile: deploy/migrate.Dockerfile environment: PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set} depends_on: pulse-postgres: {condition: service_healthy} networks: [pulse-internal] restart: "no" read_only: true tmpfs: ["/tmp"] security_opt: [no-new-privileges:true] cap_drop: [ALL] pids_limit: 256 cpus: "1.0" mem_limit: 256m mem_reservation: 64m memswap_limit: 256m volumes: pulse-postgres-data: networks: pulse-internal: internal: true pulse-edge: # Dedicated outbound boundary for the non-root read-only agent. No other # service joins it, and the agent still exposes no port or host capability. pulse-collector: