services: crw: build: . ports: # Override the host bind address / port via `.env` (CRW_BIND_ADDRESS, # CRW_HOST_PORT) for boxes already using 3000, or to stop publishing on # public interfaces (set CRW_BIND_ADDRESS=127.0.0.1 when fronting crw # with a reverse proxy). Namespaced to `crw` so they can't collide with a # generic `HOST_PORT` in the operator's env. Despite the `CRW_` prefix # this is Compose-only host-side interpolation: it is NOT passed into the # container, so the engine's figment config never sees it. Container port # stays 3000; default bind stays 0.0.0.0 (all interfaces). - "${CRW_BIND_ADDRESS:-0.0.0.0}:${CRW_HOST_PORT:-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 # ── Host protection (defense-in-depth for untrusted PDF/scrape input) ── # `mem_limit` is the hard ceiling: no matter what a malicious upload does, # this container (and its sandboxed PDF-parse children, which share this # cgroup) can never exceed it — the host stays safe. With config.docker.toml # `[document] sandbox = true`, each parse runs in a child with its own # RLIMIT_AS, so peak ≈ base + max_concurrent_parses × sandbox_memory. mem_limit: 2g memswap_limit: 2g # no swap escape hatch — count swap against the cap pids_limit: 512 # bound fork-bombs / runaway worker spawning read_only: true # immutable root FS; nothing the parser can drop survives tmpfs: - /tmp # the only writable scratch (sandbox children, if any) cap_drop: - ALL # engine needs no Linux capabilities security_opt: - no-new-privileges:true # a child can never gain privileges via setuid # 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 # LightPanda sends usage telemetry to a third-party endpoint by default. # Disable it so the default stack makes no unexpected outbound connections — # important for air-gapped / compliance deployments. Air-gapped operators who # need byte-for-byte reproducibility should additionally pin this image to a # digest (`lightpanda/browser@sha256:...`) instead of the `:latest` tag. environment: - LIGHTPANDA_DISABLE_TELEMETRY=true # Refuse private/internal destinations in the browser itself, on the # resolved socket address. The engine also validates every intercepted CDP # request, but that cannot see websockets or worker targets, and it resolves # in a different container than the browser connects from. Overrides the # image CMD, so the whole serve line is repeated. The extra CIDRs are the # ranges the engine rejects that LightPanda's private group omits. # # These flags need a recent image. Unlike the embedded launcher, which just # falls through to the next renderer when a binary rejects them, a stale # cached `:latest` here exits and restart-loops, taking the tier down. Run # `docker compose pull lightpanda` when upgrading. command: - "/bin/lightpanda" - "serve" - "--host" - "0.0.0.0" - "--port" - "9222" - "--log-level" - "info" - "--block-private-networks" - "--block-cidrs" - "0.0.0.0/8,10.0.0.0/8,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10,224.0.0.0/4,240.0.0.0/4,192.0.0.0/24,192.0.2.0/24,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24,fc00::/7,fe80::/10,fec0::/10,ff00::/8,::/96,64:ff9b:1::/48,2002::/16" 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