A self-hosted analytics instance had no path restrictions on its reverse proxy, so
/login and /dashboard both returned 200 from anywhere on the internet. That is a
credential-attack surface on an admin panel, and it had been open since the service went in. The obvious fix
is to make the hostname internal-only. You cannot. Doing that ends analytics for every
external visitor, which is all of them.
Client-side analytics works by a visitor's browser fetching a script from your analytics host and posting events back to it. Umami, Plausible, Matomo, all the same shape. The host is a dependency of every page load on every site you track, from every network in the world.
So "put it behind the VPN" trades one problem for a bigger one. The surface has to be split by path instead of by host.
Exactly two things need to be public. The tracker script, and the collection endpoint:
analytics.example.com {
# public: the tracker script and event collection, nothing else
@public {
path /script.js
method GET
}
@collect {
path /api/send
method POST
}
@admin {
not path /script.js /api/send
not client_ip <your-egress-ip>
}
respond @admin 404
reverse_proxy localhost:3000
}
Return 404 rather than 403. A 403 confirms there is something there to be forbidden from. A 404 tells a scanner the path does not exist and is worth nothing to them.
/api/collect and v2 uses /api/send. Allowlist the wrong one and analytics
silently stops, with a dashboard that looks fine and a graph that flatlines. Check against the running
instance rather than the docs for whichever version you read:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://analytics.example.com/api/send
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://analytics.example.com/api/collect
A 400 or 422 means the endpoint exists and disliked your empty body, which is the answer you want. A 404
means it is not there.
The natural allowlist is your private ranges. client_ip 192.168.0.0/16 and done.
That locks you out, and the reason is worth internalising. With no split-horizon DNS for the domain, a
browser on your LAN still resolves the analytics hostname to your CDN. The request leaves your
house, reaches the edge, and comes back to your origin reporting your public egress address.
It never arrives as a 192.168.x address at all. Your laptop is on the couch and the packet has
been to another country.
So the load-bearing entry is your public egress IP, not the private ranges. Keep the private ranges anyway
for the direct-to-origin path, when you hit the proxy by LAN IP with a Host header, but do not
expect them to carry normal browsing.
A client_ip matcher compares against the connecting address, which behind a CDN is always the
CDN. Without configuration it can never match your egress IP, because it never sees it. You need a global
block declaring your CDN's ranges trusted so the proxy reads the forwarded header instead:
{
servers {
trusted_proxies static <cdn ranges>
}
}
That block is also what closes the obvious bypass, and it is the subject of its own guide: behind a CDN, every client-IP check you write is wrong. Sites on the same proxy that are not behind the CDN are unaffected, because their connections do not originate from those ranges.
Get on cellular data or any host outside your network, and check that you closed the right half:
# must still work, or you have broken analytics for the world
curl -s -o /dev/null -w 'script: %{http_code}\n' https://analytics.example.com/script.js
# must be 404 now
curl -s -o /dev/null -w 'login: %{http_code}\n' https://analytics.example.com/login
# and confirm a real pageview still records, not just that the file loads
Then check the dashboard still opens from home. Both halves matter, and it is entirely possible to get one right and the other wrong.