SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Fix SearXNG Rate Limits and 429 Errors

SearXNG 429 errors have two causes: your own limiter, or the engines blocking your server IP. Read the log, fix the right one, and stop guessing.

Why SearXNG returns 429 errors

A self-hosted SearXNG instance returns 429 errors for two unrelated reasons, and the rate limit you need to fix is usually not the one you assume. The first reason is local: SearXNG's own limiter decided a request came from a bot and answered Too Many Requests with status 429. The second is upstream: a search engine refused your server's IP address, which reaches your users as a results page with things missing, not as a 429.

The two cases share no fix. The limiter is yours, so you can change it. Upstream blocking happens on Google's side, so nothing in your settings.yml will lift it. The log tells you which one you have in about a minute, so start there.

This guide assumes the container install described in a self-hosted SearXNG instance on your own VPS. Every setting name below comes from current upstream documentation and source, checked in August 2026.

Read the log before you change a setting

Reproduce the problem with a log window open.

cd ./searxng/
docker compose logs -f searxng-core

Limiter messages come from the logger named searx.limiter and they name an IP address. A blocklist hit reads BLOCK 203.0.113.10: matched BLOCKLIST, and an allowlist hit reads PASS 203.0.113.10: matched PASSLIST. If the limiter cannot reach its counter store, the log says The limiter requires Valkey, please consult the documentation, which means nothing is being counted at all.

Each individual bot check is logged at debug level, so you will not see it by default. Turn debug on for one test in settings.yml:

general:
  debug: true

The log then adds lines shaped like NOT OK (http_accept_language) next to the client network, naming the check that failed. Turn it off again afterwards, because upstream tells you not to run a deployed instance with debug on.

Engine failures look nothing like that. They name an engine instead of an IP, and the most common one is a timeout:

HTTP requests timeout (search duration : 3.1 s, timeout: 3.0 s)

There is also a page for this. With enable_metrics left at its default of true, your instance records engine errors at /stats/errors, and /preferences lists which engines are currently answering. If /stats/errors is full and the log holds no searx.limiter lines, the limiter is not your problem.

Pin the version before you debug anything

The upstream container setup is two files.

mkdir -p ./searxng/core-config/
cd ./searxng/

curl -fsSL \
    -O https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml \
    -O https://raw.githubusercontent.com/searxng/searxng/master/container/.env.example

cp -i .env.example .env

The compose file pulls docker.io/searxng/searxng:${SEARXNG_VERSION:-latest}. An unset variable means latest, and latest means the instance changes under you on the next docker compose pull, so a setting that worked last week can stop matching the code that reads it. SearXNG tags carry a date and a commit. The example tag in the upstream .env.example as of August 2026 is 2026.3.25-541c6c3cb, so set a real one in .env:

SEARXNG_VERSION=2026.3.25-541c6c3cb

Check the published tags and pin the release you actually tested, then debug against a fixed target. The same .env file holds your secret key, so read how env files and secrets work in Docker Compose before you commit that directory anywhere.

The limiter needs Valkey, or it does not run

The limiter counts requests per client, and those counts have to be shared across worker processes. That store is Valkey, the maintained fork of Redis. Older SearXNG guides call this setting redis:. Current releases read valkey:, so copy the key name from the current documentation rather than from an older post.

use_default_settings: true
server:
  secret_key: "change-this-value"
  limiter: true
  public_instance: false
valkey:
  url: valkey://searxng-valkey:6379/0

The upstream compose file already runs a searxng-valkey service on the docker.io/valkey/valkey:9-alpine image, so that host name resolves inside the compose network. The same value can be set with the SEARXNG_VALKEY_URL environment variable, and a Unix socket URL (unix:///path/to/socket.sock?db=0) works when SearXNG and Valkey share a host.

What happens when the store is missing depends on one other key. With public_instance: false, the limiter logs the Valkey error and gives up, so the instance keeps serving with no rate limiting at all. With public_instance: true, the process calls sys.exit(1) instead, because an open instance with broken bot protection collects CAPTCHAs (completely automated public turing test to tell computers and humans apart) from every engine within a day. A container that restarts in a loop right after you set public_instance: true is this, and the last line before each exit names Valkey.

What the limiter actually counts

ChartSearXNG limiter: requests allowed per client IP, defaults in ip_limit.py
The data behind this chart
[
  {
    "label": "Burst, normal client",
    "max_requests": 15,
    "window": "20 seconds"
  },
  {
    "label": "Burst, flagged client",
    "max_requests": 2,
    "window": "20 seconds"
  },
  {
    "label": "Sustained, normal client",
    "max_requests": 150,
    "window": "10 minutes"
  },
  {
    "label": "Sustained, flagged client",
    "max_requests": 10,
    "window": "10 minutes"
  },
  {
    "label": "Any non-HTML format",
    "max_requests": 4,
    "window": "1 hour"
  },
  {
    "label": "Flagged requests before block",
    "max_requests": 3,
    "window": "30 days"
  }
]

A normal client gets 15 requests inside a 20 second burst window and 150 inside a 10 minute window. Once a request is flagged as suspicious, the same client drops to 2 per burst window. The last row is the harshest: after 3 flagged requests inside a 30 day window, that address is redirected to the start page instead of searching, and the log says BLOCK: too many request from ... in SUSPICIOUS_IP_WINDOW (redirect to /).

These numbers are constants in searx/botdetection/ip_limit.py. They are not settings, and limiter.toml does not expose them, so changing them means editing the source. What /etc/searxng/limiter.toml does control is the address prefixes used to group clients, the list of trusted proxies, the optional link token check, and the pass and block lists.

A request gets flagged as suspicious by header checks, and each check has a name you will see in the debug log:

  • http_accept: the Accept header does not contain text/html.
  • http_accept_encoding: the Accept-Encoding header names neither gzip nor deflate.
  • http_accept_language: there is no Accept-Language header.
  • http_connection: the Connection header is set to close.
  • http_user_agent: the User-Agent is missing or matches a known bot pattern.
  • http_sec_fetch: the Sec-Fetch-Mode or Sec-Fetch-Dest header is not what a browser sends.

A browser sends all of these. A plain curl call sends almost none of them, so a hand-written test request is flagged on its first try while the same search works in a browser tab. That is why "it works in my browser, but my script gets 429" is the normal result rather than a mystery.

Behind a reverse proxy the limiter blocks everyone at once

This is the most common way to break a working instance. SearXNG takes the client address from the first untrusted IP in X-Forwarded-For, falls back to X-Real-IP, and falls back again to the address that opened the connection. Whether those headers are believed at all is decided by trusted_proxies in limiter.toml.

If your proxy's address is not in that list, the headers are ignored and every visitor arrives wearing the proxy's address. They then share one counter, so the whole site is blocked together once the total crosses 150 requests in 10 minutes. One user reloading a results page a few times takes everybody down with them.

Trusting too much is worse. If a public range is listed, any visitor can send their own X-Forwarded-For header and pick a fresh identity for every request, which turns the limiter off for anyone who knows to try it. List only the address your own proxy connects from. In Docker that is usually a bridge network inside 172.16.0.0/12, and that line ships commented out.

[botdetection]
ipv4_prefix = 32
ipv6_prefix = 48

trusted_proxies = [
  '127.0.0.0/8',
  '::1',
  '172.16.0.0/12',
]

The proxy has to send the headers as well. Nginx adds none of them on its own:

location / {
    proxy_pass http://127.0.0.1:8080;

    proxy_set_header Host              $host;
    proxy_set_header Connection        $http_connection;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
}

Caddy and Traefik set the forwarded headers for you, so with those you only need the trusted_proxies half of the job. The trade-offs are covered in choosing a reverse proxy for a self-hosted service. To verify either setup, turn debug on, search once from your phone on mobile data, and confirm the network in the log line is your phone's address rather than the proxy's.

Your agent gets four API requests per hour

The JSON output is disabled by default, so an agent needs it added:

search:
  formats:
    - html
    - json

Now read the chart row again. Any request asking for a format other than HTML is counted in its own window: 4 requests per 1 hour, per address. A research agent burns that in one task, and every call after it returns 429. Raising the limit is not an option, because the number lives in the source.

The clean fix is to tell the limiter that this client is not a stranger. Add its address to the pass list in limiter.toml:

[botdetection.ip_lists]
block_ip = []

pass_ip = [
  '10.8.0.0/24',
]

pass_searxng_org = true

pass_ip has priority over every other method, so an allowlisted client skips the header checks too and a bare curl call works. Keep the range as small as you can, and prefer a VPN subnet or a container network over anything routable. The other clean fix is to keep the agent off the public path completely: point it at the container address on the internal network, where the proxy and its limiter never see the traffic. Wiring that up is covered in giving an AI agent a SearXNG search skill.

The option to avoid is pointing an agent at a public instance somebody else runs. That is the fastest way to get a volunteer's IP address blocked by upstream engines, and it is why the JSON format is disabled by default in the first place.

When the engines block you instead

ChartHow long SearXNG suspends an engine, search.suspended_times defaults
The data behind this chart
[
  {
    "label": "SearxEngineTooManyRequests",
    "suspended_seconds": 3600,
    "roughly": "1 hour"
  },
  {
    "label": "SearxEngineAccessDenied",
    "suspended_seconds": 86400,
    "roughly": "1 day"
  },
  {
    "label": "SearxEngineCaptcha",
    "suspended_seconds": 86400,
    "roughly": "1 day"
  },
  {
    "label": "recaptcha_SearxEngineCaptcha",
    "suspended_seconds": 604800,
    "roughly": "7 days"
  },
  {
    "label": "cf_SearxEngineCaptcha",
    "suspended_seconds": 1296000,
    "roughly": "15 days"
  }
]

When an engine answers with its own 429 or with a CAPTCHA page, SearXNG raises a named exception and stops asking that engine for a while. A too-many-requests answer suspends it for 3600 seconds. A plain CAPTCHA or an access-denied answer suspends it for 1 day. A CAPTCHA served through Cloudflare suspends it for 15 days, the longest default in the list, because that answer means the block sits at the edge and retrying will not help.

Ordinary failures use different settings. A timeout or a parse error suspends the engine for a short time derived from search.ban_time_on_fail, which defaults to 5 seconds and is capped by search.max_ban_time_on_fail at 120 seconds. So a slow engine recovers on its own within a couple of minutes, while a blocked engine is gone for hours. That difference explains a symptom people report as random: results are fine, then one engine's results vanish for the rest of the afternoon.

Timeouts are worth fixing before you blame anyone. The default request_timeout is 2.0 seconds, which is tight for a small VPS sitting far from an engine's nearest edge server.

outgoing:
  request_timeout: 3.0
  max_request_timeout: 10.0
engines:
  - name: bing
    timeout: 5.0

request_timeout is the default for every engine, max_request_timeout is the ceiling, and a single engine can carry its own timeout. Raising these trades page latency for fewer failures, so move in half seconds and watch /stats/errors rather than jumping straight to 10.

For an engine that is genuinely blocking your address, remove it. Every search waits on its slowest engine, so keeping a permanently suspended one costs latency and returns nothing.

use_default_settings:
  engines:
    remove:
      - google

Apply changes with docker compose restart searxng-core, then run a few searches and reload /stats/errors. An empty page after five minutes of real use means the change worked.

A datacentre IP will be treated as a bot

Your VPS address belongs to a hosting range, and the large engines score those ranges as automation. Some of them serve a CAPTCHA to every request from such an address no matter how polite the headers or how slow the pace. No setting in settings.yml changes that judgement.

What you can change is which engines you ask and whether your instance is listed publicly. A private instance used by one household rarely trips anything. A public instance on a hosting IP will collect suspensions on the strictest engines, and that is the normal state of the software rather than a fault in your config. SearXNG can route engine requests through a proxy with outgoing.proxies or outgoing.using_tor_proxy, which moves the traffic to a different address. Exit nodes and cheap proxy pools are scored worse than hosting ranges, so expect that move to make results worse.

Watch the instance so you find out first

SearXNG answers on its port even when every engine is suspended, so an uptime check that only watches the status code stays green while the instance returns nothing. Check the content instead: request a real search and match a word you expect in the response body. Uptime Kuma keyword monitoring does exactly that with no extra tooling. Watch /stats/errors after every version bump too, because engines change their HTML and a parser breaks with no rate limit involved.

FAQ

Why does SearXNG return 429 to every visitor after I put it behind a reverse proxy?

Because the limiter is counting the proxy as the client. SearXNG only reads X-Forwarded-For when the connecting address is listed in trusted_proxies in /etc/searxng/limiter.toml. If it is not listed, every visitor shares one counter and they all cross the 150 requests per 10 minutes line together. Add the address your proxy connects from, which in Docker is usually the bridge range 172.16.0.0/12, and make sure the proxy sends X-Real-IP and X-Forwarded-For. Never list a range you do not control, because a trusted network lets any visitor set that header and choose a new identity for every request.

How many API requests per hour does the SearXNG limiter allow?

Four per IP address per hour. Any request asking for a format other than HTML counts in a separate one hour window, and that limit is set in searx/botdetection/ip_limit.py rather than in limiter.toml, so it cannot be raised from config. An agent or a script passes it in one task. Add the client's address to pass_ip in limiter.toml, or reach the instance over an internal network where the limiter never sees the request.

Why do my search results come back empty with no 429 error?

The engines are refusing your server, not your users. Open /stats/errors on your own instance: it names each engine that failed and why, and a CAPTCHA or access-denied entry means that engine blocked your server's IP address. SearXNG then suspends the engine, for an hour after a too-many-requests answer and for a day after a CAPTCHA. No local setting lifts an upstream block, so remove the engines that block your address and keep the ones that answer.

Should I enable the limiter on a private instance?

If nothing reaches the instance except you, leave limiter: false. It adds a Valkey dependency and it blocks your own scripts, and it protects against traffic you do not have. Enable it the moment the instance gets a public address, together with public_instance: true. That pair is deliberate: with public_instance: true and no working Valkey, the process exits with status 1 instead of running unprotected.