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

The Docker Compose topology: services, mounts, environment and published ports Three services — crw, searxng and the optional camofox — with their environment sourced from .env, their configuration mounted read-only from config/, a depends_on edge requiring searxng to be healthy, and every port published only to the host's loopback interface. docker compose up -d .env (GITIGNORED) SEARXNG_SECRET_KEY required — :? or refuse CRW_IMAGE CRW_BIND_ADDRESS CRW_HOST_PORT generated on first run by setup.sh config/ (READ-ONLY MOUNTS) crw.toml searxng/settings.yml mounted :ro — the container cannot rewrite its own config crw image: ${CRW_IMAGE:-ghcr.io/us/crw:latest} CRW_CONFIG=config.docker · RUST_LOG=info → /app/config.docker.toml:ro read_only · cap_drop ALL · 2g · 512 pids restart: unless-stopped extra_hosts: host.docker.internal searxng image: searxng/searxng:2026.5.9-0cba32c15 pinned — never :latest → /etc/searxng:ro healthcheck: wget -qO- /healthz every 10s · timeout 5s · retries 10 logging: json-file, 1m × 1 file camofox profiles: ["stealth"] — off by default shm_size: 2gb depends_on: service_healthy HOST — PUBLISHED TO 127.0.0.1 ONLY 127.0.0.1:3002 → 3000 the MCP endpoint and HTTP API override: CRW_BIND_ADDRESS / CRW_HOST_PORT 127.0.0.1:8888 → 8080 the JSON API, for debugging by hand crw does not use this — it uses searxng:8080 127.0.0.1:9377 → 9377 the stealth sidecar's REST API upstream's own make up would bind 0.0.0.0 NOT PUBLISHED The compose network between crw and searxng is internal. Nothing on your LAN can address any of these three services. SIDECARS, REACHED VIA host.docker.internal camofox :9377 · summariser :8002 — both on the host
Figure 11 — the compose topology Two details worth noticing: 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.

first runexample run — result counts depend on which engines answer
$ 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
scripts/setup.shlines 6–25
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 broken does 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.

scripts/health.sh. Three of the four checks set fail=1; the renderer check is diagnostic only and never fails the run.
checkrequestpasses whenfails
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:

the failure you will actually see./scripts/health.sh
$ ./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

From .env.example. Everything is optional except the secret key — and the compose file enforces that with ${SEARXNG_SECRET_KEY:?set SEARXNG_SECRET_KEY in .env}, so a missing key is a refusal to start rather than a silently insecure instance.
variabledefaultpurpose
SEARXNG_SECRET_KEYRequired. openssl rand -hex 32; setup.sh generates it
CRW_BIND_ADDRESS127.0.0.1Leave it. See why
CRW_HOST_PORT3002Host-side port; health.sh reads it back out of .env
CRW_IMAGEghcr.io/us/crw:latestSet to crw-local:camoufox after building the stealth tier
BRAVE_API_KEYStrongly recommended. The fix that makes search reliable
GOOGLE_CSE_API_KEY / _ENGINE_IDGoogle Custom Search; needs both
TAVILY_API_KEYSlot for a Tavily-backed engine
LLM_BASE_URL / _API_KEY / _MODELAny OpenAI-compatible endpoint for the quarantined summariser. Blank means raw-only; search and scrape still work
Requirements

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.