Layer 5 · host
One compose file, two scripts, and a health check that names the broken layer.
Bring-up is ./scripts/setup.sh. Diagnosis is
./scripts/health.sh, which tests four things independently so that a failure points
at one of them rather than at "search is broken".
TopologyWhat compose actually builds
depends_on uses condition:
service_healthy, so crw does not start against a SearXNG that is up but not yet
answering; and the SearXNG image is pinned to an exact digest-suffixed tag with the comment
"upgrades should be deliberate".
Bring-up./scripts/setup.sh
Twenty-five lines, and three of them are the interesting ones. It refuses to overwrite an
existing .env, generates a secret key with openssl rand -hex 32, and
edits the file with sed -i.bak — the portable form, because BSD and GNU
sed disagree about in-place editing and this repository expects to be run on a
Mac.
$ git clone https://github.com/iangogentic/localbrowser.git $ cd localbrowser $ ./scripts/setup.sh ==> wrote .env with a generated secret key ==> starting stack [+] Running 3/3 ✔ Network localbrowser_default Created ✔ Container localbrowser-searxng-1 Healthy ✔ Container localbrowser-crw-1 Started ==> waiting for searxng ── searxng ── OK — 31 results ── crw ── OK — v(your pinned image) ── renderers ── (whichever renderer key your build reports) ── scrape ── OK — 238 chars # then point an agent at it $ claude mcp add --transport http --scope user crw http://127.0.0.1:3002/mcp
6 if [ ! -f .env ]; then 7 cp .env.example .env 8 key=$(openssl rand -hex 32) 9 # portable in-place edit (BSD vs GNU sed) 10 sed -i.bak "s|^SEARXNG_SECRET_KEY=.*|SEARXNG_SECRET_KEY=${key}|" .env && rm -f .env.bak 11 echo "==> wrote .env with a generated secret key" 12 else 13 echo "==> .env already exists, leaving it alone" 14 fi … 19 echo "==> waiting for searxng" 20 for _ in $(seq 1 30); do 21 curl -sf --max-time 3 "http://127.0.0.1:8888/healthz" >/dev/null 2>&1 && break 22 sleep 2 23 done … 25 exec ./scripts/health.sh
- line 6 — idempotence
- Re-running setup on a configured machine does not regenerate your secret key, which would invalidate every existing SearXNG session and quietly change behaviour.
- lines 20–23 — a bounded wait
- Thirty attempts, two seconds apart: sixty seconds, then give up and run the health check anyway. The health check is more useful than a timeout message, because it says which layer is not ready.
- line 25 —
exec - Replaces the setup process with the health check, so
setup.sh's exit status is the health check's exit status. Scriptable:./scripts/setup.sh || echo brokendoes what you would hope. - line 4 (not shown) —
cd "$(dirname "${BASH_SOURCE[0]}")/.." - Both scripts start by moving to the repository root, so they work from anywhere. Both also
run under
set -euo pipefail.
DiagnosisFour checks, deliberately separate
An end-to-end check tells you the system is broken. It does not tell you which of four
independent things broke, and in this stack they break for entirely unrelated reasons — engine
suspensions, a container that did not start, a renderer feature that was never compiled in, a
network problem. health.sh therefore checks each layer with the narrowest request that
proves it.
| check | request | passes when | fails |
|---|---|---|---|
| searxng | GET /search?q=test&format=json | The results array is non-empty | yes — and prints unresponsive_engines |
| crw | GET /health | A version field comes back |
yes |
| renderers | GET /v1/capabilities | Always — it prints what it finds | no |
| scrape | POST /v1/scrape · example.com | More than 50 characters of markdown | yes |
The most common real failure looks like this — and note how much it tells you at a glance:
$ ./scripts/health.sh; echo "exit=$?" ── searxng ── FAIL — 0 results. Engines are probably CAPTCHA'd/rate-limited: ['google', 'CAPTCHA'] ['startpage', 'CAPTCHA'] ['duckduckgo', 'HTTP error'] See README → 'When search returns nothing'. ── crw ── OK — v(your pinned image) ── renderers ── (whichever renderer key your build reports) ── scrape ── OK — 238 chars exit=1 # the engine is fine. Page reading is fine. Only URL discovery is broken. # → searxng.html#zero, and start at fix 1: add an API-backed engine.
Reference.env
| variable | default | purpose |
|---|---|---|
| SEARXNG_SECRET_KEY | — | Required. openssl rand -hex 32; setup.sh generates it |
| CRW_BIND_ADDRESS | 127.0.0.1 | Leave it. See why |
| CRW_HOST_PORT | 3002 | Host-side port; health.sh reads it back out of .env |
| CRW_IMAGE | ghcr.io/us/crw:latest | Set to crw-local:camoufox after building the stealth tier |
| BRAVE_API_KEY | — | Strongly recommended. The fix that makes search reliable |
| GOOGLE_CSE_API_KEY / _ENGINE_ID | — | Google Custom Search; needs both |
| TAVILY_API_KEY | — | Slot for a Tavily-backed engine |
| LLM_BASE_URL / _API_KEY / _MODEL | — | Any OpenAI-compatible endpoint for the quarantined summariser. Blank means raw-only; search and scrape still work |
Docker and Docker Compose. Roughly 2 GB of RAM for the base stack and 4 GB with the stealth profile — the difference is one Firefox and its 2 GB of shared memory. No GPU is required unless you choose to run a local summariser, in which case the GPU requirement is that model's, not this stack's.
PracticePinning, and why it is not paranoia
The SearXNG image is pinned to searxng/searxng:2026.5.9-0cba32c15 with a one-line
comment: "Pinned, never :latest — upgrades should be deliberate." This matters more here
than in most compose files, because two of the three documented gotchas on this stack are upstream
behaviours that changed: !!env stopped working, and inactive: true became
the default for API engines. A stack whose search silently returns nothing after an unattended
docker compose pull is a bad afternoon.
crw, by contrast, defaults to :latest — deliberately overridable through
CRW_IMAGE, because the Camoufox path requires a locally built image anyway. If you
care about reproducibility, pin it in .env.