If you self-host anything behind a Cloudflare Tunnel, you’ve seen the logs. The moment a hostname goes public, the bots show up: probes for /wp-admin, requests for .env files, /cgi-bin/ shell attempts, .git directory scraping. None of it is aimed at you specifically — it’s the internet’s background radiation, automated scanners sweeping every IP they can reach. Most of it bounces off a 404, but it’s noisy, it wastes cycles, and occasionally one of those probes finds something you forgot to lock down.
I wanted a small, dumb gate that sits right at the mouth of my tunnel and slams the door on anyone who reaches for a doorknob that shouldn’t exist. So I built Thorngate.
What is Thorngate?
Thorngate is a tiny, zero-dependency Go reverse-proxy WAF (Web Application Firewall) that sits behind a Cloudflare Tunnel and in front of your web and API services. Think of it as a gate at the mouth of the tunnel that snags intruders before they ever reach your apps.
The idea is deliberately simple. Thorngate reverse-proxies your configured path prefixes to your internal upstreams — Kubernetes services, raw IPs, whatever you point it at. But it also treats a list of configured patterns as honeypots. Any external IP that reaches for one of those patterns gets instantly and permanently blacklisted with a 403, and that blacklist is persisted to disk so it survives restarts.
A scanner that asks for /wp-admin on a site that has never run WordPress has told you everything you need to know about its intentions. So Thorngate stops talking to it. Forever.
The whole thing is about 2,500 lines of Go, built entirely on the standard library — no external modules, no supply chain to worry about, and go build works completely offline.
Where it fits
The request path looks like this:
Internet
→ Cloudflare
→ cloudflared (tunnel)
→ thorngate (WAF)
→ your app(s)
And the logic each request runs through is just four steps:
- Read the client IP from the
Cf-Connecting-Ipheader. - If that IP is blacklisted → return
403. - If the path matches a honeypot → blacklist the IP, persist it, return
403. - Otherwise, proxy to the upstream with the longest matching path prefix.
That’s the entire mental model. Everything else — temp-bans, request history, the stats dashboard — is optional sugar layered on top of those four steps.
The one security assumption
Thorngate trusts the Cf-Connecting-Ip header to identify the real client. That trust is the load-bearing assumption of the whole design, and it only holds because the Thorngate Service is ClusterIP-only — reachable solely by the in-cluster cloudflared pod and never exposed directly. If you put Thorngate behind a LoadBalancer or Ingress, anyone could spoof that header and either dodge the blacklist or get someone else banned. Keep it internal and the assumption holds.
Honeypots: the core feature
Honeypots are the heart of Thorngate. You define a list of path patterns that no legitimate visitor would ever request, and anyone who does is blacklisted on the spot. There are five match modes so you can be as broad or as surgical as you like:
"honeypots": [
"/wp-admin", // prefix (boundary-aware)
{ "pattern": ".php", "match": "contains" }, // anywhere in the path
{ "pattern": ".env", "match": "suffix" }, // ends with
{ "pattern": "/cgi-bin/*", "match": "glob" }, // shell-style glob
{ "pattern": "\\.(git|svn|hg)(/|$)", "match": "regex" } // full Go regexp
]
One detail I care about: prefix matching is boundary-aware. /api matches /api and /api/users, but it does not match /apixyz. That keeps a legitimate route from accidentally getting swept up because it happens to share a string prefix with a honeypot.
The honeypot request itself isn’t proxied to your upstream — there’s no reason to bother your app with a request you’ve already decided to reject — so it never even shows up in its app’s logs.
Temporary bans for the slow scanners
Not every bad actor trips a honeypot. Some just hammer your endpoints looking for weak spots, generating a stream of 401s and 404s. For those, Thorngate has an optional soft layer: temp-bans.
"temp_ban": {
"enabled": true,
"status_codes": [401, 403, 404, 429],
"max": 20,
"window": "1m",
"ban_duration": "15m"
}
That config says: if any single IP produces more than 20 responses in those status codes within a one-minute sliding window, ban it for 15 minutes. The bans expire lazily — they’re cleared on the IP’s next request or at startup — so there’s no background sweeper burning CPU for the common case where nobody’s misbehaving.
Honeypots are the permanent hammer; temp-bans are the rate-limiter for the patient ones.
Forensics: request history on ban
When Thorngate blacklists an IP, it dumps that IP’s recent request history to the log so you can see exactly what the attacker was doing right before the door closed:
BLACKLISTED ip=9.9.9.9 honeypot=/wp-admin ua="curl/7.64.1" total=6
history ip=9.9.9.9 reason=honeypot 1/3 method=GET host="app.example.com" path="/" status=200
history ip=9.9.9.9 reason=honeypot 2/3 method=GET host="app.example.com" path="/robots.txt" status=404
history ip=9.9.9.9 reason=honeypot 3/3 method=GET host="app.example.com" path="/.env" status=404
The history is a bounded per-IP ring buffer with a configurable depth, a cap on how many distinct IPs are tracked, and a TTL that drops idle entries. It lives entirely in memory and is never persisted — it’s there for live forensics, not long-term storage, so it can’t balloon your memory or your disk.
A built-in dashboard
Thorngate keeps in-memory traffic counters and serves a small, self-contained admin dashboard from them. You get:
- Headline totals — requests, blocked, honeypot hits, temp-bans
- A 2xx / 3xx / 4xx / 5xx response breakdown
- A per-minute traffic-over-time chart (60-minute window by default)
- A live feed of recent requests, each with its IP, method, path, status, and outcome
The dashboard pulls Tailwind in the browser, so the styling never becomes a dependency of the actual binary — Thorngate itself stays dependency-free.
The admin API that backs it is token-protected and, importantly, runs on a separate port that you keep cluster-internal. You manage the blacklist live over it:
TOKEN=change-me-locally
# list everything currently blocked
curl -H "Authorization: Bearer $TOKEN" localhost:9000/admin/blacklist
# block an IP, or a whole CIDR range
curl -H "Authorization: Bearer $TOKEN" -d '{"ip":"1.2.3.4"}' localhost:9000/admin/blacklist
curl -H "Authorization: Bearer $TOKEN" -d '{"ip":"1.2.3.0/24"}' localhost:9000/admin/blacklist
# pardon an IP
curl -H "Authorization: Bearer $TOKEN" -X DELETE localhost:9000/admin/blacklist/1.2.3.4
The token can live in the config file or come from the THORNGATE_ADMIN_TOKEN environment variable, which maps cleanly to a Kubernetes Secret.
Routing and protocol upgrades
Thorngate isn’t just a single-app gate. It has a default upstream for everything, plus optional hostname-based overrides with wildcard support:
"upstream": "10.0.0.10:8080",
"routes": [
{ "host": "api.example.com", "upstream": "10.0.0.5:3000" },
{ "host": "*.internal.example.com", "upstream": "10.0.0.6:9000" }
]
A *.example.com wildcard matches a.example.com and a.b.example.com, but deliberately not the bare apex example.com. And because plenty of real apps need WebSockets or SignalR, Thorngate transparently passes through protocol upgrades via Go’s http.Hijacker interface — upgraded connections get recorded as a 101 so they still show up in your stats.
Running it
The fastest way to try it is the published container image. Thorngate ships as a multi-architecture (amd64 + arm64) distroless image on GitHub Container Registry:
docker run -p 8765:8765 \
-v /path/to/config.json:/etc/thorngate/config.json \
ghcr.io/timothydodd/thorngate:latest
The runtime image is gcr.io/distroless/static-debian12:nonroot — no shell, no package manager, running as a non-root user. The binary is built with CGO_ENABLED=0 and -ldflags="-s -w", so it’s a single static binary and the whole image is tiny.
If you’d rather build from source:
go build -o thorngate ./cmd/thorngate
./thorngate -config config.json # listens on :8765
# simulate a Cloudflare request tripping the ".php contains" honeypot
curl -H "Cf-Connecting-Ip: 9.9.9.9" http://localhost:8765/x/shell.php # 403, IP now blocked
curl -H "Cf-Connecting-Ip: 9.9.9.9" http://localhost:8765/ # 403 forever
go test ./...
On Kubernetes
Since this is what I actually run it on, the repo ships a complete k3s manifest under deploy/k3s/. It wires up a namespace, a ConfigMap for the config, a Secret for the admin token, a small PersistentVolumeClaim so the blacklist survives pod restarts, and two ClusterIP Services — one for traffic, one for the admin API. It’s frugal: 25m CPU / 32Mi RAM requested, 250m / 128Mi limits.
kubectl apply -f deploy/k3s/thorngate.yaml
# reach the dashboard without exposing it
kubectl -n thorngate port-forward svc/thorngate-admin 9000:9000
# then open http://localhost:9000/admin/ and paste your token
Your cloudflared ingress then points at Thorngate instead of directly at your app:
ingress:
- hostname: example.com
service: http://thorngate.thorngate.svc.cluster.local:80
- service: http_status:404
A few design decisions I’m happy with
Zero dependencies, on purpose. The entire thing is standard-library Go. There’s no go.sum full of transitive packages to audit, nothing to fetch, and the build works on a plane. For a piece of security infrastructure, the smaller the trust surface the better.
Atomic blacklist persistence. The blacklist is written with the write-temp → fsync → rename dance, so a crash mid-write can never leave you with a half-written, corrupted blocklist.
Zero overhead when features are off. If you don’t enable temp-bans, request history, or stats, Thorngate skips wrapping the response writer entirely and proxies straight through. You only pay for the inspection you actually asked for.
Whitelist always wins. A whitelisted IP is never blocked, even if it falls inside a CIDR range you’ve banned. Whitelist your own admin IP and your internal ranges first thing, so you can’t lock yourself out.
Scaling
The default is a single replica, because the blacklist lives in memory and on a local file on a ReadWriteOnce volume. That’s plenty for a homelab or a small fleet of services behind one tunnel. If you ever needed to run multiple replicas, the blacklist package is deliberately small and interface-driven — swapping the file store for a shared backend like Redis (SET / SISMEMBER) is roughly a 30-line change, and then every pod sees the same blocklist.
Get Thorngate
Thorngate is open source and MIT-ish simple to drop into an existing tunnel setup. Grab the code or pull the container:
- Source: github.com/timothydodd/thorngate
- Container: ghcr.io/timothydodd/thorngate
If you’re running services behind a Cloudflare Tunnel and you’re tired of watching the scanner noise roll past in your logs, point them at a honeypot list and let the gate do the rest.