# syntax=docker/dockerfile:1 # ───────────────────────────────────────────────────────────────────────────── # soc-ai — multi-stage build # # Stage 1 (builder): installs uv, creates a venv, and syncs production deps. # Stage 2 (frontend): builds the React SPA bundle (frontend/dist). # Stage 3 (runtime): copies the venv + source + SPA into a slim Python 3.12 # image and runs as a non-root user. # # Nothing secret (no .env, no certs, no data) is baked into the image. # All mutable state is injected at runtime via bind-mounts and volumes: # - /opt/soc-ai/.env (read-only bind from the host) # - /etc/soc-ai/cert.pem (read-only bind) # - /etc/soc-ai/key.pem (read-only bind) # - /var/lib/soc-ai/data (named volume — SQLite DB lives here) # - /var/lib/soc-ai/blocklists (named volume — optional, survives restarts) # - /var/lib/soc-ai/maxmind (named volume — optional GeoIP .mmdb files) # - /var/lib/soc-ai/cloud_prefixes (named volume — optional cloud prefix JSON) # - /var/lib/soc-ai/evals (named volume — nightly quality-eval artifacts) # The /var/lib/soc-ai/* directories are created HERE, owned by uid 1000, so a # named volume mounted over one inherits that ownership; a volume mounted at a # path the image never created would be root-owned and unwritable. # ───────────────────────────────────────────────────────────────────────────── # ── Stage 1: dependency build ───────────────────────────────────────────────── FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim@sha256:e5b65587bce7de595f299855d7385fe7fca39b8a74baa261ba1b7147afa78e58 AS builder # ^ pinned by digest (supply-chain hardening, mirrors the SHA-pinned GitHub # Actions in release.yml); tag above is for humans only — bump both # together when refreshing. Resolve a new digest with: # docker buildx imagetools inspect ghcr.io/astral-sh/uv:python3.12-bookworm-slim WORKDIR /build # Copy only the files uv needs to resolve and lock deps. # uv sync --no-install-project means it installs deps but not the project # package itself; we copy the full source in the runtime stage. COPY pyproject.toml uv.lock ./ # Sync production deps (no dev extras) into /build/.venv. # --frozen: honour the lock file exactly (no re-resolution). # --no-install-project: don't attempt to install the not-yet-copied source. # --no-dev: skip [dependency-groups.dev]. # --extra postgres: asyncpg, for a store in PostgreSQL (SOC_AI_DATABASE_URL and # the compose "postgres" profile). SQLite stays the default and needs nothing. # --extra ml: scikit-learn and numpy, for the tier 3 estate model # (estate_model_enabled, off by default). About 220 MB of site-packages on # 2026-10-04. The app imports them only when the estate model runs. RUN uv sync --frozen --no-install-project --no-dev --extra postgres --extra ml # ── Stage 2: frontend build (React SPA → /fe/dist) ──────────────────────────── # Built here and copied into the runtime image at /opt/soc-ai/frontend/dist — # where main.py's FRONTEND_DIST resolves, so FastAPI serves the SPA at /app. FROM node:22-bookworm-slim@sha256:6c74791e557ce11fc957704f6d4fe134a7bc8d6f5ca4403205b2966bd488f6b3 AS frontend # ^ digest-pinned (see the builder stage's FROM comment above); tag: 22-bookworm-slim. WORKDIR /fe # Install against the lockfile first (cached unless package*.json changes). COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci # Build the production bundle (tsc -b && vite build → /fe/dist). COPY frontend/ ./ RUN npm run build # ── Stage 3: runtime ───────────────────────────────────────────────────────── FROM python:3.12-slim@sha256:a0f57064cbdcd6285fa45f51237ab0b35ea8ebaed06a1f5973e89e19dfb86704 AS runtime # ^ digest-pinned (see the builder stage's FROM comment above); tag: 3.12-slim. # ── OS-level deps ───────────────────────────────────────────────────────────── # curl: HEALTHCHECK; ca-certificates: trust chain for outbound HTTPS; # openssh-client: the PCAP tool (soc_ai/tools/get_pcap.py) shells out to `ssh` to # run tcpdump on the Security Onion sensor — without it, t_get_pcap fails with # "ssh: not found" (the host having ssh masked this on non-container installs). RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates openssh-client \ && rm -rf /var/lib/apt/lists/* # ── Non-root user ───────────────────────────────────────────────────────────── RUN groupadd --gid 1000 soc-ai \ && useradd --uid 1000 --gid soc-ai --no-create-home --shell /sbin/nologin soc-ai # ── Directory layout ────────────────────────────────────────────────────────── # /opt/soc-ai — application source # /var/lib/soc-ai/... — runtime-writable data (mounted as volumes) # /etc/soc-ai — TLS cert + key (mounted read-only by the host) RUN mkdir -p \ /opt/soc-ai \ /var/lib/soc-ai/data \ /var/lib/soc-ai/blocklists \ /var/lib/soc-ai/maxmind \ /var/lib/soc-ai/cloud_prefixes \ /var/lib/soc-ai/evals \ /etc/soc-ai \ && chown -R soc-ai:soc-ai \ /opt/soc-ai \ /var/lib/soc-ai \ /etc/soc-ai # ── Copy venv from builder ───────────────────────────────────────────────────── COPY --from=builder --chown=soc-ai:soc-ai /build/.venv /opt/soc-ai/.venv # ── Copy application source ──────────────────────────────────────────────────── # soc_ai/ package (contains migrations in soc_ai/store/migrations/ and the # CLI-only soc_ai/store/alembic.ini — the app runs migrations programmatically # from Path(__file__), so the ini is only for the `alembic` CLI: # alembic -c soc_ai/store/alembic.ini upgrade head # pyproject.toml: read by some tooling; harmless to include. COPY --chown=soc-ai:soc-ai soc_ai/ /opt/soc-ai/soc_ai/ COPY --chown=soc-ai:soc-ai pyproject.toml /opt/soc-ai/pyproject.toml # runbooks/: the shipped starter-pack markdown (runbooks/starter-pack/*.md). # POST /runbooks/starter-pack resolves it as parent-of-package (soc_ai/store/ # runbook_pack.py:STARTER_PACK_DIR → /opt/soc-ai/runbooks) — same layout trick # as the frontend bundle below. Without this COPY the endpoint 404s honestly. COPY --chown=soc-ai:soc-ai runbooks/ /opt/soc-ai/runbooks/ # docs/: not documentation for the operator, prompt input. The agent's system # prompts are assembled from docs/OQL_PRIMER.md and docs/OQL_HUNT_EXAMPLES.md, # resolved as parent-of-package (soc_ai/agent/prompts.py:PROMPT_ASSETS → # /opt/soc-ai/docs), the same layout trick as runbooks/ above. Without this COPY # the container starts and answers, but every investigator, hunt and chat prompt # carries a stub saying the query language is unavailable, which is what shipped # until 2026-09-07. Copied WHOLESALE, like soc_ai/ and runbooks/: naming the two # files individually is exactly the mistake that produced the defect, because the # next asset added under docs/ would silently not ship. The heavy published-only # subtrees (docs/img, docs/superpowers) are excluded in .dockerignore, and # tests/test_image_layout.py fails if any declared asset stops resolving here. COPY --chown=soc-ai:soc-ai docs/ /opt/soc-ai/docs/ # Demo packaging (inert in normal deployments — the default CMD below never # touches it). mock_es.py + its demo_dataset import are the bundled loopback # Elasticsearch/LLM stand-in that docker/demo-entrypoint.sh starts on # 127.0.0.1:9200; ONLY these two files are un-ignored in .dockerignore — the # rest of scripts/ never ships in the image. The entrypoint itself is selected # explicitly by docker-compose.demo.yml / render.yaml. COPY --chown=soc-ai:soc-ai scripts/demo/mock_es.py scripts/demo/demo_dataset.py \ /opt/soc-ai/scripts/demo/ COPY --chmod=0755 --chown=soc-ai:soc-ai docker/demo-entrypoint.sh \ /opt/soc-ai/docker/demo-entrypoint.sh # ── Copy the built React SPA ─────────────────────────────────────────────────── # FastAPI serves this at /app (mounted only when the dir exists). Without it the # app still boots and the JSON API works, but /app 404s. COPY --from=frontend --chown=soc-ai:soc-ai /fe/dist /opt/soc-ai/frontend/dist WORKDIR /opt/soc-ai # ── Build stamp ─────────────────────────────────────────────────────────────── # The commit this image was built from, baked in so the running process can name # it. It cannot work this out for itself: .dockerignore excludes .git, and every # build of one release carries the same version string while the deployments run # the image as :latest — so without this, "quality dropped after Tuesday" has # nothing to attribute itself to. Empty when the builder did not pass one, which # soc_ai/__init__.py reads back as None rather than as a guess. ARG SOC_AI_COMMIT="" # ── Environment ─────────────────────────────────────────────────────────────── # PATH: venv bin dir takes precedence over system Python. # PYTHONPATH: makes soc_ai importable without an editable install — hatchling # (the build backend) is not a runtime dep, so we skip pip install entirely. # pydantic-settings reads .env relative to cwd (/opt/soc-ai) — WORKDIR above. # TLS + data-dir defaults match the compose volume mount paths; all can be # overridden in .env without rebuilding the image. ENV SOC_AI_COMMIT="${SOC_AI_COMMIT}" \ PATH="/opt/soc-ai/.venv/bin:$PATH" \ PYTHONPATH="/opt/soc-ai" \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ SOC_AI_HOST="0.0.0.0" \ SOC_AI_PORT="8443" \ SOC_AI_TLS_CERT="/etc/soc-ai/cert.pem" \ SOC_AI_TLS_KEY="/etc/soc-ai/key.pem" \ SOC_AI_DATA_DIR="/var/lib/soc-ai/data" \ BLOCKLIST_DATA_DIR="/var/lib/soc-ai/blocklists" \ MAXMIND_DATA_DIR="/var/lib/soc-ai/maxmind" \ CLOUD_PREFIX_DATA_DIR="/var/lib/soc-ai/cloud_prefixes" \ HOME="/var/lib/soc-ai" # Console entry point. Stage 1's `uv sync --no-install-project` installs deps but # NOT the project, so pyproject's [project.scripts] `soc-ai` is never generated in # the venv — yet the docs and operator muscle-memory expect `soc-ai ` (backup, # restore, blocklists refresh, audit verify, doctor) to work in-container (e.g. # `docker exec soc-ai soc-ai backup`). Ship a tiny wrapper on PATH that runs the # CLI through the venv python; PYTHONPATH (set above) makes soc_ai importable. RUN printf '#!/opt/soc-ai/.venv/bin/python\nimport sys\nfrom soc_ai.cli import main\nsys.exit(main())\n' \ > /usr/local/bin/soc-ai \ && chmod 0755 /usr/local/bin/soc-ai USER soc-ai EXPOSE 8443 # ── Liveness check ──────────────────────────────────────────────────────────── # This is a LIVENESS probe, and the word `healthy` that `docker ps` prints from # it means only that this process answered. /healthz probes no dependency: it # stays green with an unreachable grid, a dead model gateway and no analyst # model at all, and it has to, because a probe that failed on a dependency # outage would have Docker restart a container whose dependencies are merely # down. The health verdict is `GET /api/v1/health` (live per-dependency probes) # or `docker compose exec soc-ai python -m soc_ai doctor`; the response body # says so too, so it reads correctly in `docker inspect`'s health log. # # /healthz answers over HTTPS on the direct path and over HTTP on the proxy # path. The check tries HTTPS first, with -k to skip verify, then HTTP. # Interval is generous (30s) so a slow cold-start (DB migration + bootstrap) # doesn't flip the container unhealthy before the server is ready. HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -fsk https://127.0.0.1:8443/healthz || curl -fs http://127.0.0.1:8443/healthz || exit 1 # ── Default command ─────────────────────────────────────────────────────────── # Invoked as `python -m uvicorn`, NOT the `uvicorn` console script: the venv is # built at /build/.venv (Stage 1) and copied to /opt/soc-ai/.venv, so the # script's baked-in shebang (#!/build/.venv/bin/python) is dead — but the venv # python + the uvicorn module are both fine. Cert/key paths come from ENV above. # soc-ai applies its own forwarded-header trust rule (PROXY_TRUSTED_IPS). CMD ["sh", "-c", \ "exec python -m uvicorn soc_ai.main:app \ --host 0.0.0.0 \ --port 8443 \ --no-proxy-headers \ --ssl-certfile \"${SOC_AI_TLS_CERT}\" \ --ssl-keyfile \"${SOC_AI_TLS_KEY}\" \ --ssl-ciphers 'ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM'"]