# The product, from the published images. `docker compose up -d`. # # On its own - downloaded into an empty directory, no clone - this file pulls # `ghcr.io/vstorm-co/agenticos-backend` and `agenticos-frontend`, starts # Postgres (pgvector), Redis, the background worker, the API and the # console, runs the migrations, and answers on http://localhost:3000. Every # variable carries a default, so there is no `.env` to write first; write one # beside this file when there is something to change - `AGENTICOS_VERSION` to # pin a release, `PUBLIC_*` when the browser reaches the host by a name other # than `localhost`, a provider key. `docs/install.md` has the list. # # In a clone, Compose merges `docker-compose.override.yml` over this file on its # own, and that override is what turns it into the local development stack: # source built into the image, bind-mounted and reloading, the database and # Redis published to the host. `make dev` is that pair of files; so is a bare # `docker compose up` in a checkout. The other two environments have files of # their own: # # docker-compose.yml this one - the product, from images # docker-compose.override.yml merged in a clone - built from source, reloading # docker-compose-dev.yml the dev server (published images, no bind mounts) # docker-compose-prod.yml production # # The frontend used to be a separate file in every environment. It is a service # here now, because the console is part of the product and a `docker compose up` # that leaves it out answers on a port nobody was told about. Locally it sits # behind the `console` profile (the override puts it there), so that `bun dev` # on the host is not fighting a container for :3000. # # The application containers read two env files, both optional: `.env` beside # this file - where a standalone install keeps its settings, and the same file # Compose reads for `${...}` - and `backend/.env`, which is where a clone keeps # them and where the rest of this repository says to look. services: # Runs the migrations and exits. `app` and `worker` wait for it to have # *completed*, not for the database to be up, so a first `up` on an empty # volume is one command and a second `up` on a migrated one is a no-op that # takes a second. `docker compose logs migrate` is where a failed migration # says why the API never started. migrate: image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest} command: agenticos db upgrade env_file: - path: ./.env required: false - path: ./backend/.env required: false environment: - ENVIRONMENT=${ENVIRONMENT:-local} # The same interpolation `db` reads, so the two agree by construction - # the application's own default password is empty, and the image carries # no env file to fill it. - POSTGRES_HOST=db - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB=${POSTGRES_DB:-agenticos} - REDIS_HOST=redis networks: - backend depends_on: db: condition: service_healthy restart: "no" app: 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 ports: - "8000:8000" volumes: - media_data:/app/media env_file: - path: ./.env required: false - path: ./backend/.env required: false environment: - DEBUG=${DEBUG:-false} - ENVIRONMENT=${ENVIRONMENT:-local} - POSTGRES_HOST=db - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB=${POSTGRES_DB:-agenticos} - REDIS_HOST=redis # The browser profile's egress proxy, on by default so starting the browser # starts the pinning path too. Set BROWSER_EGRESS_PROXY= (empty) in the root # .env only for a browser outside compose that cannot reach it. - BROWSER_EGRESS_PROXY=${BROWSER_EGRESS_PROXY-http://browser-egress:3128} # Empty outside compose, so a bare `uvicorn` run degrades to "no container # workspace available" rather than erroring. Note what is absent here: # no docker.sock, which is the whole point of the sandbox service below. # One worker by default. `--workers` above 1 puts uvicorn's supervisor at # PID 1, which replaces a worker that dies; at 1 the process *is* PID 1, so # a death is a container exit and `restart:` below is what replaces it. A # worker that is wedged rather than dead kills itself either way # (`app/core/watchdog.py`). Each worker imports the application on its own, # 460 MiB measured, so raise this with the host's memory in mind - # docker-compose-prod.yml sizes four. # # `--ws websockets-sansio`: uvicorn's `auto` picks the legacy websockets # implementation, which fails the handshake against websockets >=14, and the # chat is a WebSocket. command: >- uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-1} --timeout-worker-healthcheck ${UVICORN_WORKER_HEALTHCHECK_TIMEOUT:-60} --ws websockets-sansio networks: - backend # Where `artifact-render` is, when its profile is up. Empty otherwise. - render - browser depends_on: migrate: condition: service_completed_successfully redis: condition: service_healthy # This probe used to be a HEALTHCHECK in backend/Dockerfile, which every # service built from the image inherited and only the API could answer. It # lives with the service that serves it now; see `worker` for the other half. # Two things are not carried over verbatim. The status: `httpx.get` on its # own returns a response object whatever the status, so the old probe passed # on a 500 as happily as on a 200 - `urlopen` raises on anything >= 400. And # `start_period`, which was 5s - shorter than this application takes to # import on any machine measured, so every start went through a spell of # `unhealthy` before settling. # # The standard library, not httpx: a probe is a fresh interpreter every 30s, # and importing httpx doubled what each one cost (956 ms against 431 ms on # the production host, #2228). 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 db: # 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` — so every document upload 500s # before a row is committed. Same major version, same data directory. image: pgvector/pgvector:pg16 environment: - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB=${POSTGRES_DB:-agenticos} volumes: - postgres_data:/var/lib/postgresql/data networks: - backend # `start_period` is what `depends_on: condition: service_healthy` is meant to # wait through, and this healthcheck had none. Two things go wrong without # it, and the second is the one that cost a deploy (#1831). A first start on # an empty volume runs `initdb` before Postgres answers, and every probe # until then counts toward `retries` rather than being the expected answer # for a database that is still starting. And a daemon that kept the previous # run's `unhealthy` across a restart answers `service_healthy` with it # immediately, before this run's first probe has had a chance to replace it, # so a container that was repaired fails the deploy that repaired it. # Thirty seconds covers `initdb` plus the extension; `scripts/deploy.sh` # covers the rest. healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] interval: 10s timeout: 5s retries: 5 start_period: 30s restart: unless-stopped redis: # Valkey, not Redis: Redis is RSALv2/SSPLv1 from 7.4 and neither is an # open-source licence, which is a component nobody should meet for the # first time in a security review. Valkey is the BSD-3-Clause fork of # Redis 7.2 and speaks the same protocol, so the service name, the port # and every `REDIS_*` setting stay as they are (docs/licenses.md). image: valkey/valkey:8-alpine # `--save ''` and no volume: rate-limit buckets, dedupe claims and # membership answers all carry a TTL and all rebuild themselves, so the # snapshot bought nothing - and cost production a crash-loop when Valkey 8 # met the RDB redis:7-alpine had written (#1830). command: valkey-server --save '' networks: - backend healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 10s timeout: 5s retries: 5 # Ten seconds, for the reason `db` gives above. Valkey is ready in well # under one, so this is not about a slow start: it is the grace a # dependency is owed before its health status is taken as a verdict. start_period: 10s restart: unless-stopped # The background worker (#2219): fired triggers, ingestion, syncs, cleanups and # every scheduled sweep, claimed from a queue in Postgres. One long-lived # process imports the application once and serves every pool; ingestion runs # in child processes under a memory watchdog (`WORKER_INGEST_MEMORY_MB`) set # below this container's limit, so the watchdog - which says why - acts before # the kernel does. A second worker, here or on another host, shares the queue. worker: image: ghcr.io/vstorm-co/agenticos-backend:${AGENTICOS_VERSION:-latest} volumes: - media_data:/app/media command: python -m app.worker env_file: - path: ./.env required: false - path: ./backend/.env required: false environment: - DEBUG=${DEBUG:-false} - ENVIRONMENT=${ENVIRONMENT:-local} - POSTGRES_HOST=db - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB=${POSTGRES_DB:-agenticos} - REDIS_HOST=redis # The browser profile's egress proxy, on by default so starting the browser # starts the pinning path too. Set BROWSER_EGRESS_PROXY= (empty) in the root # .env only for a browser outside compose that cannot reach it. - BROWSER_EGRESS_PROXY=${BROWSER_EGRESS_PROXY-http://browser-egress:3128} networks: - backend - render - browser depends_on: migrate: condition: service_completed_successfully redis: condition: service_healthy # The worker touches its health file only after a heartbeat that reached the # database, so a worker whose loop is stuck or whose database is gone reads # unhealthy: older than 90s is six missed 15s heartbeats. The standard library # only, like every probe here (#2228). The start period covers importing the # application and starting the ingest children, each of which imports it too. 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 # Longer than the worker's own drain (30s), so a stop lets running jobs # finish or be given back with their attempt rather than being killed mid-write. stop_grace_period: 45s deploy: resources: limits: memory: ${WORKER_MEMORY_LIMIT:-4g} restart: unless-stopped # The console. `BACKEND_URL` is the server-side address its route handlers # proxy to; the `PUBLIC_*` values are what it tells the *browser* to call, and # they are read when the container starts rather than baked into the image # (#1544) - so the same image serves localhost and a host with a name, and a # change is a restart. They stay `localhost` until the site is reached by # another name; then set them beside `FRONTEND_URL` and `CORS_ORIGINS`, which # are the backend's side of the same fact. frontend: image: ghcr.io/vstorm-co/agenticos-frontend:${AGENTICOS_VERSION:-latest} environment: - NODE_ENV=production - BACKEND_URL=http://app:8000 - PUBLIC_API_URL=${PUBLIC_API_URL:-http://localhost:8000} - PUBLIC_WS_URL=${PUBLIC_WS_URL:-ws://localhost:8000} - PUBLIC_SITE_URL=${PUBLIC_SITE_URL:-http://localhost:3000} # Where artifact pages are framed from, when not the API origin above. - ARTIFACT_ORIGIN=${ARTIFACT_ORIGIN:-} # The composer refuses a file above this before uploading it; the backend's # `CHAT_MAX_UPLOAD_SIZE_MB` is the limit it would hit otherwise, so the two # are the same name and should be given the same value. - CHAT_MAX_UPLOAD_SIZE_MB=${CHAT_MAX_UPLOAD_SIZE_MB:-10} - DIAGNOSTICS_ENABLED=${DIAGNOSTICS_ENABLED:-true} - DIAGNOSTICS_PUBLIC_URL=${DIAGNOSTICS_PUBLIC_URL:-https://analytics-agenticos.testapp.ovh} - OAUTH_PROVIDERS=${OAUTH_PROVIDERS:-google} - OIDC_DISPLAY_NAME=${OIDC_DISPLAY_NAME:-} - OIDC_ICON=${OIDC_ICON:-} - LDAP_DISPLAY_NAME=${LDAP_DISPLAY_NAME:-} - KERBEROS_DISPLAY_NAME=${KERBEROS_DISPLAY_NAME:-} # `instrumentation.ts` registers an OpenTelemetry SDK on every boot, and # `@vercel/otel` off Vercel takes its exporter from these two - so without # them the frontend's server-side spans are built and dropped in-process. # Passed through empty by default rather than omitted: an operator who # points them at Logfire (the same project the backend writes to) then gets # traces without editing a compose file. `frontend/README.md` has the pair. - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} ports: - "3000:3000" networks: - backend depends_on: app: condition: service_healthy healthcheck: # The oven/bun image has no curl/wget, so probe with bun's built-in fetch. test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] interval: 30s timeout: 10s start_period: 40s retries: 3 restart: unless-stopped # The only service that holds the Docker socket, and the reason an agent can # be given a container without this application ever having Docker access. # Behind a profile so a host that will not have the socket mounted turns it # off by editing one variable rather than deleting a service. # # The socket is an unauthenticated API for root on the host. Read-only does # not help: the API alone is enough. So the service is never published, and # its token is worth exactly as much as the socket is. minio: # The object store `FILE_STORAGE_BACKEND=s3` writes to, for developing # against that backend and for the integration tests that exercise it # (#1423). Behind a profile, because a deployment that keeps the default # local backend needs no object store at all: `docker compose --profile # objectstore up -d minio`, or `make docker-minio`. # # `pgsty/minio`, the community-maintained build of MinIO, because MinIO # stopped publishing images: `quay.io/minio/minio` and `minio/minio` both # answer 401 for every tag now, so a pin to either fails on every machine # with an error that reads like a login problem. Same binary, same `mc` # for the healthcheck below, and its web console is the full one. A dated # release rather than `latest`, so a version bump stays a decision. image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z profiles: [objectstore] command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER=${FILE_STORAGE_S3_ACCESS_KEY:-minioadmin} - MINIO_ROOT_PASSWORD=${FILE_STORAGE_S3_SECRET_KEY:-minioadmin} volumes: - minio_data:/data ports: - "9000:9000" - "9001:9001" networks: - backend healthcheck: # `mc ready` rather than curl: the image carries the client and carries no # curl, and the endpoint answers 403 to an unsigned request, which a bare # HTTP check reads as unhealthy. test: ["CMD", "mc", "ready", "local"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped artifact-render: # The browser `preview_artifact` draws a page in, so an agent can see what it # published. Opt-in: `docker compose --profile artifact-render up -d`, then in # `backend/.env` ARTIFACT_RENDER_CDP_URL=http://artifact-render:9222, with the # backend image built with the `browser` extra (`BACKEND_EXTRAS="--extra # browser"` locally). Never on BROWSER_CDP_ALLOWED_HOSTS: that list is what # agent authors may drive, and the browser tool's endpoint guard # (`browser_use._endpoint.is_renderer`) refuses this host anyway. # # On `render` alone, which has no route off the host. The API answers every # request a render makes, so the browser needs no network of its own, and a # page that broke out of Chromium finds nothing to talk to. Never the browser # a browsing agent drives: that one needs the internet, and this one renders # people's records. Chromium's own sandbox is off in this image (it needs # privileges a container is not given), so the container is the boundary: # read-only, no capabilities, bounded memory, CPU and processes. # # A version, not `latest`, so a Chromium upgrade is a decision. image: chromedp/headless-shell:155.0.8059.40 profiles: [artifact-render] # Appended to the image's own flags. command: - --disable-dev-shm-usage - --no-first-run - --disable-background-networking - --disable-component-update - --disable-default-apps - --disable-sync - --mute-audio - --hide-scrollbars read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true mem_limit: 1g cpus: 1.0 pids_limit: 256 networks: - render restart: unless-stopped # The browser the web browser capability drives (#2127). Behind a profile, # because only an agent bound to `browser_use` needs one: `docker compose # --profile browser up -d browser`. Never published - the API reaches it on the # compose network as `http://browser:9222`, which `BROWSER_CDP_ALLOWED_HOSTS` # has to name. A full Chromium with a window under a virtual display # (`docker/browser`), so a site sees a desktop browser rather than a headless # shell it refuses on sight (#2130). `TZ` and `BROWSER_LANG` are where and in # which language it says it is. browser: image: ghcr.io/vstorm-co/agenticos-browser:${AGENTICOS_VERSION:-latest} profiles: [browser] environment: - TZ=${BROWSER_TZ:-UTC} - BROWSER_LANG=${BROWSER_LANG:-en-US} # Room for pages that use shared memory; the default 64 MB crashes tabs. shm_size: 1gb networks: - browser restart: unless-stopped # The browser's egress proxy (`docker/browser-egress`): the app and the worker # default BROWSER_EGRESS_PROXY to it, so every browse without an organization # proxy goes out through it. It resolves each host itself, refuses # private, loopback, link-local and metadata addresses, and connects to the # address it checked - so neither a redirect nor a name that rebinds between # two lookups takes a page inside the deployment's network. browser-egress: image: ubuntu/squid:6.6-24.04_edge profiles: [browser] volumes: - ./docker/browser-egress/squid.conf:/etc/squid/squid.conf:ro networks: - browser restart: unless-stopped sandboxd: # 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 files every other secret in this stack comes # from, rather than through compose interpolation: interpolation reads the # project root's `.env` only, so a token generated into `backend/.env` - # where `make sandbox-token` puts it - would have been invisible here. 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: - path: ./.env required: false - path: ./backend/.env required: false environment: SANDBOXD_HOST: 0.0.0.0 SANDBOXD_PORT: "8080" # One host path, mounted at the same location 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 existing only inside this # container, is refused with "mounts denied: the path is not shared from # the host". # # Without it the service keeps nothing on disk, and then a workspace can # only be read by starting a container - which browsing deliberately never # does, so the Files panel could show a conversation's files only as # "this host's files could not be read". `persist_containers` did keep them # alive; it did not make them readable. # # `/tmp` rather than the `/var/lib` of the server files: it is shared by # Docker Desktop by default, so this works on a laptop with no host setup, # and it is world-writable, so the service's uid 10001 needs no `install # -d -o`. The cost is that a reboot sweeps it - fine for local dev, wrong # for a host somebody's work lives on. See docker-compose-prod.yml. SANDBOXD_WORKSPACE_ROOT: ${SANDBOX_WORKSPACE_ROOT:-/tmp/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" # One organization cannot occupy the pool of the whole installation # (`SANDBOXD_MAX_SESSIONS`, 20). Ten rather than five because a session is # per conversation by default: five was two people with three chats open, # and the sixth was refused with "already holds 5 of 5". SANDBOXD_MAX_SESSIONS_PER_TENANT: "10" SANDBOXD_NETWORK_MODE: none # Its own dashboard asks a human to paste a root-equivalent token into a # browser. AgenticOS proxies the same data behind its own authorization. SANDBOXD_UI_ENABLED: "0" volumes: - /var/run/docker.sock:/var/run/docker.sock - ${SANDBOX_WORKSPACE_ROOT:-/tmp/agenticos-sandbox-workspaces}:${SANDBOX_WORKSPACE_ROOT:-/tmp/agenticos-sandbox-workspaces} # Reaches the daemon by supplementary group rather than by running as root. # `stat -c '%g' /var/run/docker.sock` on the host; 0 on Docker Desktop, # where the socket is proxied and already reachable. group_add: - "${DOCKER_GID:-0}" networks: - backend restart: unless-stopped networks: backend: # No fixed name: every service that joins this network is in this file, so # compose scopes it to the project, and two stacks on one host do not share # a bridge on which `db` and `app` resolve to the neighbour's containers. driver: bridge render: # The page renderer's network: the API and the browser, and no way out. internal: true browser: # The browser's own network, which only the API and the worker share with it # to reach `browser:9222`. A page it is sent to - or redirected to - cannot # reach the database, Redis or MinIO, because nothing else is on it. driver: bridge volumes: media_data: minio_data: postgres_data: