-
-
Notifications
You must be signed in to change notification settings - Fork 54
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
281 lines (277 loc) · 13.9 KB
/
Copy pathdocker-compose.yml
File metadata and controls
281 lines (277 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
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