SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Harden a public SearXNG instance

Open SearXNG to other people safely: bind to localhost, get X-Forwarded-For and trusted_proxies to agree, enable the limiter, and lock down the JSON API.

Harden a public SearXNG instance before you hand out the URL

To harden a public SearXNG instance, work through five changes in this order, because each one depends on the one above it.

  1. Bind the application to 127.0.0.1 so nothing reaches it except your own proxy.
  2. Terminate TLS (transport layer security) at nginx or Caddy, and set base_url to the public address.
  3. Make the forwarded IP headers and botdetection.trusted_proxies agree with each other.
  4. Switch the limiter on, with a Valkey database behind it.
  5. Decide on purpose what a stranger may reach: the result formats, the image proxy, the safe search default, and whether search engines index the instance at all.

The order matters because the limiter counts requests per IP (internet protocol) address, and it cannot count anything correctly until the proxy tells it which address made the request. This guide assumes you already have SearXNG running on a VPS you own and are now about to let other people use it. Every path and default value below comes from the SearXNG admin documentation and source, read on 10 September 2026.

Settle one thing before you start. The limiter is bot protection, not access control. Upstream says so about its own header checks: "Probe HTTP headers can be easily bypassed." Everything here raises the cost of abusing your instance. None of it turns a public search endpoint into a private one.

Bind SearXNG to localhost

server:
  bind_address: "127.0.0.1"
  port: 8888
  base_url: "https://search.example.com/"

bind_address and port apply when the application runs directly under Python. They do not apply when the proxy talks to the app over a unix socket, because then no TCP port exists to bind. Check what is actually listening:

ss -lntp | grep 8888

The line should show 127.0.0.1:8888. If it shows 0.0.0.0:8888 or *:8888, the app is answering on the public interface, so anyone who knows the port can skip your proxy and your rate limit by asking for http://your.ip:8888/ directly. Everything else on this page is decoration until that line reads 127.0.0.1.

In a container the same rule applies to the published port. The shipped .env.example listens on [::] inside the container on port 8080, so publish it to loopback only:

services:
  searxng:
    ports:
      - "127.0.0.1:8888:8080"

A firewall rule is not a substitute here. ufw does not filter ports that Docker publishes, because Docker writes its own rules into the nat table ahead of the chain ufw manages, so ufw deny 8888 reads as protection while the port stays open to the world. The address on the left of the port mapping is what keeps it off the internet.

base_url is documented as "The base URL where SearXNG is deployed. Used to create correct inbound links." Set it to the exact public address, scheme included. When it is wrong, the links the application builds for itself point somewhere else, which a visitor meets the moment they add your instance to their browser search bar and every query lands on the wrong host.

Terminate TLS at the proxy and send the headers the limiter reads

The SearXNG documentation is blunt about this: "A correct setup of the HTTP request headers X-Forwarded-For and X-Real-IP is essential to be able to assign a request to an IP correctly." Here is the upstream nginx block, with the subpath header dropped because this instance lives at the root of its own name:

server {
    listen 443 ssl;
    server_name search.example.com;

    location / {
        proxy_pass http://127.0.0.1:8888;
        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;
    }
}

If you install SearXNG under a path such as /searxng instead, the upstream example adds proxy_set_header X-Script-Name /searxng; and the application uses that value to build its links. Leave it out at the root, or every generated URL grows a path segment that does not exist. For the rest of the block, including the TLS lines this snippet leaves out, see what each proxy_pass and proxy_set_header line is actually doing.

Caddy needs no header lines at all:

search.example.com {
    reverse_proxy 127.0.0.1:8888
}

Caddy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host itself, and by default it ignores those header values when they arrive from the client, "to prevent spoofing". It does not send X-Real-IP, and it does not need to, because SearXNG reads X-Forwarded-For first and falls back to X-Real-IP only when the first header is absent.

Why the limiter blocks everybody at once

SearXNG resolves the client address in a fixed order. It reads X-Forwarded-For and takes the first untrusted address, walking the list from right to left and skipping every address inside botdetection.trusted_proxies. With no such header it uses X-Real-IP. With neither it uses the address the web server itself saw. If nothing yields an address it uses 100::, the discard prefix from RFC 6666.

There is a gate in front of all of that, and it is the part people trip over. If the machine that opened the connection is not itself listed in trusted_proxies, SearXNG throws both forwarded headers away, logs a configuration warning, and uses the connecting address instead. That is the correct behaviour, because a forwarded header from an untrusted peer is just a claim from a stranger. It also produces the most common complaint about self-hosted public instances.

Picture nginx in one container and SearXNG in another, on a Docker bridge network. The connecting address SearXNG sees is something like 172.18.0.5. That address is not inside the shipped defaults of 127.0.0.0/8 and ::1, so the forwarded headers are dropped and every visitor on earth is counted as 172.18.0.5. They share one bucket. A few people searching at the same time empty it, and the instance starts answering 429 Too Many Requests to everyone, including you. That is a different problem from the upstream engines rate limiting your instance and handing it 429s, and it is fixed in a different file.

The fix is to name the proxy's real network in /etc/searxng/limiter.toml:

[botdetection]
ipv4_prefix = 32
ipv6_prefix = 48

trusted_proxies = [
  '127.0.0.0/8',
  '::1',
  '172.18.0.0/16',
]

[botdetection.ip_limit]
filter_link_local = false
link_token = false

[botdetection.ip_lists]
block_ip = []
pass_ip = []
pass_searxng_org = true

Take that range from your own deployment instead of copying mine: docker network inspect <network> prints the Subnet of the bridge the proxy sits on. Keep the list as narrow as the truth allows. Widening it to 0.0.0.0/0 makes the shared-bucket problem disappear and replaces it with a worse one. Any visitor can then send their own X-Forwarded-For, the limiter counts whatever address they typed, and their real address is never limited at all.

ipv4_prefix and ipv6_prefix decide how much of an address counts as one client. A single IPv4 address is one client at /32. IPv6 is grouped at /48 because a home connection is usually handed a whole block, so counting single addresses would let one client take a fresh one for every request.

To test the trust boundary, send a forged header from outside and watch whether it buys you anything:

for i in $(seq 1 40); do
  curl -s -o /dev/null -w '%{http_code} ' \
    -H "X-Forwarded-For: 198.51.100.$((RANDOM % 250 + 1))" \
    "https://search.example.com/search?q=test$i"
done; echo

The codes should start at 200 and turn into 429 once you pass the limit, exactly as they would with no header at all. If a random forged address lets you keep going indefinitely, trusted_proxies is too wide. Run this against your own instance only.

Turn the limiter on, with Valkey behind it

The limiter is off by default, and the documentation states that "The limiter requires a Valkey database". That database is where the sliding request counters live.

sudo apt install -y valkey-server
sudo systemctl enable --now valkey-server
valkey-cli ping

valkey-cli ping answers PONG when the server is up. Ubuntu 24.04 carries valkey-server in its updates pocket as of September 2026. The upstream utility script offers searxng.sh install valkey if you would rather follow that path. Then two keys in /etc/searxng/settings.yml:

server:
  limiter: true

valkey:
  url: valkey://localhost:6379/0

Restart the service and read its log instead of assuming it came back clean. systemctl list-units --type=service | grep -i searxng finds the unit name your install uses, then sudo journalctl -u <unit> -n 50 shows the startup. In a container it is docker compose logs searxng-core. A limiter that cannot reach Valkey is the quiet failure here, because the site keeps serving searches and simply stops limiting them.

With the limiter running, these are the caps it applies per counted address. They are the upstream defaults in searx/botdetection/ip_limit.py, read on 10 September 2026.

ChartSearXNG limiter sliding windows and request caps (upstream defaults, 10 September 2026)
The data behind this chart
[
  {
    "label": "Burst, 20 s window",
    "max_requests": 15,
    "max_when_suspicious": 2
  },
  {
    "label": "Long, 600 s window",
    "max_requests": 150,
    "max_when_suspicious": 10
  },
  {
    "label": "API, 3600 s window",
    "max_requests": 4,
    "max_when_suspicious": 4
  }
]

A normal visitor gets 15 requests in twenty seconds and 150 in ten minutes. A request that the header checks mark as suspicious gets 2 and 10 instead. That is what those weak checks are for: they do not block, they demote. The API window has no separate suspicious cap, so both columns hold the same number in that row.

Read the last row twice. Any request for a format other than HTML is capped at 4 requests per hour, per address.

Decide what the public may reach

The result formats are the first decision, and the shipped default is already the careful one:

search:
  safe_search: 1
  formats:
    - html

formats is documented as "Result formats available from web, remove format to deny access (use lower case)", and the default list holds html alone. Adding json turns your instance into a free search API for anyone who finds it. The hourly cap of 4 non-HTML requests makes it a poor API in any case, so if you want a JSON endpoint to feed Open WebUI, run that on a second instance that is not published to the world and keep the public origin on html.

safe_search takes 0 for none, 1 for moderate and 2 for strict. On an instance strangers can reach, 1 is the sensible default, and a visitor who wants something else changes it in Preferences. It is a default, not a filter you enforce.

Two server keys change what your machine does on a visitor's behalf. image_proxy makes your server fetch result images so the image host never sees the visitor, which the documentation notes "Uses memory space" and which also spends your bandwidth on every thumbnail. public_instance activates the link_token bot detection method along with the image proxy. Link token asks the client to fetch a generated CSS file, and a client that never fetches it is marked suspicious, which drops it to the lower caps in the chart above.

server:
  public_instance: true
  image_proxy: true
  method: "GET"
  default_http_headers:
    X-Content-Type-Options: nosniff
    X-Download-Options: noopen
    X-Robots-Tag: noindex, nofollow
    Referrer-Policy: no-referrer

method accepts GET or POST. POST keeps queries out of the browser history and out of your access log, at the cost of shareable result URLs. Keeping X-Robots-Tag: noindex, nofollow in place keeps your instance out of search results, which is often the difference between a few friends using it and a scraper finding it.

Replace the secret key, and know what it protects

Search the config for the string ultrasecretkey. If it is still there, the instance is running with the value shipped in the source, which everyone can read.

sudo -H sed -i -e "s/ultrasecretkey/$(openssl rand -hex 16)/g" /etc/searxng/settings.yml
sudo grep -c ultrasecretkey /etc/searxng/settings.yml

grep -c should print 0. In a container, set SEARXNG_SECRET in your .env file to the same kind of random value rather than editing a file inside the image.

The documentation says only that the key is "Used for cryptography purpose", which undersells one concrete case. Image proxy URLs are signed. image_proxify() computes an HMAC (hash-based message authentication code) of the target URL using secret_key and appends it as the h query parameter, and the /image_proxy route returns an empty body with status 400 when that signature does not match. With the default key, anyone can compute a valid signature for any URL, so your instance becomes an open fetcher for whatever they point it at. That is also why the settings file should not be readable by other users on the box:

sudo chown root:searxng /etc/searxng/settings.yml
sudo chmod 640 /etc/searxng/settings.yml

Replace searxng with the group your service actually runs as, then restart it and load the site once. A settings file the service cannot read stops the service, so verify before you walk away.

Rate limit at the proxy as well

The SearXNG limiter stops when Valkey stops. A limit in nginx keeps working, and it turns abusive traffic away before it reaches Python at all.

limit_req_zone $binary_remote_addr zone=searxng:10m rate=2r/s;

server {
    # ...
    location / {
        limit_req zone=searxng burst=20 nodelay;
        limit_req_status 429;
        proxy_pass http://127.0.0.1:8888;
    }
}

limit_req_zone belongs in the http block, not inside server. Test before reloading with sudo nginx -t && sudo systemctl reload nginx, because nginx -t reports syntax is ok and test is successful on a good file, and this is the one step on this page that can take a working site offline.

$binary_remote_addr is the address that connected to nginx, which is the real visitor while nginx is your edge. If something sits in front of it, a CDN (content delivery network) for example, every request arrives from that network and one zone entry then counts the entire internet. In that case you need the nginx real_ip module (set_real_ip_from plus real_ip_header) before this limit means anything. The same warning applies on the SearXNG side: one more hop in front means one more network in trusted_proxies.

What hardening does not buy you

Upstream is honest about the limiter's ceiling. Header analysis "can be easily bypassed". IP block and pass lists are "hard to maintain, since the IPs of bots are not all known and change over the time". Only the behavioural rate limit stands on its own, and it limits by address, so a client with many addresses walks around it.

Your server's address is also the address every upstream engine sees, so a busy public instance gets blocked by engines on a schedule. That arrives as engines dropping out with CAPTCHA errors rather than as anything the limiter reports. None of this changes what your instance can observe about the people using it either, which is the question somebody searching on another person's instance should be asking. If you are hardening this for a small group rather than for the open internet, stop tuning the limiter and put authentication in front of it, either forward auth at the proxy or a tunnel that leaves no ports open on the box. An instance that no anonymous visitor can reach needs much less of this page.

FAQ

Why does my public SearXNG instance return 429 to everyone at once?

Because the limiter is counting every visitor as a single address. SearXNG discards X-Forwarded-For and X-Real-IP when the machine that opened the connection is not listed in botdetection.trusted_proxies, then falls back to the connecting address. With nginx in a separate container, that connecting address is the proxy's bridge address, and the shipped defaults of 127.0.0.0/8 and ::1 do not cover it. Add the proxy's network to trusted_proxies in /etc/searxng/limiter.toml and restart. The service log carries a configuration warning while this is happening.

Do I need Valkey to run the SearXNG limiter?

Yes. The documentation states that "The limiter requires a Valkey database", because the sliding request windows are stored there. Install valkey-server, confirm valkey-cli ping answers PONG, then set server.limiter: true and valkey.url: valkey://localhost:6379/0 in /etc/searxng/settings.yml. Without a reachable database the site keeps serving searches and stops limiting them, so read the service log after the restart rather than assuming it worked.

Should I enable the JSON API on a public instance?

No. search.formats ships as html only, and adding json gives anyone who finds the instance a free search API. The limiter caps non-HTML requests at 4 per hour per address, so it is not a usable API for your own tools either. Run a second instance that is not published publicly when you need JSON.

Does the limiter make a public SearXNG instance safe to expose?

No, and upstream does not claim it does. It is bot protection, which means it raises the cost of scraping your instance. Its header checks "can be easily bypassed", its IP lists are "hard to maintain", and its rate limit counts by address, so a client with a pool of addresses is limited once per address instead of once overall. Treat the limiter as noise reduction. If only certain people should be able to search, put authentication in front of it.