Search engines know too much about you. Every query, every click, every “how do I fix this embarrassing error” 2am search — logged, profiled, monetized. If you’re already running a homelab with a reverse proxy and an auth layer, there’s no good reason to keep feeding that machine. Run your own.
This is the build: SearXNG as a self-hosted metasearch engine, Authelia sitting in front of it for real authentication, Caddy doing the reverse-proxy handoff, and the whole thing wired into both your desktop browser and Open WebUI as the default search backend. No third-party API keys, no query logs leaving your network, no ads.
The architecture
Browser / Open WebUI
│
▼
Cloudflare (DNS + proxy, optional)
│
▼
OPNsense / HAProxy (TLS termination)
│
▼
Caddy (reverse proxy, forward-auth)
│ │
▼ ▼
Authelia SearXNG
(auth) (search engine)
Caddy fronts everything and asks Authelia “is this request authenticated?” before forwarding to SearXNG. Browse to the search domain, get bounced to a login page with 2FA. Once authenticated, the session cookie carries you through.
If the OPNsense/HAProxy layer in that diagram looks unfamiliar, Beyond the Tunnel covers exactly this piece — HAProxy, ACME, and VLAN isolation sitting behind a WireGuard tunnel, which is what actually gets traffic from “the internet” down to Caddy in the first place. And if you’re starting from zero on getting traffic in at all — no public IP, ISP behind CGNAT — Bypassing CGNAT with an OPNsense WireGuard Tunnel is the prerequisite underneath that.
The one wrinkle: Open WebUI’s web-search feature calls SearXNG’s JSON API directly, with no human in the loop to click through a login screen. We’ll handle that with a network-scoped bypass rule rather than leaving the whole instance open.
Step 1 — Add SearXNG to your Docker Compose stack
If you’ve already got Caddy and Authelia running together — covered in One Login to Rule Them All, where the same Caddy-fronts-Authelia pattern was first wired up for Open WebUI and Nextcloud — this is just one more service in the same file.
yaml
services:
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
networks: [dmz]
environment:
- SEARXNG_BASE_URL=https://search.yourdomain.com/
- TZ=America/yourtimezone
volumes:
- ./searxng:/etc/searxng:rw
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
logging:
driver: "json-file"
options:
max-size: "1m"
max-file: "1"
A few things worth calling out:
SEARXNG_BASE_URLis what SearXNG uses to generate every self-referencing link — the OpenSearch descriptor, redirects, form actions. Get this wrong and things will mostly work, then quietly break in specific places (more on that pain later).- Dropped capabilities — SearXNG doesn’t need root-level Linux capabilities to run. Drop everything, add back only
CHOWN/SETGID/SETUIDfor the entrypoint’s user-switching. Standard container hardening, costs nothing. - Bounded logs —
max-size/max-filekeep the container’s logs from growing unbounded on a long-running homelab box nobody babysits daily.
Bring it up:
bash
docker compose up -d searxng
Step 2 — Wire Caddy to front it
Add a site block to your Caddyfile. If you’ve already got a forward-auth snippet defined for other internal services — this is the exact same forward_auth block from One Login to Rule Them All — reuse it rather than writing a new one:
caddyfile
(forward_auth_block) {
forward_auth authelia:9091 {
uri /api/verify?rd=https://auth.yourdomain.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
}
}
search.yourdomain.com:80 {
import forward_auth_block
log {
output file /var/log/caddy/access.log
}
header Strict-Transport-Security "max-age=15552000; includeSubDomains"
reverse_proxy searxng:8080 {
header_up Host {host}
header_up X-Forwarded-Proto https
}
}
Notice this listens on port 80, not 443. If TLS is terminated upstream (OPNsense/HAProxy, Cloudflare) and forwards plain HTTP into your Docker host, Caddy doesn’t need to handle certificates at all — auto_https off at the top of the Caddyfile makes that explicit.
Reload, don’t just restart, when testing Caddyfile edits live:
bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
restart runs the existing container with the config it started with. reload tells the running process to re-read the file. They are not the same thing, and conflating them cost me twenty minutes of confusion mid-build — a request kept slipping past auth entirely, and the running config simply hadn’t changed.
Step 3 — Authelia access control
In Authelia’s configuration.yml, add a rule for the new domain:
yaml
access_control:
default_policy: deny
rules:
- domain: search.yourdomain.com
policy: two_factor
Validate before you restart anything — it’s free insurance against a YAML typo taking down your auth layer:
bash
docker exec -it authelia authelia validate-config --config /config/configuration.yml
docker restart authelia
Authelia’s session store (SQLite or Redis, depending on your setup) persists across restarts, so this won’t force-logout anyone already authenticated elsewhere. If you haven’t set up 2FA itself yet — TOTP enrollment, real SMTP for notifications, and a hostname-vs-IP TLS gotcha that’s easy to walk straight into — Locking Down Authelia covers that foundational setup before any of this access-control layer matters.
At this point, visiting search.yourdomain.com in a browser should redirect you to Authelia’s login page, prompt for 2FA, and land you on SearXNG after authenticating.
Step 4 — The Open WebUI problem, and the fix
Open WebUI’s web-search integration hits SearXNG’s API directly:
GET https://search.yourdomain.com/search?format=json&q=...
No browser, no cookie jar, no way to click through a login page. If your forward-auth rule applies universally, every one of these calls gets rejected — and crucially, an unauthenticated SearXNG instance with no access control is worse, since anyone on the internet could query it freely. If you’re exposing anything publicly at all, it’s worth having the broader edge-hardening picture in place — Hardening Your Homelab’s Public Edge covers GeoIP blocking and CrowdSec, the layer that sits in front of everything else described here.
The right middle ground is a network-scoped bypass: Authelia skips the auth challenge only for requests originating from inside your trusted network, not for the whole world. In configuration.yml:
yaml
access_control:
rules:
- domain: search.yourdomain.com
resources:
- "^/search.*$"
networks:
- 192.168.1.0/24 # your LAN/VLAN — wherever Open WebUI's host lives
policy: bypass
- domain: search.yourdomain.com
policy: two_factor
The networks key scopes the bypass to source IP, not URL path. That distinction matters: a path-based bypass with no network restriction means anyone hitting that path skips auth, which defeats the point of putting Authelia in front of an internet-facing service in the first place. Scoping by network means the bypass only ever fires for traffic that’s already inside your perimeter. It’s the same network-scoped-trust principle behind the anti-lockout rule in Never Lock Yourself Out — trusting a request because of where it comes from, not just what it claims to be.
In Open WebUI’s admin settings (Admin Panel → Settings → Web Search), point it at:
http://searxng:8080/search?format=json
— the internal Docker service name, not the public domain. Since Open WebUI and SearXNG share a Docker network, there’s no reason to route this traffic out through Cloudflare and back in. Faster, and one less thing that can break.
Step 5 — Set it as your desktop default
In Firefox: about:preferences#search → Search Shortcuts → Add.
Name: SearXNG
Search string: https://search.yourdomain.com/search?q=%s
Select it under Default Search Engine, and every address-bar search now routes through your own instance, behind your own auth, with zero telemetry leaving your network beyond the search itself.
One practical note: with full Authelia 2FA in front of this, a long session expiration is worth setting deliberately, or you’ll be re-authenticating constantly for what should be a casual address-bar search:
yaml
session:
cookies:
- name: authelia_session
domain: yourdomain.com
authelia_url: "https://auth.yourdomain.com"
expiration: 90d
inactivity: 90d
90 days with 2FA on login is a reasonable balance — the login event is still strong, it just doesn’t need to repeat constantly for a service you check daily.
Things that bit me, in case they bite you
Environment variable changes don’t apply on restart. I changed SEARXNG_BASE_URL in the compose file and ran docker compose restart searxng. Nothing changed — the container kept the environment it was originally created with. restart cycles the existing container; it does not recreate it. Editing an env var in docker-compose.yml needs:
bash
docker compose up -d searxng
up -d diffs the desired state against the running container and recreates it if anything — including environment — has changed. This is the single most common trap in this entire build, and I hit it twice in one session before it stuck.
A renamed domain needs the cert checked, even if you’re sure it’s covered. If you’re running a wildcard cert (*.yourdomain.com), a new subdomain is automatically covered — but it’s worth confirming with a direct check rather than assuming:
bash
echo | openssl s_client -connect search.yourdomain.com:443 -servername search.yourdomain.com 2>/dev/null \
| openssl x509 -noout -subject -issuer
If the browser throws a cert error referencing a domain you didn’t type, don’t assume the server is wrong before checking — browser-side HSTS caching and stale security-policy state can produce a cert error pointing at a hostname that has nothing to do with what’s actually being served. Verify the live cert from the command line first; it settles the question in one command instead of an hour of guessing. This is the same family of hostname-vs-certificate confusion covered in more depth in Locking Down Authelia — worth a re-read if this bites you here too.
Long-running containers can carry stale internal DNS. If a container’s been up for days while your host’s network configuration shifted underneath it, Docker’s embedded resolver inside that container can be working from outdated upstream DNS servers — it has no reason to refresh unless the container restarts. The symptom is oddly specific: internal Docker-network traffic works fine, but anything needing to resolve a real internet or LAN hostname fails outright. The fix is just a restart of that specific container, not a config change.
Where this leaves you
A search engine you fully control, parked behind real authentication, reachable the same way from a browser tab or from your local LLM’s tool-calling. No query logs handed to a third party, no API quotas, no ads. It took a Caddyfile block, one Authelia access-control rule, and a Docker Compose service — most of an evening was actually spent chasing two completely unrelated DNS issues that had nothing to do with SearXNG itself, which feels like the real lesson of running anything self-hosted: the new thing usually works fine. It’s the old thing sitting quietly next to it that breaks.
If you’re banning the inevitable scanners and bots that find this instance anyway, From App Logs to Firewall Blocks closes the loop on the network side — taking fail2ban’s detections and turning them into OPNsense-level blocks before a retry ever reaches Caddy at all.
More from the homelab security series
This post sits at the end of a fairly continuous build, in case you’re reading it without the others:
- Bypassing CGNAT with an OPNsense WireGuard Tunnel — getting traffic in at all, with no public IP
- Beyond the Tunnel: HAProxy, ACME, and VLAN Isolation — deciding what’s exposed once it’s in
- Hardening Your Homelab’s Public Edge — GeoIP and CrowdSec at the perimeter
- One Login to Rule Them All — Authelia as SSO for Open WebUI and Nextcloud
- Locking Down Authelia — real 2FA, real SMTP, and the TLS trap
- From App Logs to Firewall Blocks — fail2ban detections becoming OPNsense WAN blocks
- Never Lock Yourself Out — the safety net underneath all of the above
- This post — SearXNG behind Authelia, the newest piece on top of that stack
