Ctrl K
ComunidadGuías…

docker-compose-prod.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Production. `make prod`.
#
#   docker compose --env-file backend/.env -f docker-compose-prod.yml pull
#   docker compose --env-file backend/.env -f docker-compose-prod.yml up -d
#
# The images are pulled, not built: `ghcr.io/vstorm-co/agenticos-backend` at
# the `AGENTICOS_VERSION` in `backend/.env` - a release, `latest` when unset,
# or the `sha-<short>` a commit published, which is what `scripts/deploy.sh`
# pins. Pin a release rather than tracking `latest` on a host you care about,
# and pin it from that release's checkout (`git checkout v<version>`): the
# sandboxd image below is pinned in this file, at the version that release's
# backend speaks, so this file and the backend tag must come from one commit.
#
# Prerequisites:
#   1. `backend/.env` with real secrets. The same file serves every environment;
#      there is no `.env.prod`. Work through the production checklist in
#      `docs/configuration.md` first — VAULT_MASTER_KEY especially.
#   2. A reverse proxy in front of this stack. `nginx/nginx.conf` is the
#      template; it proxies to `backend:8000` and `frontend:3000`, and both
#      names are provided as network aliases below.
#
# The frontend is `docker-compose-prod.frontend.yml`.
#
# Two networks, deliberately:
#
#   `data`  — internal, no route off the host. Postgres and Redis live here and
#             have no business reaching the internet.
#   `edge`  — an ordinary bridge. The app needs it: every agent run calls a
#             model provider, and an MCP connection calls a vendor's server.
#
# This used to be one network marked `internal: true`, which blocks egress. The
# database was correctly unreachable and so was api.anthropic.com, so no agent
# could have answered anything.

# Every service's log, capped. Docker's `json-file` driver keeps a container's
# output until the container is removed, and nothing else on the host rotates it:
# prod held 570 MB of logs and growing when this was added (#2228). Five files of
# 20 MB is about a day of a busy API and every line `docker compose logs` needs.
x-logging: &logging
  driver: json-file
  options:
    max-size: 20m
    max-file: "5"

services:
  # Runs the migrations and exits; `app` and `worker` wait for it to have
  # completed. This is what `make prod` and `scripts/deploy.sh` used to do with
  # an `exec` after `up`, and what a bare `docker compose up -d` on this file
  # did not do at all. `docker compose logs migrate` is where a failed migration
  # says why the API never started.
  migrate:
    logging: *logging
    image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest}
    command: agenticos db upgrade
    env_file:
      - ./backend/.env
    environment:
      - ENVIRONMENT=production
      - POSTGRES_HOST=db
      - REDIS_HOST=redis
    networks:
      - data
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  app:
    logging: *logging
    image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest}
    # Longer than background.drain()'s 30s timeout, or Docker's 10s default
    # SIGKILLs the container mid-drain and an upload stays `processing` (#11).
    stop_grace_period: 40s
    volumes:
      - media_data:/app/media
    env_file:
      - ./backend/.env
    environment:
      - DEBUG=false
      - ENVIRONMENT=production
      - POSTGRES_HOST=db
      - REDIS_HOST=redis
      # Empty unless the sandbox profile is up. Note what is absent from
      # this service: the Docker socket.
    # --ws websockets-sansio: uvicorn's `auto` picks the legacy websockets impl,
    # which fails the handshake against websockets >=14. The chat WS needs it in
    # production exactly as much as it does locally.
    #
    # --workers puts uvicorn's own `Multiprocess` supervisor at PID 1, and it is
    # deliberately left there (#358): it replaces a worker that died, and pings
    # the live ones over a pipe. What the ping cannot see is a worker whose
    # event loop is blocked, because a thread (`Process.always_pong`) answers it
    # regardless - so the worker judges its own loop (`app/core/watchdog.py`)
    # and kills itself, which this supervisor replaces twice a second.
    # Subclassing it instead would have meant owning four upstream call sites
    # in the one process whose job is to survive whatever killed the worker.
    #
    # That leaves the ping one job: a process that is stopped outright. Its
    # default window of 5 seconds is shorter than a starved host takes to
    # answer, and on prod it killed live workers and their half-started
    # replacements 17 times in 20 minutes (#2223) - each one an import of the
    # whole application, added to the CPU pressure that got it killed. A
    # minute costs nothing the watchdog does not already cover.
    #
    # How many workers is a deployment's decision, not a constant: each one is a
    # *spawned* process that imports the application on its own - 460 MiB
    # measured, with no copy-on-write to share - so four of them are 1.9 GB
    # before a request arrives. Four suits a busy deployment; a team of ten is
    # better served by two, which still leaves one serving while the watchdog
    # replaces a wedged sibling. The memory limit below is sized for the
    # default; lower both together.
    command: >-
      uvicorn app.main:app --host 0.0.0.0 --port 8000
      --workers ${UVICORN_WORKERS:-4}
      --timeout-worker-healthcheck ${UVICORN_WORKER_HEALTHCHECK_TIMEOUT:-60}
      --ws websockets-sansio
    networks:
      data:
      visuals:
      browser:
      edge:
        aliases:
          - backend
    ports:
      # Loopback by default, for the reason the frontend's compose file gives:
      # the reverse proxy is the way in, and an auth rate limit keyed on a
      # forwarded address is only a limit while nothing can reach past it.
      - "${BIND_HOST:-127.0.0.1}:8000:8000"
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    # Moved out of backend/Dockerfile, which the worker shares and could
    # never answer; see docker-compose.yml for the whole reasoning, including why
    # `raise_for_status` and a 30s start_period are not what the image had. Four
    # workers import the application four times, so the grace matters more here.
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=5)"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "3"
          # Four workers at the 460 MiB each one measures, plus the supervisor
          # and room for a request holding a large transcript. It was 1G, which
          # is under what the default `--workers 4` needs to *start* - so the
          # shipped limit OOM-killed the container the first time all four were
          # warm. Lower it with `UVICORN_WORKERS`: about 600M per worker.
          memory: 2560M
        reservations:
          cpus: "0.5"
          memory: 512M

  db:
    logging: *logging
    # pgvector, not stock postgres: the RAG store runs
    # `CREATE EXTENSION IF NOT EXISTS vector` when a collection is first
    # written to, and on postgres:16-alpine that fails with
    # `extension "vector" is not available`.
    image: pgvector/pgvector:pg16
    # Postgres ships defaults sized for a machine from 2005 - `shared_buffers`
    # is 128 MB whatever the host has. These are the standard ratios against the
    # 2G limit below, so the two move together: a quarter of it resident, and
    # `effective_cache_size` describing what the host's page cache is likely to
    # be holding on top. `random_page_cost` and `effective_io_concurrency` say
    # the storage is an SSD, which every deployment of this now is.
    #
    # `max_connections` is sized to what the pools can open, not left at 100.
    # Each API worker holds two pools of `DB_POOL_SIZE + DB_MAX_OVERFLOW` (15) -
    # the session's and the vector store's - so the default four workers can open
    # 120, and the background worker another 30 plus its listener and its ingest
    # children. Past 100, Postgres refuses the connection outright with "too many
    # clients", before any pool timeout or 503 can shed the load in order. Raise
    # it with `UVICORN_WORKERS` or the pool settings.
    #
    # `pg_stat_statements` records what each statement costs, which is the only
    # place a slow query shows up without reading logs (#2228). It has to be
    # loaded at server start; the migration that creates the extension is
    # `0143_pg_stat_statements`, and `doctor` says whether it is recording.
    command: >-
      postgres
      -c max_connections=200
      -c shared_preload_libraries=pg_stat_statements
      -c shared_buffers=512MB
      -c effective_cache_size=1536MB
      -c maintenance_work_mem=128MB
      -c work_mem=8MB
      -c wal_buffers=16MB
      -c random_page_cost=1.1
      -c effective_io_concurrency=200
      -c max_parallel_workers_per_gather=2
    # Parallel query workers pass tuples through POSIX shared memory, which is
    # `/dev/shm` - and Docker gives a container 64 MB of it. A parallel scan over
    # a collection's vectors then fails with "could not resize shared memory
    # segment", which reads as a corrupt index rather than as a container
    # setting.
    shm_size: 512mb
    environment:
      - POSTGRES_USER=${POSTGRES_USER:-postgres}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
      - POSTGRES_DB=${POSTGRES_DB:-agenticos}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    # No published port, and no egress: `data` is internal.
    networks:
      - data
    # See docker-compose.yml for why `service_healthy` needs a grace to wait
    # through. This is the file the deploy runs, and the one where a missing
    # grace failed the redeploy that fixed the outage before it (#1831).
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 2G

  redis:
    logging: *logging
    image: valkey/valkey:8-alpine
    # A cache with no ceiling is a cache that grows until the container is
    # killed, and this one holds rate-limit buckets, channel dedupe claims and
    # membership answers - all of which are rebuildable, none of which is worth
    # an OOM. `allkeys-lru` because everything here has a TTL anyway; the policy
    # only decides what goes first if one is ever forgotten.
    #
    # `--save ''` and no volume: nothing in here is worth keeping across a
    # restart, and keeping it is what took production down in #1830 - Valkey 8
    # forks Redis 7.2 and refuses the RDB format version 12 that redis:7-alpine
    # left behind, so the container crash-looped and every service that waits on
    # it stayed in `Created`. A cache that cannot be reloaded is a cache that
    # cannot be corrupted.
    command: >-
      valkey-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
      --maxmemory 384mb --maxmemory-policy allkeys-lru --save ''
    networks:
      - data
    healthcheck:
      test: ["CMD", "valkey-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 512M

  # The background worker: ingestion, syncs, fired triggers, cleanups and the
  # scheduled sweeps, claimed from the queue in Postgres (#2219). It replaced a
  # Prefect server and runner whose process-per-run model is what starved this
  # host on 2026-10-18 - one long-lived process now, importing the application
  # once.
  worker:
    logging: *logging
    image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest}
    volumes:
      - media_data:/app/media
    command: python -m app.worker
    env_file:
      - ./backend/.env
    environment:
      - DEBUG=false
      - ENVIRONMENT=production
      - POSTGRES_HOST=db
      - REDIS_HOST=redis
      # One ingest child by default here: with its limit below, that is the
      # worker (~450 MiB) plus one parse of up to WORKER_INGEST_MEMORY_MB, inside
      # the container's own ceiling - so the watchdog stops a runaway parse, and
      # says so, before the kernel kills anything. Raise both together.
      - WORKER_INGEST_CHILDREN=${WORKER_INGEST_CHILDREN:-1}
    # Egress too: ingestion calls an embedding provider.
    networks:
      - data
      - edge
      - visuals
      - browser
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    # Touched after every heartbeat that reached the database. See
    # docker-compose.yml.
    healthcheck:
      test: ["CMD", "python", "-c", "import os, sys, time; sys.exit(time.time() - os.stat(os.environ.get('WORKER_HEALTH_FILE', '/tmp/agenticos-worker-alive')).st_mtime > 90)"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 90s
    stop_grace_period: 45s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: ${WORKER_MEMORY_LIMIT:-2560M}

  # The only service holding the Docker socket. See docker-compose.yml for the
  # reasoning; the difference here is that it is not on by default (mounting
  # the socket on a shared host is a deliberate act).
  sandboxd:
    logging: *logging
    # Exactly the `pydantic-ai-backend` version in backend/uv.lock: the backend's
    # client and this service ship together, and a client newer than the service
    # calls routes it does not have (#2484). Bump both at once;
    # backend/tests/test_sandboxd_compat.py fails when they differ.
    image: ghcr.io/vstorm-co/sandboxd:0.2.39
    profiles: [sandbox]
    # Without an init the server is PID 1, and Python reaps only the children it
    # waits on: anything orphaned under it stays a zombie for the container's
    # lifetime, and production once showed more than 600. docker-init reaps
    # them, as it already does in every sandbox this service starts.
    init: true
    # The token comes from the same file every other secret in this stack comes
    # from, rather than through compose interpolation. Interpolation reads the
    # project root, not `backend/.env`, so a token generated where the
    # application reads it would have been invisible here - and every other
    # variable in this file carries a default precisely so the stack starts with
    # no env file at all. The service exits naming the variable when it is
    # missing, which `docker compose logs sandboxd` shows.
    env_file:
      - ./backend/.env
    environment:
      SANDBOXD_HOST: 0.0.0.0
      SANDBOXD_PORT: "8080"
      # Mounted at the same path on both sides, which is the only arrangement
      # that works: this service creates the directory and then asks the daemon
      # to bind-mount it, and the daemon resolves the path on the host. A named
      # volume, or any path that exists only in this container, is refused with
      # "mounts denied".
      #
      # The directory has to be writable by uid 10001 - `mkdir -p` then
      # `chown 10001:10001`, since `install -o` refuses a uid no account owns
      # once, on the host - and it is where a conversation's files live, so it
      # belongs on storage somebody backs up.
      SANDBOXD_WORKSPACE_ROOT: ${SANDBOX_WORKSPACE_ROOT:-/var/lib/agenticos/sandbox-workspaces}
      # Which environments an agent may ask for. **Generated - do not edit here.**
      # `make sandbox-runtimes` writes it from
      # backend/app/core/catalog/sandbox_runtimes.json, which is where a runtime
      # is added or a package changed, and which the connection dialog offers as
      # well - so the two cannot disagree about what this deployment ships.
      # `backend/tests/test_sandbox_runtime_catalog.py` fails when this line has
      # drifted from that file.
      #
      # Written into a tracked file rather than read from one at start-up because
      # `docker compose up` has to work without generating anything first;
      # otherwise a deployment silently gets the library's own default allowlist.
      #
      # Not a form in the product, and that is upstream's decision rather than an
      # omission: `PUT /policy` changes ceilings and lifetimes at run time and
      # deliberately refuses `runtimes` membership, because naming an image is a
      # decision about isolation and this token belongs to an application rather
      # than to whoever runs the host. Changing the list is a restart, and
      # `prewarm` rebuilds what it names as the service starts rather than inside
      # somebody's first request - which is minutes on a cold cache, and the
      # reason this deployment ships two runtimes rather than eight.
      SANDBOXD_RUNTIMES: '{"workbench":{"description":"Python, Node and LibreOffice - documents, data, charts and code","runtime":{"name":"workbench","base_image":"python:3.12-slim","description":"Python, Node and LibreOffice - documents, data, charts and code","setup_commands":["apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates ripgrep fd-find jq less procps xz-utils poppler-utils libreoffice-writer libreoffice-calc libreoffice-impress unzip zip tzdata fonts-dejavu-core && ln -s /usr/bin/fdfind /usr/local/bin/fd && rm -rf /var/lib/apt/lists/*","arch=\"$$(dpkg --print-architecture)\"; case \"$$arch\" in amd64) node_arch=x64 ;; arm64) node_arch=arm64 ;; *) echo \"no Node build for $$arch\" >&2; exit 1 ;; esac; curl -fsSL \"https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-$${node_arch}.tar.xz\" | tar -xJ -C /usr/local --strip-components=1 --exclude CHANGELOG.md --exclude README.md --exclude LICENSE && npm install -g tsx @anthropic-ai/claude-code@2.1.288 @openai/codex@0.160.0 && npm cache clean --force","curl -fsSL https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh"],"packages":["liteparse","pypdf","python-docx","openpyxl","python-pptx","pillow","pandas","duckdb","matplotlib","httpx","requests","beautifulsoup4","lxml","markdownify","pyyaml","tabulate","reportlab"],"env_vars":{"PYTHONUNBUFFERED":"1","PYTHONDONTWRITEBYTECODE":"1","PYTHONUTF8":"1","MPLBACKEND":"Agg","PIP_DISABLE_PIP_VERSION_CHECK":"1"}},"mem_limit":"2g","network_mode":"bridge"},"desktop":{"description":"A Linux desktop (XFCE and Firefox) for computer use, with Python and a shell","runtime":{"name":"desktop","base_image":"python:3.12-slim","description":"A Linux desktop (XFCE and Firefox) for computer use, with Python and a shell","setup_commands":["apt-get update && apt-get install -y --no-install-recommends xvfb x11-utils xfce4 xfce4-terminal dbus-x11 xdotool scrot firefox-esr fonts-dejavu-core fonts-noto-color-emoji git curl ca-certificates jq procps unzip && rm -rf /var/lib/apt/lists/*"],"packages":[],"env_vars":{"PYTHONUNBUFFERED":"1","PYTHONUTF8":"1","PIP_DISABLE_PIP_VERSION_CHECK":"1"}},"mem_limit":"2g","network_mode":"bridge"}}'
      # An escape starts from whoever the container runs as, and the default is
      # root. The image is built around this uid - a home directory and a
      # virtualenv it owns, without which an agent's `pip install` fails - so it
      # only applies to a runtime this deployment builds, which both of ours are.
      # It also makes every file an agent writes owned by this uid on the host,
      # rather than by root.
      #
      # **The service's own uid, and it has to be.** Opening a session `chown`s
      # the workspace to this user, and a sandboxd running unprivileged can only
      # do that for itself - so a different number here fails at the first
      # session rather than at start-up.
      SANDBOXD_SANDBOX_UID: "10001"
      SANDBOXD_PERSIST_CONTAINERS: "true"
      # What a session installed inside its container - the build, the wheels,
      # `node_modules` - reclaimed a day after it stopped. Without it a persisted
      # container is kept for ever and the host fills up with the ones nobody
      # reattached to. The workspace is untouched: the files are the work, the
      # container is rebuildable.
      SANDBOXD_CONTAINER_TTL: "86400"
      SANDBOXD_MAX_SESSIONS_PER_TENANT: "10"
      SANDBOXD_NETWORK_MODE: none
      SANDBOXD_UI_ENABLED: "0"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ${SANDBOX_WORKSPACE_ROOT:-/var/lib/agenticos/sandbox-workspaces}:${SANDBOX_WORKSPACE_ROOT:-/var/lib/agenticos/sandbox-workspaces}
    group_add:
      - "${DOCKER_GID:-0}"
    networks:
      # `data` only. `edge` is where the reverse proxy reaches the application,
      # and a service whose token can start containers on this host has no
      # business on a network anything outside can route to.
      - data
    restart: unless-stopped

  # Interactive browsing uses a separate browser with proxy-only public egress.
  browser:
    logging: *logging
    environment:
      - TZ=${BROWSER_TZ:-UTC}
      - BROWSER_LANG=${BROWSER_LANG:-en-US}
    image: ghcr.io/vstorm-co/agenticos-browser:${AGENTICOS_VERSION:-latest}
    profiles: [browser]
    healthcheck:
      test: ["CMD", "bash", "-ec", "exec 3<>/dev/tcp/127.0.0.1/9222; printf 'GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; timeout 5 cat <&3 | grep -q webSocketDebuggerUrl"]
      interval: 10s
      timeout: 6s
      retries: 3
      start_period: 30s
    depends_on:
      browser-egress:
        condition: service_healthy
    shm_size: 512mb
    networks:
      - browser
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 1G

  browser-egress:
    logging: *logging
    image: ubuntu/squid:6.6-24.04_edge
    profiles: [browser]
    healthcheck:
      test: ["CMD", "bash", "-ec", "exec 3<>/dev/tcp/127.0.0.1/3128; printf 'GET http://127.0.0.1/ HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; timeout 5 cat <&3 | head -n 1 | grep -q '403'"]
      interval: 10s
      timeout: 6s
      retries: 3
      start_period: 15s
    volumes:
      - ./docker/browser-egress/squid.conf:/etc/squid/squid.conf:ro
    networks:
      - browser
      - browser-egress
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M

  # Enabled by scripts/deploy.sh when VISUAL_RENDER_CDP_URL names this service.
  # Isolated from the internet, the proxy and data services. The renderer serves
  # the drawing and its libraries through CDP; Chromium needs no outbound route.
  visual-render:
    logging: *logging
    image: ghcr.io/vstorm-co/agenticos-browser:${AGENTICOS_VERSION:-latest}
    profiles: [visuals]
    healthcheck:
      test: ["CMD", "bash", "-ec", "exec 3<>/dev/tcp/127.0.0.1/9222; printf 'GET /json/version HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' >&3; timeout 5 cat <&3 | grep -q webSocketDebuggerUrl"]
      interval: 10s
      timeout: 6s
      retries: 3
      start_period: 30s
    shm_size: 512mb
    networks:
      - visuals
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 1G

networks:
  browser:
    driver: bridge
    internal: true
  browser-egress:
    driver: bridge
  visuals:
    driver: bridge
    internal: true
  # Named so `docker-compose-prod.frontend.yml`, a project of its own, can join
  # `edge` as an external network. Two stacks on one host set
  # AGENTICOS_DATA_NETWORK and AGENTICOS_EDGE_NETWORK apart in backend/.env -
  # both files read it - or every `db`, `redis` and `app` alias resolves across
  # them.
  data:
    name: ${AGENTICOS_DATA_NETWORK:-agenticos_data}
    driver: bridge
    internal: true
  edge:
    name: ${AGENTICOS_EDGE_NETWORK:-agenticos_edge}
    driver: bridge

volumes:
  media_data:
  postgres_data: