services: # TimescaleDB (PostgreSQL with time-series optimization) db: image: timescale/timescaledb:latest-pg17 container_name: argus-db restart: unless-stopped logging: driver: "json-file" options: max-size: "50m" max-file: "3" environment: TZ: ${TZ:-UTC} # Internal database credentials, not customer-configurable — nothing # outside this compose stack ever connects to Postgres directly, so # there's no real reason to let these vary. Only the password (below) # needs to be a real secret; the username/db name are just labels. POSTGRES_USER: postgres POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} POSTGRES_DB: argus command: postgres -c timezone=${TZ:-UTC} -c log_timezone=${TZ:-UTC} -c shared_preload_libraries=timescaledb -c timescaledb.telemetry_level=off -c max_locks_per_transaction=512 volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 10 start_period: 30s # Valkey (Redis-compatible) for caching valkey: image: valkey/valkey:9-alpine container_name: argus-valkey restart: unless-stopped logging: driver: "json-file" options: max-size: "10m" max-file: "3" environment: TZ: ${TZ:-UTC} command: > valkey-server --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes --appendfsync everysec volumes: - valkey_data:/data healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 10s timeout: 5s retries: 5 start_period: 10s # Restricts the api service's Docker Engine API access to exactly what it # needs (list/inspect/exec/restart existing containers for log collection # and nginx reload) — it cannot create new containers or touch the host # beyond that. See docker-socket-proxy/README.md for the exact allowed set. docker-socket-proxy: image: tecnativa/docker-socket-proxy:latest container_name: argus-docker-socket-proxy restart: unless-stopped security_opt: - "no-new-privileges:true" environment: CONTAINERS: 1 IMAGES: 1 VOLUMES: 1 SYSTEM: 1 EXEC: 1 VERSION: 1 POST: 1 # Restart (stop/restart/kill on an *existing* container) only -- # create/prune stay denied by the haproxy template's own explicit # deny-list regardless of POST, and start/stop stay off (ALLOW_START/ # ALLOW_STOP unset) since nothing needs them yet. This comment block's # own claim of "restart" capability (above) was aspirational until # this line existed -- appliance#43's Diagnostics recovery actions are # the first real caller. ALLOW_RESTARTS: 1 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./docker-socket-proxy/haproxy.cfg.template:/usr/local/etc/haproxy/haproxy.cfg.template:ro logging: driver: "json-file" options: max-size: "10m" max-file: "3" # Go API Server api: # ARGUS_VERSION is set by update.sh before `docker compose pull`, pinned # to a specific already-fully-promoted version tag rather than the # floating :latest (ADR-0051 -- argus-appliance repo). Manual/non- # update.sh use (plain `docker compose pull`, no env var set) is # unaffected: the default is :latest, identical to before this change. image: git-cloud.weboria.eu/weboria/argus-api:${ARGUS_VERSION:-latest} container_name: argus-api restart: unless-stopped # Container hardening: no-new-privileges blocks setuid privilege # escalation; cap_drop ALL removes every Linux capability, re-adding only # what's needed (DAC_OVERRIDE for the shared /etc/nginx volume owned by # the nginx user, NET_BIND_SERVICE for the DNS Security Engine's :53). security_opt: - "no-new-privileges:true" cap_drop: - ALL cap_add: - DAC_OVERRIDE - NET_BIND_SERVICE logging: driver: "json-file" options: max-size: "100m" max-file: "5" extra_hosts: - "host.docker.internal:host-gateway" environment: TZ: ${TZ:-UTC} PORT: "8080" # DATABASE_URL is not set here — the API builds it internally from # DB_PASSWORD using net/url (proper percent-encoding), instead of this # file naively interpolating the password into a raw connection # string. A raw '${DB_PASSWORD}@...' interpolation breaks outright if # the password ever contains '@', ':', '/', or similar (confirmed # live: an openssl-rand-base64-generated password containing '/' # produced an unparseable URL and the API never became healthy). DB_PASSWORD: ${DB_PASSWORD:-postgres} REDIS_URL: redis://valkey:6379/0 ENVIRONMENT: ${ENVIRONMENT:-production} NGINX_CONTAINER: argus-proxy NGINX_SKIP_TEST: "false" NGINX_STATUS_URL: "http://host.docker.internal:${NGINX_HTTP_PORT:-80}/nginx_status" NGINX_ACCESS_LOG: "/etc/nginx/logs/access_raw.log" BACKUP_PATH: ${BACKUP_PATH:-/app/data/backups} DOCKER_API_VERSION: ${DOCKER_API_VERSION:-} DOCKER_HOST: tcp://docker-socket-proxy:2375 NGINX_HTTP_PORT: ${NGINX_HTTP_PORT:-} NGINX_HTTPS_PORT: ${NGINX_HTTPS_PORT:-} API_HOST_PORT: ${API_HOST_PORT:-9080} API_HOST: ${API_HOST:-} # ANIS community intelligence — on by default against the public # Community Edition hub (community tier, no signup required). There is # no self-hosted-ANIS deployment model: the URL is fixed, not # customer-configurable. A license key upgrades the tier, but is only # ever set from Threat Intel -> ANIS Connection in the dashboard # (write-only there — never round-trips back to the client), not from # this file, so it's seeded empty (community tier) here. # (No ANIS_ADMIN_KEY or ANIS_SHARE_ATTACKERS here: both confirmed dead # via grep, zero references anywhere in internal/config.go or the ANIS # client. A prior version of this file carried ANIS_ADMIN_KEY and # falsely claimed ARGUS "fails closed at startup" without it.) ANIS_ENABLED: ${ANIS_ENABLED:-true} ANIS_URL: https://anis.weboria.eu ANIS_LICENSE_KEY: "" DNS_SECURITY_LISTEN_ADDR: ":53" ports: - "127.0.0.1:${API_HOST_PORT:-9080}:8080" # DNS Security Engine. Host-only by default — a bare forwarding # resolver reachable from the network is exactly the profile abused # for DNS amplification attacks against third parties. Set # DNS_LISTEN_HOST to an internal interface IP to route real client # DNS traffic through it (never 0.0.0.0 on an untrusted network). - "${DNS_LISTEN_HOST:-127.0.0.1}:53:53/udp" - "${DNS_LISTEN_HOST:-127.0.0.1}:53:53/tcp" volumes: - nginx_data:/etc/nginx:rw - api_data:/app/data:rw depends_on: db: condition: service_healthy valkey: condition: service_started docker-socket-proxy: condition: service_started healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"] interval: 30s timeout: 5s retries: 3 start_period: 360s # api-watchdog (2026-09-12, see docker/watchdog/README.md in the # argus-appliance repo): closes a real production gap -- `restart: # unless-stopped` above only ever reacts to the api container actually # EXITING. A real incident (CT 101, 2026-09-11/12) had it hang/wedge # internally for ~11 hours while `docker ps` still reported it `Up`; the # HEALTHCHECK above correctly kept detecting the hang the entire time, # but nothing was watching that status and acting on it. This polls the # same already-scoped docker-socket-proxy (zero new grants) and restarts # argus-api after several consecutive unhealthy checks. # # Built locally rather than pulled/pinned/cosign-verified like api/ui/ # nginx above: there is not yet a released, signed argus-api-watchdog # image in the registry (tracked as a follow-up to extend ADR-0051's # verification set to a 4th component). The build context ships in this # repo so it stays fully reproducible from source, not a hand-built # local image nobody can regenerate. api-watchdog: build: context: ./watchdog container_name: argus-api-watchdog restart: unless-stopped security_opt: - "no-new-privileges:true" cap_drop: - ALL logging: driver: "json-file" options: max-size: "10m" max-file: "3" environment: WATCHDOG_TARGET_CONTAINER: argus-api WATCHDOG_INTERVAL_SECONDS: "30" WATCHDOG_UNHEALTHY_THRESHOLD: "5" depends_on: docker-socket-proxy: condition: service_started api: condition: service_started # React UI (Admin Panel), served over HTTPS ui: # See the api service's own comment above -- same ADR-0051 pinning. image: git-cloud.weboria.eu/weboria/argus-ui:${ARGUS_VERSION:-latest} container_name: argus-ui restart: unless-stopped security_opt: - "no-new-privileges:true" cap_drop: - ALL cap_add: - NET_BIND_SERVICE logging: driver: "json-file" options: max-size: "10m" max-file: "3" environment: TZ: ${TZ:-UTC} ports: - "${UI_PORT:-81}:443" volumes: - ui_data:/app/ssl:rw depends_on: - api # Nginx reverse proxy — host network mode, so real client IPs are visible # directly without needing PROXY protocol. nginx: # See the api service's own comment above -- same ADR-0051 pinning. image: git-cloud.weboria.eu/weboria/argus-proxy:${ARGUS_VERSION:-latest} container_name: argus-proxy restart: always network_mode: host # Not cap_drop'd: runs nginx in host-network mode with a root master that # drops workers to the nginx user, needing CHOWN/SETUID/SETGID plus binds # on privileged 80/443. security_opt: - "no-new-privileges:true" logging: driver: "json-file" options: max-size: "100m" max-file: "5" environment: TZ: ${TZ:-UTC} ulimits: nofile: soft: 65535 hard: 65535 volumes: - nginx_data:/etc/nginx:rw depends_on: - api healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:${NGINX_HTTP_PORT:-80}/health"] interval: 15s timeout: 5s retries: 3 start_period: 10s volumes: postgres_data: name: argus_postgres_data valkey_data: name: argus_valkey_data nginx_data: name: argus_nginx_data api_data: name: argus_api_data ui_data: name: argus_ui_data networks: default: name: argus-network external: true