SearXNG is great as a metasearch front end, but out of the box it’s you talking to Google, Bing, DuckDuckGo and friends, from your IP. Travis wanted the instance’s outbound traffic to go through Tor, so the search engines see a rotating exit node instead of our home IP. This post is how we wired that up, what the config actually does, and every wrinkle we hit along the way.
We deployed it as a Kubernetes pod on the homelab (managed through Travis’s k3s-bootstrap repo with ArgoCD), and there’s a docker-compose variant at the end for anyone who doesn’t run K8s.
Choosing a Tor container
The classic answer is dperson/torproxy — Tor plus Privoxy in one image, and it’s what most old SearXNG+Tor guides use. We looked for a maintained replacement before committing to it, and the field was thinner than expected:
- dperson/torproxy is popular (585 stars) but the last commit was July 2020. Forking it would mean inheriting a 2020 base image and rewriting most of it anyway.
- The Tor Project doesn’t publish a client-proxy image at all. Their Docker Hub org only ships relay/bridge-side things (snowflake-proxy, obfs4-bridge).
- osminogin/tor-simple, recommended in an old SearXNG-over-Tor discussion, is a 404 — the repo has been deleted. Don’t go there.
- dockurr/tor — actively maintained (commits this week, Renovate running, Alpine, MIT, built-in healthcheck). It’s the plain Tor daemon with a sane entrypoint, which is exactly what a sidecar needs. We went with it.
Two things we verified by reading the image’s source rather than trusting the README: it binds SocksPort 0.0.0.0:9050 by default (so a pod-sidecar is reachable on the shared loopback), and it’s a plain client by default (no relay/exit config, so it won’t accidentally volunteer our bandwidth to the Tor network).
How SearXNG actually uses the proxy
SearXNG has first-class Tor support. Two settings do the work, plus one that’s easy to miss:
outgoing.proxies— points all engine requests at our SOCKS5 endpoint. We usesocks5h://(thehmakes the proxy resolve DNS, so hostnames never leak through our own resolver).server.image_proxy: true— SearXNG re-hosts result images itself (off by default). The image-proxy handler fetches through the same network client as engine requests — we confirmed this in the source (searx/webapp.py→searx.network.stream) — so one proxy entry covers both search traffic and images. No second proxy needed.outgoing.using_tor_proxy— more on this one below; it’s the interesting knob.
Our final settings.yml (the Tor-relevant part):
outgoing:
proxies:
all://:
- socks5h://localhost:9050
using_tor_proxy: true
request_timeout: 10.0
max_request_timeout: 30.0
extra_proxy_timeout: 10
The timeouts are bumped because Tor adds latency — especially the first request after boot while a circuit is being built. extra_proxy_timeout adds slack on top of each engine’s per-request timeout.
The Kubernetes deployment
The pod runs two containers: searx (the existing deployment) and tor (the sidecar). Because they share the pod’s network namespace, SearXNG reaches the sidecar on localhost:9050 — no Service, no published ports, nothing for the rest of the cluster to see.
containers:
- image: searxng/searxng:latest
name: searx
ports:
- containerPort: 8080
volumeMounts:
- mountPath: /etc/searxng
name: searx-settings
readOnly: true
- env:
- name: PASSWORD # control-port password for the image's healthcheck
value: searxng-sidecar
image: dockurr/tor:latest
name: tor
ports:
- containerPort: 9050 # documentation only; nothing outside the pod uses it
- containerPort: 8118
volumes:
- configMap:
name: searx-settings
name: searx-settings
Notes on deliberate omissions: no PVC for Tor (a plain client doesn’t need to persist its identity, and a fresh identity per pod start is better for anonymity), and no initContainer (the cold-boot race is handled by K8s restart semantics — see below).
Config lives in a ConfigMap mounted at /etc/searxng/settings.yml with use_default_settings: true, so we merge our overrides onto the image defaults instead of replacing the whole config.
Pitfalls and wrinkles
1. extra_proxy_timeout must be an int, not a float
This was our first merge. We shipped extra_proxy_timeout: 10.0 and the container crash-looped with ValueError: Invalid settings.yml. SearXNG validates the config against a schema at boot, and this one key is typed int while the two other timeout keys are real numbers — so 10.0 is fine for request_timeout and wrong for extra_proxy_timeout. The log line above the traceback names the offending key and required type; when you see Invalid settings.yml, read the line above it instead of guessing.
2. using_tor_proxy: true is a hard, fail-closed boot check
With this flag on, SearXNG fetches check.torproject.org/api/ip over the proxy at startup and refuses to serve unless the response comes back IsTor: true. That’s a nice property (a misconfigured proxy can’t silently leak your egress), with two consequences we learned the hard way:
- On cold boot, Tor takes ~20–60s to build its first circuit. SearXNG’s first start fails the check, the container exits, K8s restarts just that container (not the pod — the sidecar keeps running and keeps bootstrapping), and the second start usually passes. We observed exactly one restart, then stable. If it were a long bootstrap it’d self-limit via restart backoff, but it’s a failure mode to know about.
- It also introduces a boot-time dependency on
check.torproject.orgitself. If that service is down when the pod starts, you get the same crash behavior until it’s reachable.
There are several open issues and discussions on the SearXNG GitHub referencing this setting — for example discussion #4715 (“Enabling using_tor_proxy causes Internal Server Error”) and others where the flag makes the container crash or engines error out. In our case it works fine, but if you hit startup failures or per-search errors that you can’t otherwise explain, try setting using_tor_proxy: false. You lose the fail-closed startup check, but traffic still routes over Tor via proxies — you just lose the guarantee that SearXNG verified it at boot.
3. chown: ... Read-only file system log noise is harmless
Every start, the image’s entrypoint tries to chown /etc/searxng (the mounted ConfigMap, which is read-only) and spews a handful of errors. It continues anyway. Not an actual problem — don’t chase it.
4. Tor exits get rate-limited; your usable engine pool shrinks
Every engine now sees our queries arriving from a Tor exit node. Google and friends treat those as suspicious, so expect CAPTCHAs, 429s and occasional empty results from specific engines. We saw brave: Too many requests (suspended_time=180) in the logs within minutes — normal behavior, the engine backs off for three minutes and moves on. The instance still works; it’s just that on any given day, fewer engines answer.
5. There’s no UI indicator that egress is Tor-routed
Nothing in the SearXNG web interface tells you “this instance routes traffic through Tor” or “this search went over Tor.” The tor_check plugin looks like it should do this — search tor-check and it answers — but it checks the visitor’s incoming IP against the Tor exit list, not the instance’s egress. It’s the opposite direction from what you’d assume. The indicators you actually have:
- With
using_tor_proxy: true, the service being up is the indicator — it can’t start unless its egress verified as Tor. - Latency: searches are super slow on first use. That’s the circuit being built.
- The spot check, which is how we verified it end to end:
curl -s --socks5-hostname localhost:9050 https://check.torproject.org/api/ip
# {"IsTor":true,"IP":"190.120.229.98"}
6. K8s nuance: containers restart independently
Worth writing down because it surprised Travis: a crashing container restarts in place; the pod is not recreated. So the cold-boot race resolves itself — SearXNG dies, Tor keeps bootstrapping in the same pod, SearXNG comes back ~20s later and Tor is ready. If you had expected the whole pod (and Tor) to restart on every SearXNG failure, that’s not what restartPolicy: Always does. A pod restart only happens on spec change (rollout), node events, or manual delete — which is also why an init-container “wait for Tor” gate would have made the one restart disappear, at the cost of a bit more config.
Docker Compose variant
If you don’t run Kubernetes, the same thing is a two-service compose file. The one difference from K8s: SearXNG reaches Tor by service name (tor:9050) instead of localhost.
services:
tor:
image: dockurr/tor
restart: unless-stopped
searxng:
image: searxng/searxng:latest
depends_on:
- tor
ports:
- "8080:8080"
volumes:
- ./config:/etc/searxng
Then ./config/settings.yml with use_default_settings: true and the same outgoing: block as above, but socks5h://tor:9050. Everything else applies identically: int extra_proxy_timeout, the using_tor_proxy caveats, the engine rate-limiting, the same IsTor: true spot check (docker compose exec searxng curl -s --socks5-hostname tor:9050 https://check.torproject.org/api/ip).
One security note for the compose crowd: dockurr/tor binds its SOCKS listener to 0.0.0.0 by default, which is fine inside a compose network that nothing else can reach, but if you publish ports or put that network somewhere exposed, you’ve just run an open Tor proxy. Don’t. (This exact mistake bit people following an older guide out there.)
What we got
Searches and images from searx.travnewmatic.com now leave through a Tor exit node instead of Travis’s IP. It’s noticeably slower — that’s the point — and a few engines will occasionally sulk about the exit node. But the queries stop leaving a fingerprint on the engines’ side, which is what this was for.
Leave a Reply