SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Self-host SearXNG: your own private search

Run SearXNG on your own VPS with Docker Compose: settings.yml, the limiter, nginx with TLS, and a JSON search API your own scripts can call.

What you are building

Self-hosting SearXNG gives you a private search engine that runs on your own server. SearXNG is a metasearch engine: it takes your query, asks other engines such as Google, Bing, DuckDuckGo and Wikipedia, then merges what comes back into one result page. No profile is built and no tracking cookie is set, because the only machine that keeps your query is yours.

The stack is small. Two containers, one settings file, one reverse proxy. The real decision is whether the instance is private, meaning only you and your own scripts reach it, or public, meaning anyone on the internet can query it. That choice changes the security settings, so make it before you type anything. The default answer is private.

There is a second reason to run one. A SearXNG instance speaks JSON, so any script or AI agent you write gets a search API you own, with no key, no per-query billing and no quota mail.

Install SearXNG with Docker Compose

The project publishes a container image and a Compose file. Pull both onto a fresh Ubuntu 24.04 server that already has Docker Engine and the Compose plugin. If Docker is new to you, start with Docker Compose basics on a VPS and come back.

sudo install -d -o "$USER" -g "$USER" -m 750 /opt/searxng
cd /opt/searxng
mkdir -p core-config
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 defines two services. core is SearXNG itself, and valkey is an in-memory data store used for rate limiting and for short-lived state. It mounts ./core-config/ at /etc/searxng/ inside the container, so everything you configure lives in that one directory on the host.

Now edit .env. Every line in the shipped example is commented out, which is why the container starts on port 8080 on every address. Uncomment and set these three.

SEARXNG_VERSION=latest
SEARXNG_HOST=127.0.0.1
SEARXNG_PORT=8080

SEARXNG_HOST=127.0.0.1 is the important one. It makes the published port 127.0.0.1:8080:8080 instead of [::]:8080:8080, so the container answers only on the loopback address and the internet cannot reach it directly. Skip this and the container is exposed the moment it starts, because a published Docker port is inserted ahead of your firewall rules. That trap is worth reading in full: published Docker ports bypass ufw.

SEARXNG_VERSION=latest is fine while you are learning. On a server you care about, pin the tag. As of July 2026 the release tags are date based and look like 2026.3.25-541c6c3cb, so a pinned deployment upgrades when you decide, not when the registry changes under you.

settings.yml: the parts that matter

Create core-config/settings.yml before the first start. use_default_settings: true tells SearXNG to load its own shipped defaults and then apply only the keys you wrote, so your file stays short and survives upgrades that add new options.

Generate the secret first, because the value goes straight into the file.

openssl rand -hex 32
use_default_settings: true

general:
  instance_name: "search.example.com"

server:
  base_url: "https://search.example.com/"
  secret_key: "paste-the-openssl-output-here"
  limiter: false
  public_instance: false
  image_proxy: true

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

search:
  safe_search: 0
  autocomplete: "duckduckgo"
  formats:
    - html
    - json

secret_key signs session and token data. The shipped default is the literal string ultrasecretkey, and leaving it means anyone who knows that default can forge those tokens. Replace it once, then leave it alone: changing it later throws away every saved preference.

base_url must be the public HTTPS address, with the trailing slash. It is what SearXNG writes into the links it renders. Leave it pointing at localhost and the "next page" link in a remote browser points at the reader's own machine and fails.

formats decides which output types the web endpoint will produce. json is not in the default list, so a JSON request returns a 403 until you add it. image_proxy: true routes result thumbnails through your server, so the sites hosting those images never see your visitors' addresses.

The valkey.url uses the hostname valkey because that is the service name in the Compose file, and Compose puts both containers on one network where service names resolve. Point it at localhost and the limiter fails, because inside the core container localhost is that container.

The secret sits in a plain file, so protect the directory around it rather than the file itself. chmod 750 /opt/searxng keeps other host users out. Do not tighten core-config/settings.yml to mode 600: the container runs as its own unprivileged user, and a file it cannot read stops SearXNG from starting at all.

Start the stack and check it.

cd /opt/searxng
docker compose up -d
docker compose ps
curl -I http://127.0.0.1:8080/

docker compose ps should show both containers in state running. The curl should answer HTTP/1.1 200 OK. If it answers nothing, read docker compose logs core, since a YAML mistake in settings.yml shows up there as a parse error naming the line.

Put it behind nginx with TLS

The container listens on loopback only, so nginx is what makes it reachable, and it is also what adds transport layer security (TLS). Write /etc/nginx/sites-available/searxng.

server {
    listen 80;
    server_name search.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo ln -s /etc/nginx/sites-available/searxng /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d search.example.com

nginx -t prints syntax is ok and test is successful before you reload. Certbot rewrites the same file to listen on 443 with a certificate and adds a redirect from port 80. The DNS record for search.example.com must already point at this server, because the certificate authority proves ownership by fetching a file over HTTP. The full walkthrough, including renewal, is in the Certbot and nginx guide for Ubuntu 24.04.

The two forwarding headers are not decoration. Without X-Forwarded-For and X-Real-IP, every request arriving at SearXNG carries the proxy address, so the rate limiter sees one client making all the traffic and cannot tell visitors apart.

Why scripts and agents want a JSON search API

With json in formats, the same endpoint that renders the page returns structured data.

curl -s 'http://127.0.0.1:8080/search?q=wireguard+mtu&format=json' \
  | jq -r '.results[0:5][] | .url'

You get back an object with a results array, where each entry carries url, title, content and the engine that supplied it, alongside answers, infoboxes and suggestions. That is enough to feed a summariser, a link checker, or a research loop.

This matters for anything agent shaped. A language model has a training cutoff, so it needs live search to answer questions about the present, and commercial search APIs charge per query and rate limit hard. A local instance costs one container on a server you already pay for, and the queries never leave it. If you are wiring tools into a model, the same reasoning drives running MCP servers on a VPS, where a search tool is usually the first one people add.

Two rules for API use. Keep the instance private, so bind the API side to the loopback address or to a private network and let only your own hosts reach it. Then query it gently. SearXNG forwards your request to real search engines, so a script running a hundred queries a second is asking Google to block your server.

The limiter, and what changes for a public instance

The limiter is SearXNG's bot defence. It watches request headers, addresses and request rates, and it drops traffic that looks automated. It needs Valkey to hold that state, which is why the Compose file ships it.

On a private instance keep limiter: false. Your own scripts are automated traffic by definition, so the limiter would block exactly the JSON calls you built the instance for. Access control is the reverse proxy's job instead: an allow and deny pair in the nginx location, HTTP basic authentication, or a firewall that only admits your other servers.

If you do publish the instance for other people, turn both switches on.

server:
  limiter: true
  public_instance: true

Finer control lives in core-config/limiter.toml, which the container reads at /etc/searxng/limiter.toml. You write only the keys you want to change. Behind a proxy you must declare the proxy, or the limiter treats your nginx address as the one abusive client.

[botdetection]
trusted_proxies = [
  '127.0.0.0/8',
  '::1',
]

[botdetection.ip_limit]
link_token = true

link_token = true makes SearXNG issue a token that only a real browser session will fetch, which stops most simple scrapers. Expect a public instance to attract them within days. Expect engine errors too, because the more traffic you forward, the sooner upstream engines start returning CAPTCHAs to your server address. A public SearXNG instance is an ongoing job. A private one is not, which is why it sits on most short lists of things worth self-hosting in 2026.

Why searches return nothing

Open /stats on your instance. It lists every engine with its error rate and response time, and it is the first place to look when results feel thin.

An engine shown with "Access denied" or "CAPTCHA" errors has blocked your server address. That is common for addresses in data centre ranges, because search engines assume those belong to scrapers. SearXNG then suspends the failing engine for a period rather than retrying it, so one blocked engine quietly drops out of your results. Disable it in settings.yml or accept the loss. The remaining engines still answer.

If every engine fails at once, the container has no working outbound name resolution or no route to the internet. Test that from inside the container.

docker compose exec core wget -qO- https://duckduckgo.com > /dev/null && echo ok

FAQ

Does SearXNG make my searches anonymous?

It hides who you are from the engines it queries, because they see your server making the request instead of your browser. It does not hide the query from your server, and it does not hide your server from them. On a single user instance all traffic from that address is yours, so the address itself becomes the identifier. Traffic between your browser and your instance is protected by the TLS certificate.

Why does a JSON request return 403 Forbidden?

Two causes, and both are configuration. Either json is missing from the formats list under search: in settings.yml, which is the default state, or the limiter is on and has classified your script as a bot. Add the format first, restart with docker compose restart core, then try again. If it still fails, set limiter: false and control access at the reverse proxy instead.

Do I need the Valkey container if I keep the limiter off?

Leave it running. SearXNG works without it, but the limiter cannot be turned on later without it, and it also holds other short-lived state. The container is small and stores only cached data, so removing it saves very little and costs you the option.

How do I update SearXNG?

Run docker compose pull then docker compose up -d in /opt/searxng. Compose recreates any container whose image changed and leaves your core-config/ directory untouched, so settings.yml survives. Because use_default_settings: true merges your keys over the shipped defaults, options added upstream arrive with sensible values instead of breaking the file.

Can several people share one instance?

Yes, and that is the case where you turn the limiter on and set public_instance: true. Preferences are stored in each visitor's own browser, so there are no accounts to manage. Watch /stats for a week after opening it up, because upstream engines start rejecting your server long before you notice missing results.