services: crw: build: . ports: - "3000:3000" depends_on: lightpanda: condition: service_started # `chrome` is opt-in via `--profile heavy`. Compose treats a missing # service as "not started", so listing it here is harmless when the # profile is off — but keeping it makes `--profile heavy up` order # crw after chrome instead of racing. chrome: condition: service_started required: false # `chrome-stealth` is opt-in via `--profile stealth` (browserless image, # SSPL-licensed — only run if you accept those terms). Same listing # rationale as `chrome` above: harmless when off, deterministic ordering # when on. The two profiles are mutually exclusive in practice — pick one. chrome-stealth: condition: service_started required: false # SearXNG sidecar — backs `/v1/search`. Wait for healthy so the first # search request after `compose up` doesn't race the cold start. searxng: condition: service_healthy environment: - RUST_LOG=info - CRW_CONFIG=config.docker # Bench knob passthrough (I4 fix): bench/run_pool_bench.sh sets these # to flip pool on/off across two passes without editing config.docker.toml. # Defaults match the §B5-flipped config.docker.toml values (pool=on, # backend=vanilla, size=4) so an unset shell env leaves production # behavior unchanged; bench script overrides to `false`/`off` for the # control pass. Figment rejects empty strings for typed fields, so the # fallback must be the real default — not `${VAR:-}`. - CRW_RENDERER__CHROME_CONTEXT_POOL_ENABLED=${CRW_RENDERER__CHROME_CONTEXT_POOL_ENABLED:-true} - CRW_RENDERER__CHROME_BACKEND=${CRW_RENDERER__CHROME_BACKEND:-vanilla} - CRW_RENDERER__CHROME_POOL__SIZE=${CRW_RENDERER__CHROME_POOL__SIZE:-4} volumes: - ./config.docker.toml:/app/config.docker.toml:ro # Meta-search backend for `/v1/search`. Pinned to a dated tag (never `:latest`) # so upgrades are deliberate. # # AGPL / SearXNG attribution: SearXNG is a separate AGPL-3.0 work # (https://github.com/searxng/searxng). We ship the upstream image unmodified # and mount our own config read-only — we do NOT build a derived image. # This is the lowest-risk packaging for §6/§13 obligations: each container # runs its own program from its own publisher, with no rebundling. Operators # who deploy this stack to third parties are still responsible for SearXNG's # corresponding-source obligations on their own (the upstream image satisfies # this for unmodified deployments). If you fork SearXNG or `settings.yml`, # publish your changes alongside the deployment per AGPL §13. # # No Valkey/Redis: `server.limiter` and engine cache are off in settings.yml, # so the in-memory fallback is sufficient. Rate limiting is handled by # crw-server's own middleware. # # The container is reachable to other services as `searxng:8080`. We do NOT # publish the port to the host by default — search should only flow through # /v1/search. To debug directly, add `ports: ["127.0.0.1:8888:8080"]` in # docker-compose.override.yml. searxng: image: searxng/searxng:2026.5.9-0cba32c15 restart: unless-stopped # Hardening: SearXNG handles arbitrary user queries and proxies third-party # engines, so it has the largest abuse surface in this stack. We strip # everything it doesn't need and bound resource use so a runaway worker # can't take the host with it. read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true mem_limit: 512m memswap_limit: 512m pids_limit: 256 environment: - SEARXNG_BASE_URL=http://searxng:8080/ # Generate with `openssl rand -hex 32` and put in `.env` for production. # The default below is acceptable for local/internal-only deployments # because the limiter is off and the port isn't published. - SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY:-change-me-with-openssl-rand-hex-32-please} volumes: - ./config/searxng/settings.yml:/etc/searxng/settings.yml:ro # SearXNG writes runtime cache + uwsgi state under /var/cache + /tmp; with # `read_only: true` we route those to tmpfs so the container itself stays # immutable while the writable scratch space stays in RAM (and gets freed # on restart). tmpfs: - /tmp:size=64m - /var/cache/searxng:size=128m healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] interval: 10s timeout: 5s retries: 5 start_period: 15s # Primary JS renderer — lightweight (~64 MB), fast startup. # Handles most static-JS sites. Falls back to Chrome for complex SPAs. # # `restart: unless-stopped` is load-bearing: LightPanda is known to OOM # or segfault on adversarial pages (heavy SPA bundles, Cloudflare turnstile), # and without auto-restart the breaker stays open forever and every JS # request falls through to Chrome — defeating the lightweight tier. # `mem_limit` keeps a runaway tab from dragging Chrome and the host into # swap on small VPS instances. lightpanda: image: lightpanda/browser:latest restart: unless-stopped mem_limit: 1g memswap_limit: 1g healthcheck: test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/9222"] interval: 30s timeout: 5s retries: 3 start_period: 5s # Fallback JS renderer — full Chromium, handles complex SPAs and # Cloudflare challenges that LightPanda cannot. # --remote-allow-origins=* lets Chrome accept CDP connections where the # Host header is the Docker service name ("chrome") instead of localhost. # # Opt-in: `docker compose --profile heavy up -d`. Self-host users on # small VPS instances skip the ~500MB Chromium image by default; the # crw service falls back to LightPanda + HTTP-only when chrome is absent. chrome: image: chromedp/headless-shell:latest profiles: ["heavy"] shm_size: "2g" # --ignore-certificate-errors: render sites with expired/invalid certs # (e.g., actu-transport-logistique.fr) instead of failing nav. # --disable-http2: dodge `ERR_HTTP2_PROTOCOL_ERROR` on hosts that hard-fail # on Chrome's JA4 fingerprint mismatch (e.g., desotoms.mugshots.zone); # forces HTTP/1.1 fallback which most anti-bot stacks still allow. command: - "--remote-allow-origins=*" - "--ignore-certificate-errors" - "--disable-http2" - "--disable-blink-features=AutomationControlled" restart: unless-stopped mem_limit: 2g memswap_limit: 2g healthcheck: test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/9222"] interval: 30s timeout: 5s retries: 3 start_period: 10s # Stealth JS renderer — browserless/chromium with built-in anti-fingerprint # plugin. Drop-in replacement for the `chrome` tier on hosts that ship # bot-detection (Cloudflare Turnstile, DataDome, PerimeterX). On the 1000-URL # Firecrawl bench (2026-05-04): +2.5pt success rate (87.1% → 89.6%) over # vanilla chromedp/headless-shell, p50 1725ms. # # ⚠️ LICENSE — compliance risk to review before production: # browserless v2 is SSPL-3.0. SSPL §13 obliges anyone who makes the # functionality of the Program available to third parties as a service # (commercial OR otherwise) to release the *Service Source Code* — the # full management/automation/hosting stack around it, not just changes to # browserless. CRW (AGPL-3) connects over a network socket only, so the # opencore CRW source is most likely outside SSPL §13's reach — but the # boundary is fact-specific and we are not lawyers. If you deploy this # profile in any service that exposes scraping to third parties, get # legal review. The default `chrome` profile (chromedp/headless-shell, # Apache-2/BSD) carries none of this risk. # # Opt-in: `docker compose --profile stealth up -d`. To route the composed # `crw` service at this tier, layer the stealth override file: # docker compose -f docker-compose.yml -f docker-compose.stealth.yml \ # --profile stealth up -d # Mutually exclusive with `--profile heavy` in practice — both bind to the # chrome tier; the override file points `crw` at one of them. # # Tuning notes (2026-05-04 bench, 1000-URL Firecrawl dataset): # CONCURRENT=10 → sustained-load drops (Server disconnected ×48) # CONCURRENT=15 → sweet spot (89.6% success, stable) # CONCURRENT=25 → host saturation collapse (61.3%) chrome-stealth: image: ghcr.io/browserless/chromium:v2.27.0 profiles: ["stealth"] # Loopback-only host bind: lets a host-run `crw-server` connect via # `ws://localhost:9224/...` for development, while keeping the endpoint # off the public internet. The composed `crw` service does NOT need # this port — it reaches the container via the docker bridge as # `chrome-stealth:3000` (see docker-compose.stealth.yml override). ports: - "127.0.0.1:9224:3000" environment: # TOKEN is required by browserless v2 — clients must pass `?token=...`. # Default is a published placeholder; override via `.env` in production: # echo "BROWSERLESS_TOKEN=$(openssl rand -hex 24)" >> .env - TOKEN=${BROWSERLESS_TOKEN:-crwtest} - CONCURRENT=15 - MAX_QUEUE_LENGTH=30 - TIMEOUT=30000 # Kill the container instead of accepting traffic in a degraded state; # without this, browserless can wedge in "healthy but refusing CDP". - EXIT_ON_HEALTH_FAILURE=true shm_size: "2g" restart: unless-stopped mem_limit: 3g memswap_limit: 3g # Confine browserless's user-data-dirs and downloads to tmpfs so a # runaway session can't fill the host disk (we hit ENOSPC during # bench tuning when this was missing). Browserless v2 writes session # state under /tmp by default, so a single bounded tmpfs is enough. tmpfs: - /tmp:size=512m healthcheck: # TCP accept on browserless's listen port. App-level CDP failures # (chromium pool wedged, /chromium routing broken) are caught by # browserless's own supervision via `EXIT_ON_HEALTH_FAILURE=true` # above — it kills the container and `restart: unless-stopped` # brings it back up. A bash-based HTTP probe with token # interpolation is fragile (compose-vs-runtime expansion of $TOKEN, # and short-lived /dev/tcp FDs racing with HTTP/1.0 connection close) # and gave us nothing this combo doesn't already. test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/3000"] interval: 30s timeout: 5s retries: 3 start_period: 15s