SearXNG + Tor in Docker: Your Own Private Search Engine

A step-by-step guide to deploying SearXNG in Docker with Tor integration and an authenticated Caddy HTTPS reverse proxy. Private, self-hosted search that doesn't leak your queries and doesn't quietly become a service you run for strangers.

SearXNG + Tor in Docker: Your Own Private Search Engine

Every search you type into Google, Bing, or DuckDuckGo gets logged, profiled, and sold. Your search history is a detailed map of your thoughts, fears, medical concerns, financial worries, and political leanings.

SearXNG fixes this. It’s an open-source metasearch engine that queries other engines on your behalf, without sending your identity along for the ride. Add Tor routing and it doesn’t even reveal your IP to the engines it queries.

Fifteen minutes, start to finish.

Revision note, 31 July 2026. An earlier version of this guide told readers to set SocksPort 0.0.0.0:9050 and run the container with --network host. That combination published an open Tor proxy and an unauthenticated search API to the whole network, and to the internet on a port-forwarded host. It also carried a Tor verification step that could not execute, so nobody could confirm the guide’s central claim. All of it is corrected here, and the configuration in this guide has been booted and tested on a live instance. Verify as you go anyway — this document has twice shipped steps that did not work, and the checks in Step 5 exist so you need not take our word for it. If you built from the old version, go to Fixing an exposed install first.


Set your expectations first

Tor hides your origin from search engines. It does not make search better. Engines rate-limit, CAPTCHA and block traffic from Tor exit nodes, because plenty of abusive traffic arrives that way too. A Tor-routed instance with two or three engines unresponsive on a given day is working normally.

Aggregating many engines does not mean many engines answer. Over Tor you will often be served by whichever handful is not currently blocking your exit. There’s a section below on what to do when that number reaches zero.


What You’re Building

Browser ──HTTPS:443──► Caddy (auth) ──HTTP:127.0.0.1:8080──► SearXNG (Docker)
                                                                  │
                                                      SOCKS5:172.17.0.1:9050
                                                                  │
                                                            Tor (systemd)

SearXNG listens on loopback only, and Caddy is the only thing reachable from your network — with a password in front of it. Both of those matter: binding to loopback alone just moves an open service from port 8080 to port 443.


Prerequisites

  • A Linux box with Docker (sudo apt install docker.io, or the official docs)
  • Root/sudo access, 512MB RAM minimum

Step 1: Install and Configure Tor

sudo apt update && sudo apt install -y tor

Confirm your Docker bridge gateway address before editing anything — it is usually 172.17.0.1 but not always:

ip -4 addr show docker0 | awk '/inet /{print $2}'

Edit /etc/tor/torrc:

SocksPort 127.0.0.1:9050
SocksPort 172.17.0.1:9050
SocksPolicy accept 127.0.0.1/32
SocksPolicy accept 172.17.0.0/16
SocksPolicy reject *

Tor’s default is loopback-only, which is safe. The second SocksPort exists so containers on the bridge can reach it. The three SocksPolicy lines are what keep that safe — Tor accepts SOCKS from your own machine and from Docker containers, and refuses everything else. Leave off the first accept line and Tor will deny your own loopback connections too, including the verification step below; torrc’s policy has no implicit loopback exemption.

Reboot ordering, or Tor will not start. 172.17.0.1 only exists once Docker has created docker0. If Tor starts first, it does not degrade — it aborts with “Failed to bind one of the listener ports”, and because Step 2 enables a Tor health check, SearXNG then refuses to start too. You get a total outage after every reboot with two unrelated-looking errors. Fix it once:

echo 'net.ipv4.ip_nonlocal_bind=1' | sudo tee /etc/sysctl.d/99-tor-bind.conf
sudo sysctl --system

Or add a systemd drop-in for tor.service with After=docker.service.

Two caveats on the sysctl route. It must go in a file under /etc/sysctl.d/ as above and not be set with a bare sysctl -w, because a -w value evaporates on reboot, which is the exact event the fix exists to survive. And with non-local binding enabled, if docker0 ever comes up on a different subnet, Tor will bind the old address quite happily and containers will silently fail to reach it. The After=docker.service drop-in does not have that failure mode, so prefer it if you are choosing one.

Start it:

sudo systemctl enable --now tor

Verify, from the host:

curl -s --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip

You want "IsTor":true.


Step 2: Configure SearXNG

sudo mkdir -p /etc/searxng
sudo chmod 700 /etc/searxng

That directory is about to hold a secret key, so lock it before anything goes in it.

Generate a secret key:

python3 -c "import secrets; print(secrets.token_hex(32))"

Create /etc/searxng/settings.yml:

use_default_settings:
  engines:
    keep_only:
      - google
      - bing
      - duckduckgo
      - brave
      - startpage
      - mojeek
      - wikipedia
      - wikidata
      - github
      - stackoverflow
      - arxiv
      - bing news
      - duckduckgo news

general:
  instance_name: "SearXNG Local"
  enable_metrics: false

server:
  secret_key: "PASTE_YOUR_GENERATED_KEY_HERE"
  limiter: false
  image_proxy: true

search:
  safe_search: 0
  default_lang: auto
  formats:
    - html
    - json

ui:
  default_theme: simple
  theme_args:
    simple_style: light

outgoing:
  request_timeout: 15
  max_request_timeout: 25
  using_tor_proxy: true
  proxies:
    http: socks5h://172.17.0.1:9050
    https: socks5h://172.17.0.1:9050

engines:
  - name: bing
    disabled: false
  - name: mojeek
    disabled: false

Then tighten the file itself. The container’s entrypoint changes ownership but never changes the mode, so left alone this file sits at 0644 and every local account on the box can read your secret key:

sudo chmod 600 /etc/searxng/settings.yml

Some of these earn their place:

use_default_settings with keep_only is how you actually restrict the engine pool. A bare engines: list does not replace the defaults — it merges into them, so the common pattern of naming thirteen engines leaves the whole default list in place — roughly 280 engines shipped, of which about 90 are enabled and actually get queried. keep_only genuinely filters.

The trailing engines: block is not redundant. keep_only filters the list but never flips an engine’s disabled flag, and bing and mojeek both ship disabled. Without those two entries you would have eleven working engines while believing you had thirteen. This is the same trap the guide warns about for Seznam further down, and it catches people in both directions.

using_tor_proxy: true is the most valuable line in the file. It is not advisory: SearXNG performs a live Tor check at startup and raises Invalid network configuration and refuses to serve if egress is not actually Tor. Two consequences worth knowing — the check fetches check.torproject.org on every start, so an outage there blocks startup, and socks5h:// is required rather than stylistic, because the check fails on plain socks5://.

enable_metrics: false stops metric collection and closes /metrics. It does not close /stats or /stats/errors — those routes are registered unconditionally and still return HTTP 200 with empty bodies. If that bothers you, block them at the proxy, as the Caddyfile below does.

limiter: false is deliberate. Setting it true requires a Valkey or Redis instance; without one the limiter silently does nothing, though SearXNG does log an explicit error naming the cause. Behind loopback plus authentication, the limiter is not what’s protecting you.

No bind_address line. Older guides, including an earlier version of this one, carry bind_address: "0.0.0.0:8080". That is the wrong shape for the setting, which takes a bare host and no port, and it does nothing in this image anyway, because binding is controlled by Granian. Exposure is set by the Docker publish flag in Step 3.

Ownership, so it doesn’t surprise you. The container’s entrypoint runs chown -R searxng:searxng on the config mount at every start, so /etc/searxng and its contents end up owned by UID/GID 977 and your host user may lose write access. That is expected, and it is why the section on editing settings later goes through docker exec.


Step 3: Launch SearXNG

docker run -d \
  --name searxng \
  -p 127.0.0.1:8080:8080 \
  --restart unless-stopped \
  -v /etc/searxng:/etc/searxng \
  searxng/searxng:2026.6.7-70de3cc56

-p 127.0.0.1:8080:8080 publishes to loopback only. Write -p 8080:8080 and Docker binds every interface, exposing an unauthenticated JSON search API to anyone who can route to the box.

On the image tag. Docker Hub tags this repository YYYY.M.D-<commit>, so 2026.6.7-70de3cc56 is a valid tag and a bare 2026.6.7 is not — it fails with no such manifest. Browse Docker Hub tags and pin one that exists. Pin something; latest is fine until an upgrade changes the settings schema overnight and you have nothing to roll back to.

Verify, from the host:

curl -s 'http://127.0.0.1:8080/search?q=test&format=json' | python3 -m json.tool | head -20

A connection refused from another machine on your network is the correct result.

services:
  searxng:
    image: searxng/searxng:2026.6.7-70de3cc56
    container_name: searxng
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - /etc/searxng:/etc/searxng

Step 4: HTTPS and Authentication with Caddy

Caddy is how you reach the instance from your other machines. Put a password on it. SearXNG has no authentication of its own, and format=json is enabled, so an unprotected reverse proxy is just the open API again on a nicer port.

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy

Generate a password hash:

caddy hash-password

Edit /etc/caddy/Caddyfile:

http://YOUR_HOST_IP {
    redir https://{host}{uri} permanent
}

https://YOUR_HOST_IP {
    tls internal

    basic_auth {
        youruser PASTE_THE_BCRYPT_HASH_HERE
    }

    @stats path /stats /stats/*
    respond @stats 404

    reverse_proxy 127.0.0.1:8080
    header {
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "no-referrer"
    }
}

tls internal issues a self-signed certificate, since public CAs won’t certify private IPs. The @stats block closes the two endpoints enable_metrics leaves open.

Three things that will bite you here.

basic_auth is evaluated before respond, so an anonymous request to /stats returns 401, not 404. Authenticated requests get the 404. Both outcomes are correct; if you test anonymously and see 401, the matcher is working, not failing.

The password must be the bcrypt hash from caddy hash-password, not the password itself. Paste a plaintext password and caddy validate will catch most of them, which is exactly why you run it. The error names neither the password nor the file, so it reads like a syntax problem:

Error: ... provision http.authentication.providers.http_basic:
       base64-decoding password: illegal base64 data at input byte 4

The catch is not complete: a purely alphanumeric password whose length is a multiple of 4 is itself valid base64, so it validates cleanly, Caddy starts, and every login attempt returns 401 including the correct password. If auth rejects a password you know is right, you pasted plaintext instead of the hash.

Directive name depends on your Caddy version. basic_auth requires Caddy 2.8 or newer. The cloudsmith repository above installs 2.11, where it is correct. But apt install caddy from Debian’s or Ubuntu’s own archive can give you 2.6.2, and there the Caddyfile fails to parse outright:

Error: Caddyfile:6: unrecognized directive: basic_auth

On that version the directive is spelled basicauth, without the underscore. Check with caddy version before you debug anything else.

caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy

To avoid browser warnings, trust the CA:

sudo cp /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt \
    /usr/local/share/ca-certificates/caddy-local-ca.crt
sudo update-ca-certificates

Import the same root.crt into your browser’s certificate authorities.


Step 5: Verify the Full Stack

# 1. SearXNG answers on loopback
curl -s 'http://127.0.0.1:8080/search?q=privacy&format=json' \
  | python3 -c "import sys,json; r=json.load(sys.stdin); print(f'{len(r.get(\"results\",[]))} results')"

# 2. HTTPS through Caddy, with credentials
curl -sk -u youruser:yourpassword 'https://YOUR_HOST_IP/search?q=privacy&format=json' \
  | python3 -c "import sys,json; r=json.load(sys.stdin); print(f'{len(r.get(\"results\",[]))} results')"

# 3. Without credentials, you should be refused
curl -sk -o /dev/null -w '%{http_code}\n' 'https://YOUR_HOST_IP/search?q=privacy'   # expect 401

# 4. Tor is genuinely the egress path
curl -s --socks5-hostname 172.17.0.1:9050 https://check.torproject.org/api/ip      # expect "IsTor":true

# 5. A container really is using Tor. Run a search, then immediately:
sudo ss -tnp state established '( sport = :9050 )'

On check 5, note the filter is sport, not dport: on the host you are the server side of that connection. You should see a peer inside 172.17.0.0/16. Be clear about what this proves — the container’s own socket lives in a separate network namespace and is invisible to ss on the host, so this confirms a container is talking to Tor, which is the strongest claim available from the host side. The startup check in Step 2 is what actually guarantees SearXNG itself cannot search in the clear.

A verification step that used to be here. Earlier versions suggested docker exec searxng curl .... The image ships no curl, so that command fails with “executable file not found”, and under the old host-networking setup it proved nothing about SearXNG’s egress even in principle.


Fixing an exposed install

If you built from the earlier version, you have an open Tor proxy and an unauthenticated search API.

# 1. Is Tor's SOCKS port listening beyond loopback?
sudo ss -tlnp | grep 9050

# 2. Is SearXNG using host networking or published on all interfaces?
docker inspect -f '{{.HostConfig.NetworkMode}}' searxng
sudo ss -tlnp | grep 8080

Do not use docker port for check 2. A container on --network host has no port mappings, so docker port returns empty output and exit 0 — which reads as “not exposed” for precisely the configuration that is most exposed.

If check 1 shows 0.0.0.0:9050, or check 2 shows host or a listener on 0.0.0.0:8080, remediate:

# Replace the torrc SocksPort line with the five lines from Step 1
sudo systemctl restart tor

# Recreate the container with loopback publishing
docker stop searxng && docker rm searxng
# re-run the docker run command from Step 3

Then add authentication in Caddy as in Step 4.

On rotating the secret key. Generate a new one, paste it in, re-tighten the mode and restart the container so it takes effect:

python3 -c "import secrets; print(secrets.token_hex(32))"
# paste into /etc/searxng/settings.yml, then:
sudo chmod 600 /etc/searxng/settings.yml
docker restart searxng

Worth doing, but be clear why: a leaked SearXNG secret_key lets someone forge preference cookies. It is not what was exposed by the open proxy. Two caveats — if you never set a key, the entrypoint generated a random one and this may not apply; and rotating it invalidates everyone’s saved preferences.

The exposure that key rotation does not address: while that SOCKS port was open, anyone who could reach your machine could route traffic through Tor under your IP. If the host was port-forwarded from the internet, treat that as an incident rather than a tidy-up — check your router’s forwarding rules and consider what your ISP’s records would show.


Searching in a language that isn’t English

default_lang: auto follows the browser. Older guides pin en, which quietly biases every query. ui.default_locale sets the interface language separately.

A single surviving engine will monopolise your results, and it looks exactly like bias. Over Tor most engines block you, and whichever one still answers supplies the whole result set. Query something Norwegian, get ten results from one obscure engine, and the instinct is to blame that engine. It is simply the last one standing.

keep_only bounds which engines can ever answer. If one you don’t want keeps appearing, disable it explicitly:

  - name: seznam
    disabled: true

Several engines blamed for this, Seznam among them, already ship disabled: true. If one is dominating, something enabled it, or it is the only one your exit can reach.


When the Results Go Empty

One day you’ll query your instance and get a clean HTTP 200 with an empty result list. Nothing has crashed. This is the most common failure mode of a Tor-routed SearXNG.

The cause is almost never SearXNG. Engines rate-limit, CAPTCHA and block Tor exits, and SearXNG suspends a refusing engine rather than hammering it. Those suspensions are longer than most people expect: one hour for rate limiting, a day for access-denied and ordinary CAPTCHAs, seven days for a reCAPTCHA, and fifteen days for a Cloudflare CAPTCHA. Land on an exit several engines have blacklisted and you can have everything suspended at once.

Diagnose it first

curl -s 'http://127.0.0.1:8080/search?q=test&format=json' \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print(len(d['results']),d.get('unresponsive_engines'))"

Reasons like Suspended: too many requests, Suspended: CAPTCHA or Suspended: access denied mean an exit-node problem, not a broken install. Confirm Tor is healthy before changing anything:

curl -s --socks5-hostname 172.17.0.1:9050 https://check.torproject.org/api/ip
docker logs --tail 100 searxng

Rotate to a fresh circuit without root

Tor enables IsolateSOCKSAuth by default, so every distinct SOCKS username gets its own circuit. Put credentials in the proxy URL:

outgoing:
  proxies:
    http: socks5h://searxng-a:isolate@172.17.0.1:9050
    https: socks5h://searxng-a:isolate@172.17.0.1:9050

Change the username and restart to force a fresh exit:

docker restart searxng

The restart is also the real remedy for a long suspension. Suspension state is held in process memory and is not persisted, so a restart clears it immediately — which matters a great deal when the alternative is waiting fifteen days for a Cloudflare timer.

What to expect

Two or three names in unresponsive_engines on a given day is the normal condition of a Tor-routed instance. Zero total results is the alarm condition.

In our own use, Wikipedia, Wikidata, GitHub and arXiv have been the most consistently reachable over Tor, while the large general engines are the ones that come and go. That is an observation from one operator’s instances rather than a measured ranking, and your exits will differ.

If you edit settings.yml inside the container

The config mount is owned by the container’s user, so the host account often cannot write to it:

docker exec searxng cat /etc/searxng/settings.yml > settings.yml.new
docker exec searxng cp -p /etc/searxng/settings.yml /etc/searxng/settings.yml.bak
# edit settings.yml.new locally, then:
docker exec -i searxng sh -c \
  'cat > /etc/searxng/settings.yml && chown 977:977 /etc/searxng/settings.yml && chmod 600 /etc/searxng/settings.yml' \
  < settings.yml.new
docker restart searxng

Maintenance

docker logs searxng --tail 50
docker restart searxng

# Update — check the tag list, then bump the pinned tag
docker pull searxng/searxng:NEW_TAG_WITH_COMMIT_HASH
docker stop searxng && docker rm searxng
# re-run the docker run command from Step 3

sudo systemctl status tor

# Periodic exposure check — neither should show 0.0.0.0
sudo ss -tlnp | grep -E '9050|8080'

Optional: JSON API for Automation

curl -s 'http://127.0.0.1:8080/search?q=linux+hardening&format=json' | jq '.results[:3]'

Useful for scripts, monitoring, or local AI pipelines without touching a commercial API.

Keep it on loopback, or behind the Caddy authentication from Step 4. An unauthenticated JSON search endpoint reachable from your network is a service you are running for other people.


What You’ve Gained

Before After
Google logs every search Your queries stay on your hardware
One engine’s bias A pool of engines you control explicitly
Your IP visible to search engines Tor hides your origin
HTTP on LAN HTTPS, loopback origin, password in front
Dependent on external services Self-hosted, yours to control

Your search history is nobody’s business but yours.


Search is thinking out loud. Keep your thoughts private.

AI disclosure

This article predates the formal AI disclosure regime introduced on 19 May 2026. AI tools were used to polish and generate some text in this article. Editorial responsibility: Thomas A. Kleppestø.

Get the next guide

New privacy and security guides, plus the occasional investigation. No tracking, no spam, unsubscribe any time by replying. We never share your address.

Prefer not to sign up? Just email a tip or a correction to HAL0zum@proton.me, or use our PGP key for sensitive material.