Zoraxy — Reverse Proxy

Zoraxy — Reverse Proxy
Zoraxy — Reverse Proxy

Updated 21 October 2026. This replaces the 13 October version. The plugin is now v0.3.3, the weekly report script is in the plugin's repository instead of pasted here, there is a public analysis of six months of real scanner traffic, and the last section explains how I use Claude to run all of this, because several people asked.

If you've spent any time self-hosting at home you've hit the same wall I did: one public IP address, a dozen services you want to reach from the internet, and the question of how to route it all cleanly. That's what a reverse proxy solves, and Zoraxy is the one I've settled on.

What is Zoraxy

Zoraxy is an open-source, general-purpose HTTP reverse proxy written in Go by Toby Chui. Unlike Nginx Proxy Manager, Caddy or Traefik it was built to be approachable. The repo describes it as being for "networking noobs", which is accurate and also undersells it: it is a fully featured reverse proxy with a clean web UI that doesn't make you touch a config file to get started.

What makes it useful in a homelab:

  • TLS with ACME automation, including DNS-01 for wildcard certificates, applied automatically to matching proxy rules.
  • WebSocket proxying that just works.
  • Access control at the proxy layer: IP blacklists and whitelists (single IPs, CIDR, wildcards), country blocking and basic auth per host.
  • Built-in exploit blocking for common attack patterns, logged and returned as a 403.
  • Uptime monitoring, a static file server and TCP/UDP stream proxying.
  • A plugin system, which is where this post has changed the most.

Pros: dead simple to start; wildcard certificates that work; one place for security (not entirely, see below); a light Go binary; self-contained, so a backup is a tarball.

Cons: a hardcoded five-minute WebSocket deadline (upstream issue #1159); one instance and one admin, so no clustering; a limited API; a basic static file server; exploit-detection false positives on legitimate admin calls, which is noise you have to filter in reporting; and it is a single point of failure.

How Zoraxy fits into the network

The shape matters more than my addresses. A small dedicated Debian container runs the proxy on Proxmox; the applications live in separate containers on their own hosts. All external traffic reaches Cloudflare first, where WAF rules drop crawlers, scanners and automated probes. A Cloudflare tunnel runs on the proxy container, so port 443 never touches the router's WAN interface. Zoraxy then applies its own layer (IP and country rules, rate limiting, exploit detection) and forwards to the right backend, terminating TLS internally so traffic stays encrypted on the LAN. Cloudflare filters the internet; Zoraxy filters what Cloudflare passes.

One thing silently defeats the second layer, and it caught me out. Behind a tunnel, the proxy sees connections coming from itself, because the tunnel daemon relays them locally. Unless the tunnel's address is in Zoraxy's trusted proxies and "Trust proxy headers only" is on for each access rule, Zoraxy judges every rule against its own address instead of the visitor's. A country whitelist then waves everyone through and IP blacklists never match. Worse, Cloudflare's protection keeps working, so nothing looks wrong. Test it:

curl -sk -o /dev/null -w '%{http_code}\n' --resolve <host>:443:<proxy-ip> \  -H 'CF-Connecting-IP: 8.8.8.8' https://<host>/

On a country-restricted host that must return 403. If it returns 200, your access rules aren't doing anything for tunnel traffic.

The Cloudflare layer: five rules, no spare slot

The free plan allows five custom rules per zone, so each has to earn its place. Mine, in order: a challenge for traffic from the hosting networks that originate most scanning, including cloud addresses that claim to be verified bots; a block for verified crawler categories and scripted-client and scanner user-agents; a block for path probing (WordPress, .env, .git, cloud metadata addresses); the plugin's blocklist rule; and, new on 21 October, a block for Cloudflare's alternate ports.

That last one is worth copying if you only serve 443. Cloudflare proxies HTTPS on 2053, 2083, 2087, 2096 and 8443, and HTTP on 2052, 2082, 2086, 2095, 8080 and 8880. Scanners sweep them, and in my logs most of those requests were passed through to a real application and answered 200. One rule fixes it:

cf.edge.server_port in {2052 2053 2082 2083 2086 2087 2095 2096 8080 8443 8880}   →  Block

I tested it from outside. Before the rule, 443, 8443 and 2053 all answered 200. After it, 8443, 2053, 2083 and the HTTP-only alternates returned 403, and the normal site on 443 still answered 200. (A rule that shows zero events isn't necessarily broken. Test it with a harmless request instead of trusting the counter.)

A second rule worth knowing: the plugin keeps one account-wide IP list, so adding ip.src in $your_list_name by hand to any other zone in the same account gives that zone the same protection, with the action you prefer.

The weekly forensic script

The script that summarises Zoraxy's access log is now version 2.10.0 and lives in the plugin's repository: scripts/forensic-report.sh, with a README covering install, configuration and how to read it. It is read-only. I stopped pasting it here because a 650-line script in a blog post goes stale in a week; the old copy was two versions behind.

What it gives you: who is hitting the proxy (LAN and outside kept apart), what was refused and by which Zoraxy layer, a blocked clients table where a 170-request scanner burst is one row instead of 170 lines, who is behind your 401s, which backends are failing, and which alternate ports are being swept. A second, private file covers certificate expiry, renewals, restarts and plugin health, and is deliberately kept out of anything I share.

Two lessons from building it. First, a report section that prints only the matching lines understated blacklist refusals roughly fiftyfold, so I changed it to print counts per layer per day. Second, a 401 spike that looked like a credential attack was one browser tab retrying an expired session.

The Zoraxy Cloudflare WAF plugin (v0.3.3)

The forensic reports told me who was attacking me, a week later. So I wrote a plugin that acts in seconds: Braedach/zoraxy-cloudflare-waf. It is now listed in Zoraxy's plugin store.

It watches the same access log and flags a client three ways: an exploit pattern in the request; a sensitive-path probe, which is a request for something only a scanner asks for (/.git/, /.env, cloud and tool credential directories, SSH keys), whatever the status code; or an error burst. The path rule exists because single-page apps answer any path with 200, so a scanner sweeping them never produces the errors the burst rule counts. In a 38-hour dry run the burst rule alone caught 8 scanners and missed 9 more.

A flagged address goes into one Cloudflare IP list, referenced by a single custom rule (so unlimited addresses use one of your five slots), and optionally into Zoraxy's own blacklist as a second layer. Bans expire after 14 days by default, because scanners use rented cloud addresses that are later reused by legitimate services. It ships inert: disabled, in dry run.

In production, to 21 October: 94 distinct addresses banned from 97 ban actions (81 by a sensitive-path probe, 10 by an error burst, 5 relayed from Zoraxy's own blacklist, 1 by an exploit pattern). The ban typically landed within one to two seconds of the first probe. The Cloudflare list, the plugin's own record and Zoraxy's blacklists all held the same 62 addresses, with no private address among them. Expiry has removed 77 aged bans without error.

What changed in v0.3.3: Zoraxy restarts every Sunday when it updates itself, and the plugin's "Recent actions" table lived in memory, so it was emptied every week. Actions are now written to a small history file and restored on start. It is size-capped, private to the owner, and a write failure can never stop a ban. The release has 71 tests, and the builds are reproducible: I rebuilt earlier releases from their tags and they matched the published binaries byte for byte.

The plugin README also has the part I'd read first if I were you: how to scope the Cloudflare token to exactly two permissions, the trusted-proxy trap above, and how to uninstall cleanly.

A public analysis, for other administrators

I published an analysis of six months of what a small home-lab proxy sees on the open internet. It is written to be useful, not sanitised into nothing: the scanning campaign from cloud-hosted addresses, the paths it hunts, the spoofed crawler names, how fast each layer stopped each burst, the alternate-port sweeps, reusable Cloudflare rules, and a checklist to reproduce the set-up. Internal detail, the operator's own devices and open weaknesses are left out, and it was written fresh rather than redacted from the internal version, because redaction by editing leaks.

Zoraxy forensic network analysis, public edition (rev 1.7)

Working with Claude

I don't do this by hand any more. I use Claude Code (Anthropic's coding agent) as an operator, and several people asked how it is set up, so here is the honest version, including what went wrong.

The setup. Claude Code runs on my desktop and reaches the servers the way I would: SSH to the hypervisor, then into the containers. A separate build container compiles and tests the plugin. For Cloudflare it uses my logged-in browser through the Chrome extension, so it can read and edit rules in the dashboard under my own session, with no extra credentials handed over. The API token for the plugin stays in the plugin's own config file and is never printed. Work goes through git branches and pull requests, and the repository holds the documentation that makes the next session start informed.

The rules I work by.

  1. Read first, change second. Every task starts with looking at the live system, not remembering it.
  2. Approvals for anything outward-facing. Merging, publishing, editing a Cloudflare rule or touching production needs my say-so, and the tool blocks some of those until I give it. That friction is a feature.
  3. Test the thing, then test it live. The plugin has unit tests; every release is then deployed, hash-checked against the build, and watched in the journal.
  4. Separate sessions for code and for reports, because they need different access, and a mixed conversation gets hard to trace.
  5. Write the corrections down. My first AI-written analyses contained confident errors: counts read from a filtered section, a Zoraxy version copied from last week, a scanner called a breach. The fix was a short advisory document of standing rules, which the assistant reads first. A generic version is in the repo: REPORT-GUIDE.md.
  6. Never let it write what it hasn't verified. If a number can't be checked, the report says so.

What it got wrong. It told me a Zoraxy release candidate was one number when the weekly updater had moved it to the next. It described a burst on four alternate ports as continuing when the timestamps showed one burst. It committed to a public repository under my personal email until I noticed, and had to re-author the commits. Each was caught because the next step was to check against the live system or the raw data. That is the point: the assistant is fast and thorough, and it still needs a reviewer.

What it is good at. Reading logs and cross-checking five sources against each other; building and verifying releases the same way every time; writing documentation as it goes; and doing the tedious second pass, such as re-running the report against every month of logs to see which numbers still reproduce.

If you want to try it:

  • Start with read-only access to one machine and a task you can verify, such as "summarise last week's log and say what you couldn't check".
  • Give it a dedicated low-privilege SSH key, not your own, and scope every API token to the minimum.
  • Keep the secrets out of its reach and out of git, and tell it that rule explicitly.
  • Make it work in branches, and merge them yourself.
  • Keep a document of standing rules and corrections, and add to it every time it gets something wrong.
  • Treat a change to a production system like any other change: test it, then watch it.

Conclusions

Was there a breach? No. Do I have better visibility? Substantially. The weekly report shows what happened; the plugin shows what is happening, in the journal, as it happens, and then does something about it. The report is the analysis, the plugin is the reflex.

What changed practically: blocking is automatic and takes seconds; the single-page-app blind spot is covered; bans expire, so the list stays current; enforcement happens at Cloudflare's edge, with Zoraxy's blacklist as a local backstop; and a misconfigured trusted-proxy setting had been quietly nullifying my country rules for tunnel traffic. Worth checking on your own setup.

Zoraxy isn't the most powerful reverse proxy available and it has rough edges. For a homelab running dozens of services on one public IP, the combination of simplicity, TLS automation, a security layer that tells you what it is doing and a plugin system open enough to bolt on exactly what was missing is hard to argue with.

I have learnt heaps doing this project. When you think about opening that brand new service to the internet so you can use it while you're away, have a long, hard think. Securing this stuff is incredibly difficult. I wasn't planning to write a plugin. I got tired of chasing my tail.

Why edge protection isn't paranoia

The scanning is constant, automated and indiscriminate. Nobody chose my domains. They are in the address space, and someone's bot sweeps it continuously looking for a .git directory, an exposed .env, or a credential file left in a web root. Four hosts sweeping for .DS_Store seven seconds apart isn't a person, it's infrastructure, and the only realistic answer is infrastructure that responds automatically, at the edge, before the request reaches anything that matters. The Radware 2026 Global Threat Report is the "why" behind all of this; it is worth your time (see Radware's own site for the current download).

Until the next report then.

#enoughsaid

Written with Claude, edited by me. The figures were checked against the live system on 21 October 2026.