How to Run SearXNG as a Private Search API for Agents
Updated October 27, 2026 .md
Short answer: Run the official searxng/searxng Docker image in a small Sandboxer sandbox, add json to search.formats, set a random secret_key, turn the limiter off, bind it to 127.0.0.1 and host it with sbx host --localhost. Agents then call /search?format=json with the __sbx_auth cookie. Which engines answer depends on upstream blocking.
What we ran#
Everything below ran on production on October 27, 2026, in one small sandbox (2 vCPU, 4 GB) in eu-central, with SearXNG 2026.10.27-ef7bae9dd from the searxng/searxng:latest image. We sent about 25 searches in total. Timings are single runs or the range of five.
| Step | Result |
|---|---|
sbx new --type small until ready |
2 s |
Pull the 265 MB image, start it, wait for /healthz |
about 8 s |
| Search with the default engines plus Bing, inside the sandbox | 0.51 to 0.76 s, 27 to 50 results |
| Same search from a laptop over the private hosted URL | 0.76 to 1.0 s (one cold first query took 3.4 s) |
sbx stop --wait, then sbx resume |
2.8 s and 2.1 s, memoryRestored: true, first search 0.74 s later |
SearXNG is a metasearch engine: it doesn't index the web, it forwards your query to Brave, Bing, Google and others and merges the results. So the real question for an agent is which upstream engines still answer from a datacenter IP. Section 3 has what we saw, per engine.
1. Write a settings file for one private client#
SearXNG's Docker install docs mount a config directory at /etc/searxng and a cache directory at /var/cache/searxng. Create settings.yml before the first start. Save this as setup.sh on your laptop:
mkdir -p ~/searxng/config ~/searxng/data
cat > ~/searxng/config/settings.yml <<YML
use_default_settings: true
server:
secret_key: "$(openssl rand -hex 32)"
limiter: false
public_instance: false
image_proxy: false
search:
formats:
- html
- json
YML
chmod 600 ~/searxng/config/settings.ymlThen create the sandbox and run the script in it. The $(openssl ...) runs inside the sandbox, so the secret never appears on your laptop or in your terminal:
sbx new --name searxng --type small --no-env
id=sb_ftoc7rtk1a # the id that sbx new printed
sbx exec $id "$(cat setup.sh)"What each setting does, from SearXNG's server settings and search settings:
| Setting | Why |
|---|---|
use_default_settings: true |
Keep every default and override only what you list. |
secret_key |
Signs SearXNG's own cookies and tokens. A random value per instance. |
search.formats with json |
Without it, format=json is refused. We asked for format=csv, which we didn't enable, and got 403. |
limiter: false |
The limiter is bot protection for a public instance and needs a Valkey database. Here the Sandboxer token already keeps strangers out, and one agent firing searches in a burst would look like a bot. |
public_instance: false |
Leaves off the features meant for public instances. |
image_proxy: false |
Your agent reads URLs, it doesn't need SearXNG to proxy thumbnails. |
2. Start it on 127.0.0.1 and wait for it#
Docker is preinstalled in every sandbox. Publish the port on 127.0.0.1 only, so nothing reaches SearXNG except through the hosted URL:
sbx exec $id --timeout 600 "cd ~/searxng && docker run -d --name searxng --restart unless-stopped \
-p 127.0.0.1:8080:8080 -v \$PWD/config:/etc/searxng -v \$PWD/data:/var/cache/searxng \
docker.io/searxng/searxng:latest >/dev/null \
&& until curl -sf -o /dev/null localhost:8080/healthz; do sleep 0.3; done; docker logs searxng 2>&1 | grep -E 'SearXNG|Listening'"SearXNG 2026.10.27-ef7bae9dd
[INFO] Listening at: http://[::]:8080The log also showed ERROR:searx.botdetection: X-Forwarded-For nor X-Real-IP header is set! and a warning about a missing limiter.toml. With the limiter off, searches worked anyway, so you can ignore both. Requests through the hosted URL do carry X-Forwarded-For and X-Real-IP, set by the Sandboxer proxy from the real connection; localhost calls from inside the sandbox don't.
latest moves every day. For a setup you want to repeat, pin the tag the log printed.
One gotcha: the container takes ownership of the config directory. Afterwards, cat >> settings.yml from the sandbox user failed with Permission denied (owner uid 977, mode 600). Edit it with sudo, as in the next section.
3. See which engines answer from a datacenter#
Ask for one engine at a time with the engines parameter and read unresponsive_engines in the reply. The search API docs list the parameters. This ran inside the sandbox, one query per engine, a second apart:
for e in brave "google cse" duckduckgo wikipedia google bing qwant; do
q=$(jq -rn --arg e "$e" '$e|@uri')
curl -s "localhost:8080/search?q=sqlite+wal+mode&format=json&engines=$q" \
| jq -c --arg e "$e" '{engine:$e, results:(.results|length), unresponsive:.unresponsive_engines}'
sleep 1
doneWhat we got on October 27, 2026, from this sandbox's IP:
| Engine | Enabled by default in this build | Results | What happened |
|---|---|---|---|
| Brave | yes | 25 | Worked on every query |
| Google CSE | yes | 20 | Worked on every query |
| DuckDuckGo | yes | 0 | CAPTCHA on every query |
| Wikipedia | yes | 0 | No error; it fills infoboxes, not result lists |
| no | 0 | access denied |
|
| Bing | no | 10 | Worked here; returned 0 with no error on 2 of 5 later queries |
| Qwant | no | 0 | access denied |
| Startpage, Mojeek | no | n/a | Marked inactive in this build's defaults because they use proof-of-work captchas |
This is a snapshot from one IP on one day. Engines change their blocking often, and results can differ by address. Run the loop yourself, and check unresponsive_engines in production code rather than trusting this table.
Based on that run, we turned Bing on and DuckDuckGo off, so every search doesn't wait on a captcha. Engine overrides go in an engines: list. Append it with sudo and restart:
sbx exec $id "sudo tee -a ~/searxng/config/settings.yml >/dev/null <<'YML'
engines:
- name: bing
disabled: false
- name: duckduckgo
disabled: true
YML
docker restart searxng >/dev/null; until curl -sf -o /dev/null localhost:8080/healthz; do sleep 0.3; done
curl -s localhost:8080/config | jq -c '[.engines[]|select(.enabled and (.categories|index(\"general\")))|.name]'"["wikipedia","bing","currency","wikidata","google cse","lingva","dictzone","mymemory translated","brave"]Five searches with that set, inside the sandbox, took 0.51 to 0.76 s and returned 27 to 50 results each. Brave gave 20 to 26, Google CSE 20, Bing 10 or 0.
4. Give it a private HTTPS URL#
SearXNG listens on 127.0.0.1, so host the port with --localhost. Private is the default, as covered in the hosting guide:
sbx host $id 8080 --localhost --title SearXNG{"ok":true,"type":"port.hosted","sandboxId":"sb_ftoc7rtk1a","port":8080,"access":"private","isProtected":true,"localhost":true,"url":"https://rapid-happy-hawk-8080.box.sandboxer.com?_token=<token>"}From the laptop:
| Request | Status |
|---|---|
GET /search?q=test&format=json, no token |
401 from the Sandboxer proxy |
GET /?_token=<token> |
302 to /, with set-cookie: __sbx_auth=<cookie>; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax |
GET /search?q=x&format=json&_token=<token> |
302, not JSON: a GET with the token is always traded for the cookie first |
GET /search?q=sqlite+wal+mode&format=json with Cookie: __sbx_auth=<cookie> |
200, 44 results, first https://www.sqlite.org/wal.html |
So a script makes one request with the token, keeps the cookie, and sends it on every search. The cookie lasts 30 days. Treat it like a password.
5. A web_search tool for your agent#
This is plain Python with no dependencies. It trades the private URL for the cookie once, then exposes web_search and a tool definition in the JSON Schema shape most agent frameworks accept:
# searx_tool.py: a web_search tool backed by a private SearXNG URL.
import json, os, urllib.parse, urllib.request
PRIVATE_URL = os.environ["SEARXNG_URL"] # https://<sub>-8080.box.sandboxer.com/?_token=...
BASE = PRIVATE_URL.split("?")[0].rstrip("/")
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def _cookie() -> str:
"""Trade the ?_token= URL for the __sbx_auth cookie once."""
opener = urllib.request.build_opener(_NoRedirect)
try:
opener.open(PRIVATE_URL)
except urllib.error.HTTPError as e: # the 302 lands here
return e.headers["Set-Cookie"].split(";")[0]
raise RuntimeError("expected a 302 with Set-Cookie")
COOKIE = _cookie()
def web_search(query: str, max_results: int = 5) -> dict:
qs = urllib.parse.urlencode({"q": query, "format": "json"})
req = urllib.request.Request(f"{BASE}/search?{qs}", headers={"Cookie": COOKIE})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.load(r)
return {
"results": [
{"title": x["title"], "url": x["url"], "snippet": x.get("content", "")[:300],
"engines": x["engines"]}
for x in data["results"][:max_results]
],
"unresponsive_engines": data["unresponsive_engines"],
}
# Tool definition in the JSON Schema shape most agent frameworks accept.
WEB_SEARCH_TOOL = {
"name": "web_search",
"description": "Search the web. Returns titles, URLs and snippets.",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "default": 5},
},
"required": ["query"],
},
}
if __name__ == "__main__":
import sys
print(json.dumps(web_search(" ".join(sys.argv[1:]), 3), indent=2))We called the function directly from the laptop. We didn't connect a model, so there's no model transcript here:
SEARXNG_URL='https://rapid-happy-hawk-8080.box.sandboxer.com?_token=<token>' python3 searx_tool.py sqlite wal mode{
"results": [
{
"title": "Write-Ahead Logging - SQLite",
"url": "https://www.sqlite.org/wal.html",
"snippet": "Beginning with version 3.22.0 (2018-01-22), a read-only WAL-mode database file can be opened if the -shm and -wal files already exis…",
"engines": ["brave", "google cse"]
},
…
],
"unresponsive_engines": []
}Returning unresponsive_engines to the model is deliberate: when an engine is blocked, the agent can tell thin results apart from a real absence of results. To register the tool, pass WEB_SEARCH_TOOL to your framework and route its calls to web_search. The OpenAI Agents SDK guide shows that wiring for one framework.
6. Stop and resume#
sbx stop $id --wait # 2.8 s
sbx resume $id # 2.1 sWhile the sandbox was stopped, the hosted URL returned 404. After resume, sbx info showed memoryRestored: true, the same cookie still worked, and the first search returned 34 results in 0.74 s. Memory was restored, so the container was the same running process.
If a resume lands on a different CPU type, the sandbox boots from disk instead. The config is on disk, and --restart unless-stopped starts the container again. We didn't force that case in this run.
A small sandbox costs $0.018/hour while it runs and nothing while it's stopped.
FAQ#
Why does format=json return 403?#
json isn't in search.formats. The default settings allow only html. Add json (and csv or rss if you want them) and restart the container.
Should I turn the limiter on?#
Not for one private client behind a Sandboxer token. The limiter is meant to stop bots on a public instance. It needs Valkey, and it could throttle your own agent. If you make the URL --public, turn it on and follow the limiter docs.
Why did DuckDuckGo and Google return nothing?#
They blocked this sandbox's datacenter IP: DuckDuckGo with a CAPTCHA, Google with access denied. SearXNG reports that in unresponsive_engines and still returns what the other engines found. Results may differ on another day or IP.
Can my agent use the token URL directly instead of a cookie?#
Not for GET searches. A GET carrying ?_token= gets a 302 that sets the cookie, so a client has to keep cookies or send Cookie: __sbx_auth=... itself, as the wrapper above does.
How much does one search cost?#
Sandboxer bills the running sandbox per second of run time, not per search. The default engines we used needed no API keys, but upstream engines may rate-limit or block you, so keep agent query volume modest.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.