The SearXNG limiter and why it needs Valkey
The SearXNG limiter keeps its per IP counters in Valkey. Set up limiter.toml in a container or a source install, and see which sections actually matter.
What the SearXNG limiter is
The SearXNG limiter is the bot protection built into SearXNG. It counts requests per client IP address (internet protocol address) and refuses the ones that go over a ceiling. Turning it on takes one line in settings.yml. Configuring how it decides takes a second file, limiter.toml. Running it at all takes a Valkey server, because that is where the counters are kept.
Everything below was checked against the SearXNG documentation and source at release 2026.9.23, on 24 September 2026. SearXNG ships rolling dated releases, so read the upstream limiter documentation as well if your version is much newer than that.
Why the SearXNG limiter needs Valkey
Rate limiting is stateful. To know that an address has made fifteen requests in the last twenty seconds, something has to remember the previous fourteen. SearXNG serves requests from several worker processes, so a counter held inside one process would only ever see part of the traffic, and it would reset on every reload. Valkey is a shared store outside those workers, so every worker increments the same counter and the count survives a restart of SearXNG itself.
Valkey is the open source fork of Redis, started in 2024 after Redis changed its licence. SearXNG names it directly: the settings key is valkey:, and the official container template runs a searxng-valkey service. Releases before that rename used a redis: key, so copy the key name from the current documentation rather than from an older guide.
If the limiter is enabled and no Valkey connection is available, SearXNG logs this line and then serves search with no limiter at all:
The limiter requires Valkey, please consult the documentation: https://docs.searxng.org/admin/searx.limiter.htmlThat is the quiet failure worth looking for. An instance with limiter: true and no Valkey is an unprotected instance, and no part of the page says so. With server.public_instance: true the same condition is fatal instead, because the process exits rather than run a public instance without bot protection.
Install Valkey and point SearXNG at it
On a source install, Ubuntu 24.04 carries the server and the client in its own repositories, so nothing external is needed.
sudo apt update
sudo apt install valkey-server valkey-tools
sudo systemctl enable --now valkey-serverThen set two things in /etc/searxng/settings.yml, the file SEARXNG_SETTINGS_PATH points at. The shipped defaults are limiter: false and url: false, so both lines have to be written out.
server:
limiter: true
valkey:
url: valkey://localhost:6379/0The URL also accepts valkeys:// for a connection wrapped in TLS (transport layer security), and unix:///path/to/valkey.sock?db=0 for a Unix socket. A socket is the tighter choice on a single box because nothing is listening on a port, but the SearXNG user needs permission on the socket file, so check that file's group and mode before assuming the connection is usable.
Under the official container setup you install nothing, because Valkey is a second service. The old searxng-docker repository was archived in March 2026 and is superseded, so take the current template from the main repository:
curl -fsSL \
-O https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml \
-O https://raw.githubusercontent.com/searxng/searxng/master/container/.env.exampleThat template defines a searxng-core service from the docker.io/searxng/searxng image and a searxng-valkey service from docker.io/valkey/valkey:9-alpine, started with valkey-server --save 30 1 --loglevel warning. The host directory ./core-config/ is mounted at /etc/searxng/ inside the core container, and a named volume holds /var/cache/searxng/. Both containers share one compose network, so the URL in your settings names the Valkey service instead of localhost: valkey://searxng-valkey:6379/0.
Where limiter.toml lives
SearXNG reads the limiter configuration from its user configuration folder, which is /etc/searxng/limiter.toml unless you have moved that folder. A default copy of the same file ships inside the Python package and acts as the schema. That shipped copy supplies every default, so your own file only needs the keys you are changing. Copying the whole default file into /etc/searxng/ is the common mistake, because it freezes today's defaults into your configuration and hides later upstream changes behind values you never chose on purpose.
In a container deployment the path inside the container is the same, and the host path is whatever you mounted at /etc/searxng/. With the official template that is ./core-config/limiter.toml, sitting next to ./core-config/settings.yml. The file is read when the worker starts, so recreate or restart the core service after an edit. An edit with no restart changes nothing at all, which looks exactly like a setting that does not work.
Which sections of limiter.toml actually matter
The file has three sections. Most operators only ever touch two of them.
[botdetection]
ipv4_prefix = 32
ipv6_prefix = 48
trusted_proxies = [
'127.0.0.0/8',
'::1',
]
[botdetection.ip_limit]
filter_link_local = false
link_token = false
[botdetection.ip_lists]
block_ip = []
pass_ip = []
pass_searxng_org = trueipv4_prefix and ipv6_prefix set how much of an address counts as one client. A prefix of 32 on IPv4 means each address is its own client. A prefix of 48 on IPv6 means a whole /48 block is treated as one client, and that default is deliberate: home and mobile connections are routinely handed a /56 or a /48, so counting single IPv6 addresses would let one user rotate through billions of them at no cost. Lowering ipv4_prefix to 24 makes a whole /24 count as one client. That stops an abuser renting a subnet, and it also punishes every user sitting behind one large shared NAT (network address translation).
trusted_proxies decides whose word the limiter takes about the client address. The X-Forwarded-For and X-Real-IP headers are only believed when the connection itself arrives from an address in this list. The default covers a proxy running on the same host. In a container deployment the proxy is a separate container with an address on a bridge network, so that network's range has to be added here, or every request is attributed to the proxy.
[botdetection.ip_lists] is the blunt instrument, and it wins. This method has priority over every other check in the limiter, so an address in pass_ip skips bot detection completely and an address in block_ip is refused whatever else it does. Use pass_ip for your own monitoring host or an office range. pass_searxng_org passlists addresses belonging to the SearXNG project, which is what allows the public instance list to check your instance, so set it to false if you are not listed publicly.
link_token inside [botdetection.ip_limit] turns on a stricter mode. A client that asks for the search page without fetching a resource that a real browser would fetch is marked suspicious, and a suspicious client is held to much lower ceilings for the next thirty days. filter_link_local decides whether link local addresses are watched at all.
The limits you cannot change in limiter.toml
Here is the part that surprises people. The actual numbers are constants in searx/botdetection/ip_limit.py, not keys in limiter.toml. As of the 2026.9 releases they are these.
The data behind this chart
[
{
"label": "Burst window, 20 s",
"max_requests": 15
},
{
"label": "Burst window, 20 s, suspicious client",
"max_requests": 2
},
{
"label": "Long window, 600 s",
"max_requests": 150
},
{
"label": "Long window, 600 s, suspicious client",
"max_requests": 10
},
{
"label": "API window, 3600 s, non HTML formats",
"max_requests": 4
}
]A normal client may make 15 requests inside the 20 second burst window and 150 inside the 600 second window. A client marked suspicious by the link token check is cut to 2 and 10 for those same two windows. Requests for a format other than HTML, which means the JSON and CSV output, get their own one hour window with a ceiling of 4.
That last row catches people who build on top of their own instance. Feeding SearXNG's JSON API into Open WebUI means a program making format=json requests, and the built-in ceiling for those is per hour, not per minute. You cannot raise it from limiter.toml. The supported answer is the priority rule above: put the address of the machine running that tool into pass_ip, or keep the tool on the loopback address or a private network and reserve the limiter for the public path.
public_instance overrides part of your file
Setting server.public_instance: true changes two things beyond the limiter flag itself. A missing Valkey becomes fatal at startup instead of a logged warning. And SearXNG forces botdetection.ip_limit.link_token on in memory, whatever your limiter.toml says. So if you wrote link_token = false and still see clients being treated as suspicious, check public_instance before you doubt the file. The wider set of decisions that come with running an instance other people use is covered in hardening a public SearXNG instance.
A per IP limit is not an engine suspension
Two different mechanisms both produce the complaint that SearXNG is blocking a search, and they have different fixes.
The limiter acts on the connection between the reader and your instance. It answers HTTP 429 and no outgoing search happens at all. The cause is always something on your side: a ceiling, a shared address, or a proxy that hides the real client.
An engine suspension acts on the connection between your instance and one upstream search engine. Your instance accepted the search and asked the engine, the engine refused, so SearXNG stops asking that engine for a set time. The results page still returns results from every engine that answered, with an error shown against the suspended one. These are the defaults that decide how long it stays out.
The data behind this chart
[
{
"label": "Engine failure, first ban (ban_time_on_fail)",
"suspend_seconds": 5
},
{
"label": "Repeated failures, ceiling (max_ban_time_on_fail)",
"suspend_seconds": 120
},
{
"label": "Engine answered too many requests",
"suspend_seconds": "3,660"
},
{
"label": "Engine answered with a CAPTCHA",
"suspend_seconds": "86,400"
},
{
"label": "Engine denied access",
"suspend_seconds": "86,400"
}
]An ordinary engine failure suspends that engine for 5 seconds, rising towards the ceiling of 120 seconds set by search.max_ban_time_on_fail. When the engine answers with its own rate limit, the suspension jumps to 3,660 seconds. A CAPTCHA (completely automated public Turing test) or a flat access denial costs 86,400 seconds, a full day. All of these come from search.ban_time_on_fail and search.suspended_times in settings.yml.
Nothing in limiter.toml touches any of that. If your problem is an engine going quiet after upstream errors, the fix belongs on the engine side, and the CAPTCHA and access denied errors from individual engines are a separate job from the limiter. If your problem is your own readers seeing 429, work through why SearXNG returns 429 and how to triage it first, then come back here to change the configuration.
One note on reverse proxies
The limiter counts whatever address it believes belongs to the client, so a reverse proxy that does not set X-Forwarded-For or X-Real-IP, or one whose own address is missing from trusted_proxies, makes every request on the internet look like one very busy client sharing a single bucket. Set the header in the proxy, list the proxy address in trusted_proxies, and treat anything still odd about headers as its own piece of work.
What to check after you restart
- Valkey answers at all: run
valkey-cli pingon the host, ordocker compose exec searxng-valkey valkey-cli pingfor the container setup, and look for a healthy reply rather than a refused connection. - The SearXNG log from startup: search it for
limiter, and confirm the Valkey error quoted earlier is not there. - The database is being written: run
valkey-cli dbsizebefore and after a handful of searches and look for the number to move. A size that never changes with the limiter on means SearXNG is not talking to the server you think it is. - Your own address is seen correctly: put your workstation address in
pass_ip, restart, and look for the behaviour to change. If nothing changes, the address the limiter sees is not the address you believe it sees, which points straight back at the proxy headers.
A working baseline is worth more than any single setting here, so get plain search running first, as in a self hosted SearXNG search instance on a VPS, and add the limiter once the instance answers queries.
FAQ
Where is limiter.toml in a Docker install of SearXNG?
Inside the container it is /etc/searxng/limiter.toml, the same path as a source install. On the host it is whichever directory you mounted at /etc/searxng/, which is ./core-config/ in the official compose template, so the file you edit is ./core-config/limiter.toml beside settings.yml. The file is read when the worker starts, so restart or recreate the core service after every edit. You do not need to create the whole file: only the keys you are changing, because the package ships the defaults.
Does the SearXNG limiter work without Valkey?
No. The counters have to live somewhere shared by all the worker processes, and Valkey is that place. With limiter: true and no reachable Valkey, SearXNG logs that the limiter requires Valkey and then serves search with no protection, which is the dangerous case because nothing on the page shows it. With server.public_instance: true the process exits at startup instead. Install the valkey-server package or run the valkey/valkey container, then set valkey: url: in settings.yml.
Can I change the number of requests the limiter allows?
Not from limiter.toml. The windows and their ceilings are constants in the limiter source, so the 20 second burst window and the 600 second window carry fixed ceilings, and requests for non HTML formats such as JSON have a separate hourly ceiling. What you can change is who those ceilings apply to. Widen ipv4_prefix or ipv6_prefix to change what counts as one client, add trusted addresses to pass_ip so they skip bot detection completely, and leave link_token off unless you want stricter treatment of clients that never fetch browser resources.
Why is one engine failing while the limiter is not blocking anyone?
Because those are two separate mechanisms. The limiter sits between your readers and your instance and answers HTTP 429 before any search goes out. An engine suspension sits between your instance and an upstream provider: the provider refused your server, so SearXNG stops querying that one engine for a while and shows an error for it while other engines still return results. A CAPTCHA or an access denial from a provider suspends that engine for a full day by default. Editing limiter.toml will not shorten it, and the settings that do are search.ban_time_on_fail and search.suspended_times.