Layer 3 · SearXNG
Every enabled engine, in parallel, from your IP address.
SearXNG turns a query into candidate URLs by asking a lot of search engines at once and merging what comes back. There is no sequential fallback. That single design fact explains both why this layer is cheap and why it fails the way it does.
This is the layer that will break. Not because SearXNG is fragile, but because scraping search engines from a residential IP under sustained agent traffic is a thing search engines actively defend against. The repository's response is not to pretend otherwise — the README has a section titled When search returns nothing that opens with "This will happen."
TopologyThe fan-out
Failure modeWhen search returns nothing
The dangerous property of this failure is not that it happens, but that it is quiet. Individual engines being suspended is normal and invisible — the merge still returns results. When the default general engines suspend together, the merge returns an empty list, and every layer above it faithfully reports zero results with no error anywhere.
# the one-liner from the README $ curl -s "http://127.0.0.1:8888/search?q=test&format=json" | jq .unresponsive_engines # or let the health script say it — it checks this layer first, alone $ ./scripts/health.sh ── searxng ── FAIL — 0 results. Engines are probably CAPTCHA'd/rate-limited: ['google', 'CAPTCHA'] ['brave', 'HTTP error'] ['duckduckgo', 'CAPTCHA'] # exact shape varies by build See README → 'When search returns nothing'. ── crw ── OK — v(your pinned image)
Note what that transcript shows: crw is fine. The engine is healthy, the container is
up, scraping works. Only the layer that finds URLs is broken. This is exactly why
health.sh checks four things separately instead of running one end-to-end query — a
single composite check would have told you "search is broken" and left you reading crw logs.
The fixes, ranked by how much they actually help
| fix | what it does | honest verdict |
|---|---|---|
| 1. Add API-backed engines | Engines that authenticate instead of scraping keep working when the scraped ones are suspended | The only fix that makes search reliable |
| 2. Keep the extra crawlers on | mojeek, marginalia, stract, mwmbl, right dao, yep — independent indexes, different limits | The difference between fewer results and zero |
3. Set useragent_suffix |
Puts a real contact address in your User-Agent | SearXNG's docs: it helps "if an engine wants to block you" |
| 4. Wait | Nothing | Suspensions are temporary |
| 5. Route through a different IP | ssh -D 1080 user@host is a free SOCKS5 proxy on a machine you already own; SearXNG rotates round-robin across outgoing.proxies and picks a different one on each retry |
Works, if you have somewhere to route through |
Operators log traffic and inject content, and cheap "residential" pools are frequently sourced from malware-infected devices rather than opt-in consent. You would be routing your searches through someone else's compromised router — in a setup where the same agent may be holding your credentials. The proxy fix is only a fix if you own the exit.
ConfigurationThree ways to fail silently
Adding an API-backed engine to SearXNG is four lines of YAML and about an hour of your life, because all three of the ways it goes wrong produce no error message. The repository documents them in the config file itself, next to the block you are about to uncomment.
inactive: false is required
Upstream ships API engines with inactive: true, which removes them from the
engine registry entirely. It is stronger than disabled and is not
overridden by disabled: false. Without it the engine simply never exists — no
error, no log line, and even its !bang shortcut is unrecognised.
!!env VAR does not work
Current SearXNG builds hard-fail settings load with could not determine a constructor
for the tag tag:yaml.org,2002:env and the container restart-loops. Inline the key in the
file — it is gitignored in a real deployment — or template it in before the container starts.
This one at least fails loudly, but it fails as a crash loop rather than a config error.
request_body must be doubled
json_engine runs the body through Python's .format(), so a literal
{ opens a placeholder and you get KeyError: '"q"' at
json_engine.py: request_body.format(**fp). Write {{ and
}} for literal JSON braces. Nothing upstream documents this.
json_engine supports method, headers
and request_body, which between them cover POST APIs with header authentication.
113 - name: serper 114 engine: json_engine 115 shortcut: srp 116 categories: [general, web] 117 inactive: false 118 disabled: false 119 timeout: 5.0 120 search_url: https://google.serper.dev/search 121 method: POST 122 headers: 123 X-API-KEY: 'YOUR-SERPER-KEY' 124 Content-Type: application/json 125 request_body: '{{"q": "{query}", "num": 10}}' 126 results_query: organic 127 url_query: link 128 title_query: title 129 content_query: snippet
- lines 126–129 — the shape adapter
- Four jq-ish paths are all it takes to map a foreign JSON response onto SearXNG's result model: where the list lives, and which keys hold URL, title and snippet. No Python.
- line 119 —
timeout: 5.0 - Higher than
outgoing.request_timeoutof 3.0 at line 32, because a paid API you are waiting on is worth a longer leash than a scraped page you can afford to lose. - line 125 —
num: 10 - Not arbitrary. Serper bills 1 credit for up to 10 results and 2 credits for 11–100, so asking for 11 doubles the price of every search for one extra link.
| option | engine: | cost | notes |
|---|---|---|---|
| Serper | json_engine | $1 / 1k | 2,500/mo free tier, no card required; POST with header auth |
| Brave Search API | braveapi | $5 / 1k | Native engine module, results_per_page: 20 |
| Google CSE | google_cse | — | Needs both api_key and a cx engine ID |
ConfigurationThe rest of the file
Everything not about engines is about being a private instance that a machine talks to, rather than a public one that people use.
13 # JSON must be enabled explicitly — SearXNG returns 403 on ?format=json 14 # otherwise, and every downstream call silently fails. 16 formats: 17 - html 18 - json … 22 bind_address: "0.0.0.0" # container-internal; compose publishes to loopback 26 limiter: false 27 public_instance: false 28 image_proxy: false … 32 request_timeout: 3.0 36 useragent_suffix: "" 37 retries: 3
- lines 16–18 —
json - The single most important line in the file. Without it,
?format=jsonreturns 403, crw gets nothing, and nothing in the stack says why.htmlstays enabled so the web UI at127.0.0.1:8888remains usable for debugging by hand. - line 22 —
0.0.0.0 - Looks alarming, is not: this is the bind inside the container. The compose file
publishes it as
127.0.0.1:8888, so the only interface it is reachable on is your loopback. - line 26 —
limiter: false - SearXNG's own rate limiter needs Valkey or Redis. Skipped deliberately — one less container for a private instance. The file's comment is emphatic: do not skip it if you expose the instance.
- line 36 —
useragent_suffix - Empty by default and worth filling in with a real contact address. It makes you look like an identifiable small instance rather than an anonymous scraper.
SearXNG queries every enabled engine in parallel — there is no sequential fallback, so a second paid key is not a "backup", it is a second bill on every search.
README.md — When search returns nothing