# Vendored from Talon-Control docker-compose.prod.yml — do not edit here.
#
# This is the file https://talonlabs.dev/control/download tells customers
# to fetch. Re-vendor it instead of editing it:
#
#   CONTROL_REPO=../Talon-Control pnpm vendor:control
#
# Comment references to docs/*.md point at the product's internal runbooks;
# the download page and /control/docs cover everything an install needs.
#
# upstream-commit: 6ef235ae9ccd5c7fb40d906c54488dbfda573588
# upstream-file:   docker-compose.prod.yml
# body-sha256:     c1d09a427784b92b7ad069958ec03d6b24894b7b9deddd24da678451a58593ef
# --- vendored content follows; everything below is a copy ---
# Production Talon Control: the published image, a private Postgres, and
# nothing exposed to the internet directly.
#
# Differs from the repo's dev compose (which builds locally for a quick try)
# in the ways that matter in production:
#   - Every image is PINNED BY DIGEST — the app from GHCR by tag, every
#     third-party sidecar by `tag@sha256:…`. A tag is a moving pointer; a
#     digest is the bytes, so the appliance you booted is the one you keep.
#   - Every secret is required. Compose refuses to start rather than defaulting
#     to a weak password, which is how a throwaway credential reaches production.
#   - The app binds to 127.0.0.1 only. A reverse proxy on the host is the sole
#     way in, so a misconfigured firewall cannot publish the console by accident.
#   - Postgres publishes no port at all — reach it with `docker compose exec`.
#
#   cp control.env.example .env && docker compose up -d
#
# The optional `search` profile also needs deploy/searxng/settings.yml,
# which this compose bind-mounts. It is served beside this file at
# https://talonlabs.dev/control/deploy/searxng/settings.yml — fetch it to
# ./deploy/searxng/settings.yml before turning that profile on.
#
# See docs/production-hosting.md for the full runbook, including backups and the
# rollback caveat around migrate-on-boot.

services:
  db:
    image: postgres:16-alpine@sha256:cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685
    restart: unless-stopped
    environment:
      POSTGRES_USER: talon
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: talon_control
    volumes:
      - talon-control-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U talon -d talon_control']
      interval: 5s
      timeout: 5s
      retries: 10
    # Without this a crash loop or a chatty query can fill the disk, which takes
    # Postgres down with it.
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # ONE instance, deliberately. Do not `docker compose up --scale app=2`, and do
  # not raise the replica count if this file is driven by Coolify: the login,
  # MFA-challenge and MCP-token throttles in lib/rate-limit.ts count in-process
  # with no shared store, so a second container hands every throttle its own
  # budget and the login brute-force ceiling multiplies by the container count
  # (docs/security-review-2026-08.md, F-3). Scheduled automations also
  # double-fire and the SSE bus is per-process, so a shared rate-limit store
  # alone would not make two safe. The Helm chart refuses to render above one
  # replica; compose has no equivalent hook, so this comment is the only warning
  # you get.
  app:
    image: ghcr.io/talon-labs-detection-platform/talon-control:${TALON_CONTROL_IMAGE_TAG:?set TALON_CONTROL_IMAGE_TAG in .env, e.g. sha-e6dcfc96b301}
    restart: unless-stopped
    # Loopback only. The host's reverse proxy (Caddy, cloudflared) is the entry
    # point; binding 0.0.0.0 here would put the console on the public internet
    # the moment a firewall rule slipped.
    ports:
      - '127.0.0.1:3001:3001'
    depends_on:
      db:
        condition: service_healthy
    environment:
      TALON_CONTROL_POSTGRES_URL: postgresql://talon:${POSTGRES_PASSWORD}@db:5432/talon_control
      TALON_CONTROL_SIGNING_KEY_PATH: /secrets/license-signing-private.pem
      AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET in .env}
      TALON_CONTROL_ENCRYPTION_KEY: ${TALON_CONTROL_ENCRYPTION_KEY:?set TALON_CONTROL_ENCRYPTION_KEY in .env}
      # The public origin. Auth breaks behind a proxy if this is wrong, and
      # Studio-style https detection keys off the scheme.
      NEXTAUTH_URL: ${NEXTAUTH_URL:?set NEXTAUTH_URL in .env, e.g. https://control.your-domain.com}
      # The documented production entry is a reverse proxy (cloudflared, Caddy)
      # on the host, so EVERY request arrives from 127.0.0.1. Without this the
      # app refuses forwarded headers by design — which means per-IP rate
      # limiting collapses to one shared bucket for the whole internet (a
      # lockout DoS on yourself), and the traffic subsystem can never compute a
      # visitor hash, so unique visitors and geo read zero forever with only the
      # visitors card admitting it.
      #
      # It has to be declared HERE. Compose only passes variables a service
      # names, so setting it in .env did nothing — which is why the Helm chart
      # models both of these and this file forgot them.
      TALON_TRUST_PROXY: ${TALON_TRUST_PROXY:-true}
      # The marketing site's origin: the only CORS origin the public traffic
      # collector accepts, and the base the site's beacon posts to.
      TALON_LABS_URL: ${TALON_LABS_URL:-}
      AUTH_TRUST_HOST: 'true'
      LOG_FORMAT: json
      # The local speech models (Kokoro ~90MB, Whisper ~40MB) are pulled from
      # Hugging Face on first use. Cached here — on the named volume below —
      # rather than in the container's writable layer, where every recreate
      # threw them away and the next spoken reply re-downloaded ~130MB, talking
      # in the browser voice until it landed. Deliberately outside /app so it
      # can never shadow anything the build wrote.
      TALON_CONTROL_SPEECH_CACHE_DIR: /var/cache/talon/models
      # @huggingface/transformers reads neither of these — its only lever is the
      # variable above, which the app applies for it. They are set to the same
      # root so any Hugging Face tooling that DOES read them lands on the volume
      # instead of back in the image layer.
      HF_HOME: /var/cache/talon/models
      TRANSFORMERS_CACHE: /var/cache/talon/models
      # Dev Sessions (Claude Code engine). The OAuth token from `claude
      # setup-token` bills the Claude subscription; both state dirs live on the
      # data volume so session resume survives a container recreate.
      CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-}
      CLAUDE_CONFIG_DIR: /app/data/claude
      # Same shape for the Codex engine — see docker-compose.yml's comment.
      CODEX_ACCESS_TOKEN: ${CODEX_ACCESS_TOKEN:-}
      CODEX_HOME: /app/data/codex
      TALON_DEV_SESSIONS_DIR: /app/data/dev-sessions
      # Where *your* conversations run, when that should not be where the fleet
      # runs. The fleet can sit on the local model all day because nobody is
      # waiting on it; the assistant panel and the stand-up are you, waiting,
      # with the mic open — and a small model answers slowly and writes spoken
      # prose in one unpunctuated breath. Leave these unset and everything uses
      # the connection saved on Settings → AI, exactly as before. Changing
      # provider needs a model named with it — a model name means nothing to a
      # provider it wasn't written for, and a half-specified switch is ignored
      # rather than allowed to fail mid-conversation. The exception is
      # 'claude-code' — the dev-session engine has a default model of its own,
      # so TALON_CONTROL_AI_CHAT_PROVIDER=claude-code alone is enough to run
      # your conversations on the Claude subscription (the OAuth token above),
      # while the fleet stays wherever Settings → AI points it.
      TALON_CONTROL_AI_CHAT_PROVIDER: ${TALON_CONTROL_AI_CHAT_PROVIDER:-}
      TALON_CONTROL_AI_CHAT_MODEL: ${TALON_CONTROL_AI_CHAT_MODEL:-}
      TALON_CONTROL_AI_CHAT_API_KEY: ${TALON_CONTROL_AI_CHAT_API_KEY:-}
      TALON_CONTROL_AI_CHAT_URL: ${TALON_CONTROL_AI_CHAT_URL:-}
      # Semantic recall for the Company Brain. Full-text search matches WORDS,
      # so a question about a refund policy misses a note titled "Credit notes
      # and returns" — and that gets worse as the Brain fills, because more
      # notes mean more incidental word overlap. Embeddings match meaning
      # instead, and improve with more content.
      #
      # Off unless BOTH the URL and the model are set. With them unset, Brain
      # search is exactly the full-text ranking it has always been: nothing
      # degrades, nothing warns. Any OpenAI-compatible /v1/embeddings endpoint
      # works — a local Ollama, or a hosted provider.
      #
      # Existing notes are embedded by a background sweep at twenty a minute,
      # so switching this on is retroactive and changing the model later is the
      # same operation: vectors are keyed by the model that made them, so the
      # new one simply has no vectors yet and the sweep fills them in.
      #
      # Declared HERE for the reason the TALON_TRUST_PROXY comment above gives:
      # compose only passes variables a service names, so setting these in .env
      # alone would silently do nothing.
      TALON_CONTROL_EMBEDDING_URL: ${TALON_CONTROL_EMBEDDING_URL:-}
      TALON_CONTROL_EMBEDDING_MODEL: ${TALON_CONTROL_EMBEDDING_MODEL:-}
      TALON_CONTROL_EMBEDDING_API_KEY: ${TALON_CONTROL_EMBEDDING_API_KEY:-}
      TALON_CONTROL_EMBEDDING_INSECURE_TLS: ${TALON_CONTROL_EMBEDDING_INSECURE_TLS:-}
      # The in-process fallback, on by default. Set to false on a box that
      # cannot spare the memory or cannot fetch the weights.
      TALON_CONTROL_EMBEDDING_LOCAL: ${TALON_CONTROL_EMBEDDING_LOCAL:-}
      TALON_CONTROL_EMBEDDING_LOCAL_MODEL: ${TALON_CONTROL_EMBEDDING_LOCAL_MODEL:-}
      # Backstops on the two endpoints that spend money per model call, per
      # ACCOUNT rather than per IP. Only a runaway client can reach the
      # defaults (60 chat turns and 20 agent runs a minute); raise them if a
      # real workload ever does.
      TALON_CHAT_RATE_LIMIT_PER_MINUTE: ${TALON_CHAT_RATE_LIMIT_PER_MINUTE:-}
      TALON_AGENT_RUN_RATE_LIMIT_PER_MINUTE: ${TALON_AGENT_RUN_RATE_LIMIT_PER_MINUTE:-}
      # How many tool round-trips one conversation turn may take, and how long
      # it may run before it has to start finishing. Defaults are 12 steps and
      # four minutes; the turn always ends with a written answer either way.
      TALON_CHAT_STEP_BUDGET: ${TALON_CHAT_STEP_BUDGET:-}
      TALON_CHAT_TURN_TIMEOUT_MS: ${TALON_CHAT_TURN_TIMEOUT_MS:-}
      # How much context a local model server is given per call. Ollama's
      # default is a few thousand tokens, and a prompt past it is not rejected —
      # the front is dropped, and the front is the system prompt. Raising it
      # costs memory per loaded model. Setting OLLAMA_CONTEXT_LENGTH on the
      # Ollama server itself is the version no client can undo.
      # Note that Ollama allocates num_ctx × OLLAMA_NUM_PARALLEL: check that on
      # the model server before raising this, or the KV cache outgrows the box
      # and layers get offloaded, which on a CPU-only appliance is far more
      # expensive than the window is worth.
      TALON_CONTROL_OLLAMA_NUM_CTX: ${TALON_CONTROL_OLLAMA_NUM_CTX:-16384}
      # How long one model call may take before its socket is cut. Node's own
      # default is 300s — below what a local model legitimately takes here, and
      # it fails as "Invalid JSON response" rather than as a timeout.
      TALON_CONTROL_MODEL_TIMEOUT_MS: ${TALON_CONTROL_MODEL_TIMEOUT_MS:-900000}
      # Hard wall-clock stop for one agent run, under the 30-minute reaper so a
      # slow run fails as itself rather than being reaped as a dead process.
      TALON_CONTROL_RUN_TIMEOUT_MS: ${TALON_CONTROL_RUN_TIMEOUT_MS:-1500000}
      # Spoken replies, when the optional `voice` profile below is running.
      # Empty by default, which keeps the in-process engine and changes
      # nothing for a deploy that has not opted in.
      TALON_CONTROL_TTS_URL: ${TALON_CONTROL_TTS_URL:-http://kokoro:8880}
      # First hop of the Research agent's webSearch chain, when the optional
      # searxng service below is running. Empty by default so the provider is
      # skipped rather than tried-and-failed — pointing this at a container
      # that was never started costs every search a wasted hop.
      TALON_CONTROL_SEARXNG_URL: ${TALON_CONTROL_SEARXNG_URL:-}
      # The meeting bot's gateway, when the optional `meetings` profile below
      # is running. Derived rather than typed: setting VEXA_ADMIN_TOKEN (which
      # the profile needs anyway) points the app at the sidecar's in-network
      # address and hands it the same token, so Connections → Vexa reads
      # "bundled with this deployment" with nothing pasted — the SearXNG
      # arrangement, one step further. The admin token rather than a shared
      # key because Vexa Lite registers no key it is handed: the app mints its
      # own bot key through the admin API, the way Vexa's bring-up does, and
      # saves it. Everything stays empty while the token is unset, so a
      # deployment without the profile skips the bot sweep rather than trying
      # a container that was never started. Set TALON_CONTROL_VEXA_URL
      # yourself for a gateway elsewhere; a value saved in Connections still
      # wins over any of these. VEXA_API_KEY is for a key you minted yourself.
      TALON_CONTROL_VEXA_URL: ${TALON_CONTROL_VEXA_URL:-${VEXA_ADMIN_TOKEN:+http://vexa:8056}}
      # Where the admin API answers — its own service, not the gateway, and in
      # the Lite image bound to the gateway container's loopback. The
      # `vexa-admin` proxy below shares that container's network namespace, so
      # the address is the gateway's host with the admin port.
      TALON_CONTROL_VEXA_ADMIN_URL: ${TALON_CONTROL_VEXA_ADMIN_URL:-${VEXA_ADMIN_TOKEN:+http://vexa:18057}}
      TALON_CONTROL_VEXA_ADMIN_TOKEN: ${VEXA_ADMIN_TOKEN:-}
      TALON_CONTROL_VEXA_API_KEY: ${VEXA_API_KEY:-}
    volumes:
      # The Ed25519 licence-signing key. Read-only: nothing in the app writes it,
      # and minting is disabled outright when it is absent.
      - ./secrets:/secrets:ro
      - talon-control-data:/app/data
      # The speech models. `docker compose down` keeps this volume; `down -v`
      # deletes it, and the first spoken reply after that re-downloads ~130MB
      # from Hugging Face with the browser voice standing in meanwhile.
      - talon-control-model-cache:/var/cache/talon/models
    healthcheck:
      # /ready checks Postgres too, so an unhealthy app means "cannot serve",
      # not merely "process alive".
      test:
        [
          'CMD',
          'node',
          '-e',
          "fetch('http://127.0.0.1:3001/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 60s
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # Optional self-hosted web search for the Research agent. Starts only with
  # the `search` profile active:
  #
  #   COMPOSE_PROFILES=search
  #   SEARXNG_SECRET=<openssl rand -hex 32>
  #   TALON_CONTROL_SEARXNG_URL=http://searxng:8080
  #
  # SearXNG ships no versioned tags, only `latest` — so the digest IS the
  # version here, and without it "the same deployment" means different bytes
  # on every host that pulled on a different day. To take an upgrade,
  # replace the digest below with the one you want and read the changelog
  # first: the pin is what makes that a decision rather than a surprise.
  searxng:
    profiles: ['search']
    image: docker.io/searxng/searxng:latest@sha256:8486daaebc65adacfe434be38b991cf90da92d3fd80ae9f0ab1409ba65664e28
    restart: unless-stopped
    environment:
      SEARXNG_SECRET: ${SEARXNG_SECRET:?set SEARXNG_SECRET in .env (openssl rand -hex 32)}
      SEARXNG_BASE_URL: http://searxng:8080/
    volumes:
      - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro
    # Publishes nothing, on a file whose whole posture is "nothing exposed
    # directly". The app reaches it in-network at http://searxng:8080, and its
    # bot limiter is off (settings.yml) precisely because it is unreachable
    # from anywhere else.
    # Probes the search page, not a dedicated health endpoint — the root path
    # is the one URL a working SearXNG certainly answers, and nothing here
    # depends on this container being *healthy* to start, so a false negative
    # is cosmetic rather than an outage.
    healthcheck:
      test:
        [
          'CMD',
          'wget',
          '-qO-',
          'http://127.0.0.1:8080/',
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # The voice, as a container rather than as code inside the app.
  #
  # Same model the app has always spoken with (Kokoro-82M), same voice names,
  # served by an upstream image. The reason it is out here is a licence:
  # kokoro-js reaches eSpeak NG (GPL-3.0-or-later) through `phonemizer`, and
  # linking that into a proprietary server we ship is the one dependency whose
  # copyleft can argue its way into Control's own source
  # (docs/third-party-licence-review.md §4.1). Pulled from upstream by your box
  # rather than built by us, we convey none of it — the arrangement SearXNG and
  # Vexa already have.
  #
  #   COMPOSE_PROFILES=voice
  #   TALON_CONTROL_TTS_URL=http://kokoro:8880
  #
  # Weights are baked into the image, so a recreate costs nothing and there is
  # no volume to keep. Publishes no port: the app reaches it in-network.
  #
  # No longer opt-in: this IS the speech path. `kokoro-js` has left the image,
  # so there is no in-process engine to fall back to — with this service down
  # or TALON_CONTROL_TTS_URL unset, spoken replies are unavailable and the
  # client uses the browser's built-in voice.
  kokoro:
    image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.8.1@sha256:28d6f0b6e4df369559012578299d201b855a08fac466f616653edd1f08c5370a
    restart: unless-stopped
    # No volume over /app/api/src/models. The image ships its weights there,
    # so mounting anything on top hides them and the container exits at
    # startup with "Model files not found!". Nothing needs persisting: there
    # is no download for a restart to repeat.
    healthcheck:
      test: ['CMD', 'python', '-c', "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8880/health')"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 120s
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # Optional meeting bot — the agent that joins a Meet, Zoom or Teams call and
  # transcribes it, for docs/meeting-notes-plan.md M-4. Vexa (Apache-2.0),
  # in its single-container "lite" form, plus the two datastores it needs and
  # a CPU Whisper beside it so the audio never leaves this host. Starts only
  # with the `meetings` profile active:
  #
  #   COMPOSE_PROFILES=meetings          # (or `search,meetings`)
  #   VEXA_ADMIN_TOKEN=<openssl rand -hex 32>
  #   VEXA_BOT_NAME="Talon notes (recording)"   # what every attendee sees join
  #
  # and that is the whole setup: the app service above derives its
  # TALON_CONTROL_VEXA_URL from the token being set, mints its own bot key
  # through the gateway's admin API and saves it, so Connections → Vexa shows
  # "bundled with this deployment" with nothing pasted — the SearXNG
  # arrangement. (Vexa Lite registers no key it is handed as VEXA_API_KEY; it
  # only skips minting the one its terminal signs in with.) Nothing here
  # publishes a port: the app reaches the gateway in-network, and the bot
  # reaches the meeting platforms outbound.
  #
  # Two things are decided here rather than in the app, on purpose:
  #   - RECORDING_ENABLED=false — the bot keeps a transcript, never the audio.
  #     A transcript is already a new category of personal data to register;
  #     a recording would be a second one with nothing to justify it.
  #   - Lite shares one X display between bots, so it is honest for ONE call
  #     at a time. A second concurrent meeting queues behind the first; the
  #     full Vexa stack (container per bot) is the upgrade when that bites.
  vexa:
    profiles: ['meetings']
    image: docker.io/vexaai/vexa-lite:v012@sha256:5d0b6f865afe726109bb326361b917a3d9f762d64abbeea0826744134162d051
    restart: unless-stopped
    # Chromium for the bot's browser session lives in /dev/shm; the default
    # 64MB crashes it mid-call.
    shm_size: '2g'
    depends_on:
      vexa-db:
        condition: service_started
      vexa-minio:
        condition: service_started
      vexa-whisper:
        condition: service_started
    environment:
      # No VEXA_API_KEY: handed to the sidecar it would only stop it minting
      # the key its own terminal signs in with, and it registers nothing the
      # app could use. The app mints its bot key through the admin API instead.
      ADMIN_TOKEN: ${VEXA_ADMIN_TOKEN:-}
      ADMIN_API_TOKEN: ${VEXA_ADMIN_TOKEN:-}
      # "(recording)" is deliberate: the participant list is the only
      # announcement the bot can make — Vexa 0.12 seals speak and chat-write
      # without serving them. Rename it with VEXA_BOT_NAME below.
      DEFAULT_BOT_NAME: ${VEXA_BOT_NAME:-Notes (recording)}
      DB_HOST: vexa-db
      DB_PORT: '5432'
      DB_NAME: vexa
      DB_USER: vexa
      DB_PASSWORD: ${VEXA_DB_PASSWORD:-vexa}
      # Vexa's object store, by address rather than by identity. The default
      # is the bundled MinIO below — which is the one dependency here we would
      # rather not ship: AGPL-3.0, a vendor selling a commercial licence aimed
      # at exactly this use, and a repository archived in early 2026
      # (docs/third-party-licence-review.md §2.5). Nothing in Control talks to
      # it; Vexa wants an S3 endpoint and that is the whole of it.
      #
      # Point these at any S3-compatible server to get out from under that —
      # SeaweedFS and versitygw are both Apache-2.0 — and drop the `minio`
      # service. The chart has this as a proper switch
      # (`vexa.objectStore.backend`); compose has no conditionals, so here it
      # is three variables and deleting a service.
      MINIO_ENDPOINT: ${VEXA_S3_ENDPOINT:-vexa-minio:9000}
      MINIO_ACCESS_KEY: ${VEXA_S3_ACCESS_KEY:-vexa-access-key}
      MINIO_SECRET_KEY: ${VEXA_S3_SECRET_KEY:-${VEXA_MINIO_SECRET:-vexa-secret-key}}
      MINIO_BUCKET: vexa
      MINIO_SECURE: 'false'
      # Transcription stays on this host: the faster-whisper sidecar below
      # speaks the OpenAI-compatible /v1/audio/transcriptions shape Vexa
      # expects. The token is required non-empty and checked by nobody.
      TRANSCRIPTION_SERVICE_URL: http://vexa-whisper:8000/v1/audio/transcriptions
      TRANSCRIPTION_SERVICE_TOKEN: local
      TRANSCRIPTION_MODEL: ${VEXA_WHISPER_MODEL:-Systran/faster-whisper-small.en}
      TRANSCRIBE_ENABLED: 'true'
      RECORDING_ENABLED: 'false'
      # Fifteen minutes in a lobby before the bot gives up — long enough for a
      # late host, short enough that a forgotten meeting does not hold the
      # one display for an hour.
      VEXA_LOBBY_BUDGET_S: '900'
      # Ten minutes of nobody speaking and the bot leaves on its own. This is
      # what should end a call everyone has walked out of, and it is stated
      # here rather than inherited so it is a decision you can find: a bot sat
      # in an instant Meet after the last person left, and the app's own
      # ceiling (lib/services/meeting-bot.ts) is only the backstop.
      BOT_ALONE_SILENCE_WINDOW_MS: ${VEXA_ALONE_SILENCE_MS:-600000}
      VEXA_PUBLIC_API_URL: http://vexa:8056
      TERMINAL_PUBLIC_URL: http://vexa:3001
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:8056/health']
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 120s
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # The admin API's way out of the container — the service that mints bot
  # keys. Vexa binds it to 127.0.0.1:8001 inside the gateway container and the
  # gateway does not forward /admin, so until this existed nothing could reach
  # it and the app's mint answered 404 on every call. `network_mode:
  # service:vexa` puts this container in the gateway's network namespace,
  # which is the only way to reach that loopback; the port then answers on
  # `vexa` itself. It forwards bytes and nothing else — the admin API still
  # checks the admin token, and no port is published to the host. One
  # operational wrinkle of sharing a namespace: recreating `vexa` invalidates
  # this container's network, so restart it alongside — `docker compose up -d`
  # does, and `docker compose restart vexa` on its own does not.
  vexa-admin:
    profiles: ['meetings']
    image: docker.io/alpine/socat:1.8.1.3@sha256:5f275aa1b6e9889c851f61097142ee050fc6ac4615b4ea64ac1f2b0e81ff8d7f
    restart: unless-stopped
    network_mode: 'service:vexa'
    depends_on:
      vexa:
        condition: service_started
    command: ['TCP-LISTEN:18057,fork,reuseaddr', 'TCP:127.0.0.1:8001']
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  vexa-db:
    profiles: ['meetings']
    image: postgres:17-alpine@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73
    restart: unless-stopped
    command: ['postgres', '-c', 'idle_in_transaction_session_timeout=60000']
    environment:
      POSTGRES_DB: vexa
      POSTGRES_USER: vexa
      POSTGRES_PASSWORD: ${VEXA_DB_PASSWORD:-vexa}
      TZ: UTC
      PGTZ: UTC
    volumes:
      - vexa-pgdata:/var/lib/postgresql/data
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # The default object store, and the one to replace — see the MINIO_* block
  # on the `vexa` service above for why and how.
  vexa-minio:
    profiles: ['meetings']
    image: docker.io/minio/minio:latest@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
    restart: unless-stopped
    command: ['server', '/data']
    environment:
      MINIO_ROOT_USER: vexa-access-key
      MINIO_ROOT_PASSWORD: ${VEXA_MINIO_SECRET:-vexa-secret-key}
    volumes:
      - vexa-miniodata:/data
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

  # CPU Whisper for the bot. small.en is the honest default for post-call
  # notes on a box without a GPU; VEXA_WHISPER_MODEL=Systran/faster-whisper-
  # medium.en hears better and costs ~3x. Not the app's own whisper-base.en:
  # that one is loaded in-process for 60-second voice notes, and a 45-minute
  # call is a different workload that belongs in its own container.
  vexa-whisper:
    profiles: ['meetings']
    image: docker.io/fedirz/faster-whisper-server:latest-cpu@sha256:760e5e43d427dc6cfbbc4731934b908b7de9c7e6d5309c6a1f0c8c923a5b6030
    restart: unless-stopped
    environment:
      WHISPER__MODEL: ${VEXA_WHISPER_MODEL:-Systran/faster-whisper-small.en}
      WHISPER__INFERENCE_DEVICE: cpu
      WHISPER__TTL: '-1'
    volumes:
      - vexa-whisper-cache:/root/.cache/huggingface
    logging:
      driver: json-file
      options: { max-size: '10m', max-file: '3' }

volumes:
  talon-control-pgdata:
  talon-control-data:
  talon-control-model-cache:
  # The meeting bot's own datastores and model cache (`meetings` profile).
  vexa-pgdata:
  vexa-miniodata:
  vexa-whisper-cache:
