# Todd — self-hosted multi-agent operator. # docker compose up -d --build → dashboard at http://localhost:3000 # docker compose --profile local up → also runs Ollama for fully local models # Requires Docker Compose v2.24+. # # Isolation: # core postgres, api, web (sandbox and browser can't reach the DB or the dashboard) # sandbox_net api ↔ sandbox (agent-run code can reach the API port, but every API call needs a # browser_net api ↔ browser token that only api and web have: see the api-token volume) # signedin_net api ↔ signedin commands that use your sign-ins (git push, gh, deploy CLIs, EAS, CLI # sign-ins) run here, in their own container with the same workspace: nothing running in the sandbox # (a dev server, an npm install script) can read their tokens # media_net api ↔ media embeddings, slides and MP4s for the video toolset: the shared workspace, # a random token only the API has (media-token volume), no vault, fetches only MEDIA_FETCH_HOSTS # previews localhost:3000 (and other dev ports) in the browser → api:17000+ → the same port in the sandbox, so # agents can look at what they're building without deploying it (PREVIEW_PORTS / PREVIEW_RELAY_BASE) # Published ports bind to 127.0.0.1 only. The dashboard answers only to localhost and only to its own pages (see # services/web/lib/guard.ts; TODD_ALLOWED_HOSTS adds names), and the live view has a password (random unless # VNC_PASSWORD is set). Todd can spend money: don't expose it publicly without adding real sign-in. name: todd services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: todd POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-todd} POSTGRES_DB: todd volumes: - pgdata:/var/lib/postgresql/data networks: [core] healthcheck: test: ["CMD-SHELL", "pg_isready -U todd -d todd"] interval: 5s timeout: 3s retries: 20 api: build: ./services/api restart: unless-stopped env_file: - path: .env required: false environment: DATABASE_URL: postgresql+psycopg://todd:${POSTGRES_PASSWORD:-todd}@postgres:5432/todd TODD_DATA_DIR: /data TODD_API_TOKEN_FILE: /shared/api_token SANDBOX_URL: http://sandbox:7000 SANDBOX_TOKEN: ${SANDBOX_TOKEN:-change-me-sandbox-token} SIGNEDIN_URL: http://signedin:7000 SIGNEDIN_TOKEN_FILE: /signedin/token MEDIA_URL: http://media:7000 MEDIA_TOKEN_FILE: /media/token BROWSER_CDP_HOST: browser BROWSER_CDP_PORT: "9223" BROWSER_LIVE_URL: ${BROWSER_LIVE_URL:-http://localhost:6080/vnc_lite.html?scale=true} VNC_PASSWORD: ${VNC_PASSWORD:-} VNC_PASSWORD_FILE: /live/vnc_password CORS_ORIGINS: http://localhost:3000 volumes: - apidata:/data - api-token:/shared - signedin-token:/signedin - media-token:/media - live-password:/live:ro - ./plugins:/app/custom/plugins - ./prompts:/app/custom/prompts networks: [core, sandbox_net, browser_net, signedin_net, media_net] depends_on: postgres: condition: service_healthy sandbox: condition: service_started signedin: condition: service_started media: condition: service_started browser: condition: service_started sandbox: build: ./services/sandbox restart: unless-stopped init: true # reaps the background processes agents and CLI sign-ins leave behind environment: SANDBOX_TOKEN: ${SANDBOX_TOKEN:-change-me-sandbox-token} volumes: - workspace:/workspace networks: [sandbox_net] security_opt: ["no-new-privileges:true"] # Same image, same workspace; runs only the commands that carry your sign-ins. Only the API reaches it, with a # random token the API makes (signedin-token volume). signedin: build: ./services/sandbox restart: unless-stopped init: true environment: SANDBOX_TOKEN_FILE: /secret/token SANDBOX_PREVIEWS: "0" volumes: - workspace:/workspace - signedin-token:/secret:ro networks: [signedin_net] security_opt: ["no-new-privileges:true"] # Image embeddings (CLIP), captioned slides and MP4s for the video toolset. Only the API reaches it, with a random # token the API makes (media-token volume); it mounts the workspace and never sees the vault. media: build: ./services/media restart: unless-stopped init: true environment: MEDIA_TOKEN_FILE: /secret/token WORKSPACE_ROOT: /workspace MEDIA_FETCH_HOSTS: ${MEDIA_FETCH_HOSTS:-images.pexels.com,videos.pexels.com,pixabay.com,cdn.pixabay.com} volumes: - workspace:/workspace - media-token:/secret:ro networks: [media_net] security_opt: ["no-new-privileges:true"] browser: build: ./services/browser restart: unless-stopped shm_size: 2gb environment: VNC_PASSWORD: ${VNC_PASSWORD:-} SCREEN_WIDTH: "1440" SCREEN_HEIGHT: "900" volumes: - browser-profile:/data - live-password:/secret networks: [browser_net] ports: - "127.0.0.1:6080:6080" # live view (noVNC) web: build: ./services/web restart: unless-stopped environment: API_URL: http://api:8000 TODD_API_TOKEN_FILE: /shared/api_token TODD_ALLOWED_HOSTS: ${TODD_ALLOWED_HOSTS:-} # names besides localhost the dashboard answers to (comma-separated) volumes: - api-token:/shared:ro networks: [core] ports: - "127.0.0.1:3000:3000" depends_on: api: condition: service_healthy ollama: image: ollama/ollama:latest profiles: ["local"] restart: unless-stopped volumes: - ollama:/root/.ollama networks: [core] networks: core: sandbox_net: browser_net: signedin_net: media_net: volumes: pgdata: apidata: api-token: signedin-token: media-token: live-password: workspace: browser-profile: ollama: