# How to Run SearXNG as a Private Search API for Agents

> 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.

Source: https://sandboxer.com/guides/run-searxng-in-a-sandbox. Updated 2026-10-27.

**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](https://docs.searxng.org/admin/installation-docker.html) 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:

```bash
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.yml
```

Then 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:

```bash
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](https://docs.searxng.org/admin/settings/settings_server.html) and [search settings](https://docs.searxng.org/admin/settings/settings_search.html):

| 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](https://docs.searxng.org/admin/searx.limiter.html) 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:

```bash
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'"
```

```text
SearXNG 2026.10.27-ef7bae9dd
[INFO] Listening at: http://[::]:8080
```

The 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](https://docs.searxng.org/dev/search_api.html) list the parameters. This ran inside the sandbox, one query per engine, a second apart:

```bash
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
done
```

What 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 |
| Google | 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:

```bash
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]'"
```

```text
["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](/guides/give-a-sandbox-port-an-https-url):

```bash
sbx host $id 8080 --localhost --title SearXNG
```

```text
{"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:

```python
# 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:

```bash
SEARXNG_URL='https://rapid-happy-hawk-8080.box.sandboxer.com?_token=<token>' python3 searx_tool.py sqlite wal mode
```

```text
{
  "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](/guides/run-openai-agents-sdk-tools-in-a-cloud-vm) shows that wiring for one framework.

## 6. Stop and resume

```bash
sbx stop $id --wait     # 2.8 s
sbx resume $id          # 2.1 s
```

While 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](https://docs.searxng.org/admin/searx.limiter.html).

### 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

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Run Meilisearch in a sandbox](/guides/run-meilisearch-in-a-sandbox)
- [Should each agent tool call get a fresh sandbox?](/guides/a-fresh-sandbox-per-tool-call)
- [Hosting](/docs/hosting)
