SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

SearXNG JSON API for Open WebUI search

Your SearXNG instance can feed web search to Open WebUI. Enable the JSON format, then hand the other container a URL it can actually reach.

What you are connecting

Open WebUI reads its web results from SearXNG's JSON API, and an instance set up for people does not answer programs. The instance that works in your browser returns HTTP 403 to the first request Open WebUI makes. Two edits fix it: one line in settings.yml, and one URL in Open WebUI that names the container instead of localhost.

This guide starts where the install ends. If you do not have an instance yet, begin with a self-hosted SearXNG instance on a VPS and come back. Everything below uses the layout from SearXNG's own container documentation, as of August 2026: the compose project is searxng, the service is core, and the container is searxng-core. If you installed from the older searxng-docker repository, which its maintainers archived in March 2026, your service is called searxng and the config directory sits one level up. Nothing else in this guide changes.

The settings file as it ships

This is the settings file the container installation starts from, unedited:

# SearXNG settings

use_default_settings: true

general:
  debug: false
  instance_name: "SearXNG"

search:
  safe_search: 2
  autocomplete: 'duckduckgo'
  formats:
    - html

server:
  # Is overwritten by ${SEARXNG_SECRET}
  secret_key: "ultrasecretkey"
  limiter: true
  image_proxy: true
  # public URL of the instance, to ensure correct inbound links. Is overwritten
  # by ${SEARXNG_BASE_URL}.
  # base_url: http://example.com/location

valkey:
  # URL to connect valkey database. Is overwritten by ${SEARXNG_VALKEY_URL}.
  url: valkey://localhost:6379/0

Your copy of it is the settings.yml inside the directory you mounted at /etc/searxng/, so core-config/settings.yml in the current layout. use_default_settings: true on the first line means this file is an overlay. Every key you do not write here comes from SearXNG's built-in defaults, which is why thirty lines is a complete configuration.

Two blocks in that file decide whether another program can use this instance at all. The formats list under search is the instance's whole API surface, because a response format that is not named in that list is refused no matter who asks for it. The limiter key under server turns on bot detection, and it is the reason a correct format list can still return nothing. Read both blocks before you edit either one.

The one change that turns on the JSON API

Add json to the format list. The search block becomes:

search:
  safe_search: 2
  autocomplete: 'duckduckgo'
  formats:
    - html
    - json

Restart the service and ask it for JSON from the host shell:

docker compose restart core
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8080/search?q=vps&format=json'

200 means the API is open. The body is a JSON object whose results array holds one entry per hit, each carrying url, title and content, which is what a front end consumes. A 403 or a 429 is not a broken instance. Each one names a different check that stopped the request, and the next two sections cover them in the order the request meets them.

Why does Open WebUI return 403 from the SearXNG JSON API?

Ask an unedited instance for JSON and the answer is a page, not data:

<!doctype html>
<html lang=en>
<title>403 Forbidden</title>
<h1>Forbidden</h1>
<p>You don't have the permission to access the requested resource. It is either read-protected or not readable by the server.</p>

That page comes from two lines in SearXNG's request handler, searx/webapp.py:

if output_format not in settings['search']['formats']:
    flask.abort(403)

The format named in the request is compared against the list in your settings file, and a format that is missing from the list is refused before any search engine is contacted. Open WebUI's search client always sends format=json in its query parameters, so it hits that check on every single request while your browser, which asks for HTML, keeps working. This is why the instance looks healthy from the outside and the chat still comes back empty.

If you edited the file and still get 403, you edited a file the container is not reading. Run docker compose exec core cat /etc/searxng/settings.yml and look at the formats list in the output. What that command prints is the only copy that matters.

Which URL does the other container need?

http://127.0.0.1:8080 is the address you use from the host shell, and it is the wrong address for Open WebUI. Inside the Open WebUI container, 127.0.0.1 is that container's own loopback interface. Nothing is listening on port 8080 there, so the connection is refused and the search fails before a packet leaves the container. Containers reach each other by name, on a network they share.

So two things have to be true. Open WebUI must be attached to the network SearXNG already runs on, and the URL must use a name Docker publishes for the SearXNG container on that network.

services:
  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: open-webui
    restart: always
    ports:
      - "127.0.0.1:3000:8080"
    volumes:
      - open-webui:/app/backend/data
    environment:
      ENABLE_WEB_SEARCH: "true"
      WEB_SEARCH_ENGINE: "searxng"
      SEARXNG_QUERY_URL: "http://searxng-core:8080/search?q=<query>"
      WEB_SEARCH_RESULT_COUNT: "3"
      WEB_SEARCH_CONCURRENT_REQUESTS: "10"
    networks:
      - searxng_default

volumes:
  open-webui:

networks:
  searxng_default:
    external: true

A compose project named searxng creates a default network called searxng_default, and external: true joins that existing network instead of creating a second one. On it, the SearXNG container answers to searxng-core, its container name, and to core, its service name. An install from the archived searxng-docker layout answers to searxng. Run docker network inspect searxng_default when you are not sure which names exist.

The /search?q=<query> tail is required by Open WebUI, and <query> is a literal placeholder that Open WebUI replaces at request time. Do not substitute anything for it yourself.

Test the path before you touch the app, using a throwaway container on the same network:

docker run --rm --network searxng_default curlimages/curl:latest \
  -s -o /dev/null -w '%{http_code}\n' \
  'http://searxng-core:8080/search?q=vps&format=json'

200 proves the whole path, from name resolution to the format list. 000 means curl never got an answer, so the name did not resolve or the two containers are on different networks. 403 is the format list. 429 is the limiter, below.

The limiter answers before the format check does

limiter: true installs a check that SearXNG registers as a Flask before_request hook, which means it runs ahead of the search handler on every request. It inspects the caller: pass and block lists first, then the User-Agent header, then, on search requests, the Accept, Accept-Encoding, Accept-Language and Sec-Fetch headers along with a per-address rate count. A caller that fails any of those gets 429 and never reaches the code that reads your format list.

Your own app fails those checks because it is automation, and the limiter cannot tell your retrieval pipeline apart from anyone else's scraper. This is why the earlier curl can return 429 on an instance whose settings file is already correct. The 429 is not about JSON.

Open WebUI's documentation handles this by relaxing bot detection in limiter.toml, in the same mounted config directory:

[botdetection.ip_limit]
link_token = false

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

For an instance that only your own containers reach, turning the limiter off in settings.yml with limiter: false is simpler and does the same job. SearXNG also documents $SEARXNG_LIMITER as the environment override for that key. Either way the trade is real, so read the last section before you disable it. Which check fires, how the pass list lets one client through while the limiter stays on, and what the container log prints when a request is blocked, are all in the guide to SearXNG 429 errors and the limiter.

AnythingLLM and crawl4ai use the same endpoint

Nothing above is specific to Open WebUI. Every client needs the same two things: json in the format list, and a name it can resolve on a shared network.

AnythingLLM reads the instance address from AGENT_SEARXNG_API_URL, which you can also set under Agent Skills in its interface. Its search function appends q and format=json to that address itself, so give it the endpoint with no query string of your own: http://searxng-core:8080/search. When the value is missing, the agent answers in the chat with "I can't use SearXNG searching because the user has not defined the required base URL." instead of logging an error, so an agent that suddenly has no web access is worth asking directly.

crawl4ai has no search engine of its own. It crawls URLs you give it, so SearXNG becomes the step in front of it: query the JSON API, take the links, crawl them.

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

That list of URLs is what you pass to crawl4ai's arun. The same two steps are how a coding agent gets a working browser search skill, and the same JSON rows are the front door of a retrieval pipeline you host yourself.

The model answers with no sources at all

The search silently doing nothing looks the same as the model choosing not to search. Work through the chain in this order, because each step rules out the one after it.

  1. Check that web search is switched on for the message. Open WebUI only searches when the feature is enabled for that chat, and a model answering from memory produces confident text with no source list under it.
  2. Check which setting Open WebUI actually loaded. The web search variables are PersistentConfig: their values are read from the environment on first launch, written to the database, and after that the stored value wins and the compose file is ignored. Change them in Admin Panel, or set ENABLE_PERSISTENT_CONFIG to False and keep the compose file as the source of truth.
  3. Check the path with the throwaway curl container above, then watch docker compose logs -f core while you send one chat message. A request line appearing there means Open WebUI reached SearXNG, which moves the problem to the answer.
  4. Check that the engines returned anything. curl -s 'http://127.0.0.1:8080/search?q=vps&format=json' | jq '.results | length, .unresponsive_engines' returning 0 with a non-empty engine list means SearXNG answered correctly and its upstream engines refused it. That is a SearXNG problem, not an Open WebUI one.

If you get through all four and the front end is still the weak part of the setup, the other local LLM front ends worth running on a VPS all take the same endpoint URL, and moving is a matter of copying one line.

Keep the instance off the public interface

The official compose file publishes SearXNG's port on every interface unless you set SEARXNG_HOST in .env. Set it to 127.0.0.1, or delete the ports block entirely, because Open WebUI reaches the container over the compose network and never needs a published port.

The honest version of this design: an instance your LLM can reach is an instance everything else on that network can reach, and the JSON API has no authentication of any kind. Anyone who finds it gets a free search proxy. The cost lands on you, because the upstream engines rate-limit and then block the address querying them, and your own searches start coming back with empty results and a long unresponsive_engines list. Disabling the limiter makes an exposed instance worse, so the two decisions belong together: the limiter goes off only on an instance that nothing outside your own network can talk to.

FAQ

Why does Open WebUI get a 403 from SearXNG?

SearXNG compares the requested output format against the formats list in settings.yml and calls flask.abort(403) when it is missing, before contacting any search engine. Open WebUI always requests format=json, so every one of its requests is refused while the browser, which asks for HTML, still works. Add json under search.formats, restart the service, and confirm with docker compose exec core cat /etc/searxng/settings.yml that the container is reading the file you edited.

What should SEARXNG_QUERY_URL be set to?

Use the container name on a network both containers share, plus the mandatory path: http://searxng-core:8080/search?q=<query>. Do not use 127.0.0.1, because inside the Open WebUI container that address is its own loopback and nothing is listening there. Attach Open WebUI to SearXNG's network with an external: true network entry in its compose file, then verify with docker run --rm --network searxng_default curlimages/curl:latest -s -o /dev/null -w '%{http_code}\n' 'http://searxng-core:8080/search?q=vps&format=json'.

Why does the browser work but my app gets 429?

The limiter runs as a before_request hook, so it answers ahead of the search handler. It checks the User-Agent header on every request and the Accept, Accept-Encoding, Accept-Language and Sec-Fetch headers on searches, and automated clients fail those checks. A 429 means the request never reached the format check, so the format list is not your problem yet. Relax bot detection in limiter.toml, or set limiter: false on an instance that only your own containers can reach.

Open WebUI ignores the web search settings in my compose file. Why?

Those variables are PersistentConfig. Their values are read from the environment on the first launch only, then stored in the database, and on later restarts the stored value wins. Edit them in Admin Panel under Settings, or set ENABLE_PERSISTENT_CONFIG to False so the environment is authoritative again, keeping in mind that changes made in the interface then last only until the next restart.