# AiSOC — production compose. # # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d # # This is the file `apps/docs/docs/deployment/docker.md` has pointed at since # the deployment page was written. It did not exist, which is what discussion # #629 reported: the documented production command failed on a missing file, # and the only stack an operator could actually start was the development one, # where `ENVIRONMENT` defaults to `development` and # `services/api/app/api/v1/dev_auth.py` resolves an unauthenticated request to # a demo user whose role is `admin`. # # It is an overlay over `docker-compose.yml`, so there is exactly one # definition of every service and this file holds only what production changes. # A standalone copy would have drifted from the base within a release, which is # the failure this repository keeps finding. # # It was written with the Compose Spec `include` directive and a single `-f`. # That does not work: `include` imports a model, and overriding a service it # imported is rejected with `services. conflicts with imported resource`. # `infra/compose/docker-compose.dev.yml` had the same shape and has since # moved to a two-`-f` overlay for the same reason — it could never have # carried an override, which is why it carried nothing. # Measured rather than assumed — `-f docker-compose.prod.yml` alone fails on # Compose v2.29.7 and v2.39.4 and resolves only on 5.1.3, so the documented # production command worked on almost no installation. Two `-f` flags are the # ordinary override form and resolve identically on all three. # # WHAT THIS FILE GUARANTEES # # * The authentication bypass is unreachable. `ENVIRONMENT` and `AISOC_DEV_MODE` # are fixed values here, not interpolated, so no `.env` can re-enable them. # * Nothing starts on a default credential. Every secret uses `${VAR:?...}`, # which makes Compose refuse to start and name the variable rather than # starting on nothing — and, because `:?` rejects an *empty* variable and # cannot look at a value, `preflight-secrets` below runs ahead of every # other service and refuses the literals this repository publishes. Until # it existed this bullet was false: `${POSTGRES_PASSWORD:?… the # development default is a published literal}` accepted `aisoc_dev_secret`, # which is what `.env.example` shipped. # * No datastore is reachable from the host. Postgres, Redis, Kafka and # Qdrant publish no ports at all; the console and the ingest endpoint are # the only things bound, and they bind to loopback unless you set # AISOC_BIND_ADDR deliberately (put a TLS-terminating proxy in front — # see docs/runbooks/HARDENING.md section 2). # # WHAT IT DOES NOT DO # # TLS, secret rotation, network policies, audit-log forwarding and backups # are not in a compose file. `docs/runbooks/HARDENING.md` is the checklist # and this file is the starting posture, not the finished one. services: # --------------------------------------------------------------------------- # The value check `${VAR:?}` cannot do. # # Compose's `:?` operator rejects an unset or empty variable. It cannot look # at a value, so `${POSTGRES_PASSWORD:?… the development default is a # published literal}` accepted `aisoc_dev_secret` — the exact literal its own # message names, shipped in `.env.example` and defaulted to in # `docker-compose.yml`. Every guard below had the same hole, so the guarantee # in this file's header held for none of them. # # This runs first and every other service waits for it to exit 0. # `scripts/check_published_secrets.py` derives the refused values from the # tree — the `${VAR:-literal}` defaults in the compose files, the non-empty # values in `.env.example`, and INSECURE_SECRET_KEY_DEFAULTS — so a default # added later is refused without anyone remembering to list it here. # # Every `${VAR:?}` variable in this file is repeated below. That is what # gets it into the container's environment, and `--check` fails when a guard # exists that the preflight never sees. # --------------------------------------------------------------------------- preflight-secrets: image: ghcr.io/beenuar/aisoc-core-api:${AISOC_VERSION:-latest} pull_policy: missing container_name: aisoc-preflight-secrets restart: "no" entrypoint: ["python3", "/repo/scripts/check_published_secrets.py", "--check-env"] # Read-only, and the whole tree rather than the four files the harvest # happens to read today: a hand-listed mount is another list that stops # matching the code, and the failure mode is the check quietly reading # less than it thinks it does. `AISOC_REPO_ROOT` is how `gate_toolkit` # is pointed at a tree without shelling out to git, which this image # does not carry. volumes: - .:/repo:ro environment: AISOC_REPO_ROOT: /repo POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD — the development default is a published literal} AISOC_APP_DB_PASSWORD: ${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD — the development default is a published literal} REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD} SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY — run `make env` to generate it} AISOC_CREDENTIAL_KEY: ${AISOC_CREDENTIAL_KEY:?set AISOC_CREDENTIAL_KEY — run `make env` to generate it} AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN — run `make env` to generate it} AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN — run `make env` to generate it} AISOC_AGENTS_SERVICE_TOKEN: ${AISOC_AGENTS_SERVICE_TOKEN:?set AISOC_AGENTS_SERVICE_TOKEN — run `make env` to generate it} AISOC_REALTIME_JWT_SECRET: ${AISOC_REALTIME_JWT_SECRET:?set AISOC_REALTIME_JWT_SECRET — run `make env` to generate it} REALTIME_INTERNAL_TOKEN: ${REALTIME_INTERNAL_TOKEN:?set REALTIME_INTERNAL_TOKEN — run `make env` to generate it} METRICS_TOKEN: ${METRICS_TOKEN:?set METRICS_TOKEN — guards /metrics outside development; `openssl rand -hex 32`} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET — signs SAML/OIDC sessions} NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?set CLICKHOUSE_PASSWORD} GRAFANA_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD — required for the monitoring profile} # --------------------------------------------------------------------------- # Data plane. No published ports: in the development stack these are bound to # 127.0.0.1 so a developer can attach a client, which is reasonable there and # is an attack surface here. `ports: []` overrides the base list rather than # appending to it. # --------------------------------------------------------------------------- postgres: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD — the development default is a published literal} # The DML-only runtime role the services connect as, so the row-level # security policies actually apply to them. Separate from the owner # password above and rotated the same way. AISOC_APP_DB_PASSWORD: ${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD — the development default is a published literal} redis: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped zookeeper: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped kafka: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped qdrant: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped # --------------------------------------------------------------------------- # Application services. `ENVIRONMENT` and `AISOC_DEV_MODE` are literals on # purpose: an interpolated value here would let a stray `.env` put a # production deployment back into the environment where the auth bypass # lives, which is the defect this file exists to make impossible. # --------------------------------------------------------------------------- api: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: ENVIRONMENT: production AISOC_DEV_MODE: 0 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} AISOC_APP_DB_PASSWORD: ${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD} DATABASE_URL: postgresql+asyncpg://aisoc_app:${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD}@postgres:5432/aisoc DATABASE_MIGRATION_URL: postgresql+asyncpg://aisoc:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/aisoc REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379/0 # `make env` generates all of these. They are required rather than # defaulted because a service that starts without one either serves # unauthenticated or signs with a key published in this repository — # both were reported vulnerabilities, fixed in v12.0.0. SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY — run `make env` to generate it} AISOC_CREDENTIAL_KEY: ${AISOC_CREDENTIAL_KEY:?set AISOC_CREDENTIAL_KEY — run `make env` to generate it} AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN — run `make env` to generate it} AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN — run `make env` to generate it} # Verified by `alert_writeback.service_token_valid`, which reads this # and only this. Unset, every internal context route the agents service # calls refuses it, and triage silently runs without organisation # memory, tenant skills, dispositions, identity or runbooks. AISOC_AGENTS_SERVICE_TOKEN: ${AISOC_AGENTS_SERVICE_TOKEN:?set AISOC_AGENTS_SERVICE_TOKEN — run `make env` to generate it} AISOC_REALTIME_JWT_SECRET: ${AISOC_REALTIME_JWT_SECRET:?set AISOC_REALTIME_JWT_SECRET — run `make env` to generate it} REALTIME_INTERNAL_TOKEN: ${REALTIME_INTERNAL_TOKEN:?set REALTIME_INTERNAL_TOKEN — run `make env` to generate it} # The remaining two secrets `enforce_secure_defaults` hard-fails on, and # the reason this stack could not boot at all: `ENVIRONMENT: production` # above turns every entry in `warn_if_insecure_defaults` into a refusal, # and these two were the only ones no service block passed through. The # API crash-looped on # # InsecureProductionDefaultsError: Refusing to boot in production with # insecure defaults: # - METRICS_TOKEN is empty in a non-development environment # - JWT_SECRET is empty or set to the well-known placeholder # # while compose reported the stack up. Declaring them here is what makes # the refusal arrive the way the rest of this file's do — before anything # starts, naming the variable — rather than as a restart loop behind a # console that is already listening. # # `make env` deliberately does not generate METRICS_TOKEN: a token set # anywhere overrides the development exemption on /metrics, so writing # one into every `.env` would close the scrape endpoint on development # stacks whose Prometheus sends no credential. It is a production secret, # set deliberately here. METRICS_TOKEN: ${METRICS_TOKEN:?set METRICS_TOKEN — guards /metrics outside development; `openssl rand -hex 32`} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET — signs SAML/OIDC sessions; the same value ingest-worker takes below} ingest-worker: depends_on: preflight-secrets: condition: service_completed_successfully ports: !override ["${AISOC_BIND_ADDR:-127.0.0.1}:8081:8080"] restart: unless-stopped environment: # Both keys, because `envmode.Current()` reads ENV first and the base # file's ENV is what shadowed an operator's ENVIRONMENT before this. ENV: production ENVIRONMENT: production # Required outside development by # services/ingest/internal/config/config.go. This is the service that # accepts events off the network, so it is the last one that should be # allowed to start without a signing secret. JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET — ingest refuses to start without it outside development} AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN} AISOC_NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD} fusion: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: ENVIRONMENT: production AISOC_DEV_MODE: 0 AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?set CLICKHOUSE_PASSWORD} agents: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: ENVIRONMENT: production AISOC_DEV_MODE: 0 AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN} AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN} # The same value the api service declares above. Eight modules here # present it; the API verifies it without falling back. AISOC_AGENTS_SERVICE_TOKEN: ${AISOC_AGENTS_SERVICE_TOKEN:?set AISOC_AGENTS_SERVICE_TOKEN} NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD} actions: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: ENVIRONMENT: production AISOC_DEV_MODE: 0 # Without this the service answers 503 on every mutating route, which is # the intended failure introduced in v12.0.0 — it previously served # isolate_host and run_script to anything that could reach the port. AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN} connectors: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: ENVIRONMENT: production AISOC_DEV_MODE: 0 AISOC_CREDENTIAL_KEY: ${AISOC_CREDENTIAL_KEY:?set AISOC_CREDENTIAL_KEY} AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN} SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY} threatintel: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: ENVIRONMENT: production AISOC_DEV_MODE: 0 NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD} realtime: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: # Unset, the edge rejects every connection and /internal/* answers 503. # Both used to fall back: the ticket verifier to a constant committed to # this repository, and the internal guard to "authorized". AISOC_REALTIME_JWT_SECRET: ${AISOC_REALTIME_JWT_SECRET:?set AISOC_REALTIME_JWT_SECRET — run `make env` to generate it} REALTIME_INTERNAL_TOKEN: ${REALTIME_INTERNAL_TOKEN:?set REALTIME_INTERNAL_TOKEN — run `make env` to generate it} litellm: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped ollama: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped # Declared here only to take the preflight dependency. It is the one service # the base file defines that this overlay otherwise has nothing to say about, # and a single service that starts ahead of the credential check is the hole # the check exists to close. ollama-pull: depends_on: preflight-secrets: condition: service_completed_successfully # The MCP server (depth 9.1), a `--profile mcp` opt-in that is off by # default. It publishes 8096 in the base file for local clients; in # production it is unpublished like every other upstream, because the # console does not proxy it and an operator who wants an external MCP # endpoint exposes it deliberately. It authorizes every call from the # credential in the request (no static key — `check_mcp_over_http.py` # enforces that), so the credential check still gates its start. mcp: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped # The console. Demo mode is inlined at build time by Next, so this asserts # the runtime side only; pull the `latest` tag rather than a `-demo` one. web: depends_on: preflight-secrets: condition: service_completed_successfully restart: unless-stopped environment: NODE_ENV: production AISOC_DEMO_MODE: "false" # --------------------------------------------------------------------------- # `full`-profile stores. They do not run in a CORE deployment, but the base # file gives every service a default that points at the published dev # credential, so `--profile full` would start Neo4j and ClickHouse on a # password anyone can read. Required here rather than defaulted, and required # on the services that dial them so the two cannot disagree. # --------------------------------------------------------------------------- neo4j: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?set NEO4J_PASSWORD — required for the full profile} clickhouse: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?set CLICKHOUSE_PASSWORD — required for the full profile} opensearch: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped # Kafka UI browses every topic with no authentication of its own. It is # useful while developing and is not something to run beside production data, # so it is moved onto a profile nothing here activates: `--profile full` no # longer starts it. An operator who wants it can still ask for it by name. kafka-ui: depends_on: preflight-secrets: condition: service_completed_successfully profiles: !override ["debug-ui"] ports: !reset [] # `no`, not `unless-stopped`: this is a tool you bring up to look at # something and shut down again. Restarting it on boot would quietly leave # an unauthenticated topic browser running next to production data. restart: "no" # --------------------------------------------------------------------------- # HARDENING.md section 2 — transport and network surface. # # Profiled services that bind a host port in the development file. The # console and the ingest endpoint are the only things that need to be # reachable: apps/web/next.config.js proxies every upstream server-side, so # a browser only ever talks to `web`. Reach these with `docker compose exec` # for debugging, or publish one deliberately in a local override. # --------------------------------------------------------------------------- alertmanager: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped enrichment: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] honeytokens: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] osquery-tls: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] otel-collector: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped prometheus: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped purple-team: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] slack-bot: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] teams-bot: depends_on: preflight-secrets: condition: service_completed_successfully # Same treatment as its sibling: the base file publishes a port for # local work, and in production only the console and ingest are # reachable from the host. `!reset` rather than an empty list, because # a plain override appends. ports: !reset [] tempo: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped ueba: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] # HARDENING.md section 5 — observability and audit. # # Grafana defaults to admin/admin in the development file. Required here, so # `--profile monitoring` cannot start a dashboard over production telemetry # on a password printed in its own documentation. grafana: depends_on: preflight-secrets: condition: service_completed_successfully ports: !reset [] restart: unless-stopped environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD — required for the monitoring profile}