← Back to Wiki
Security / Self-Hosting

Lock Down a Self-Hosted Analytics Dashboard Without Killing Your Analytics

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.

Share on X

Why the host has to stay public

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.

Split the surface: two public endpoints, everything else closed

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.

BE WARNED: verify which collection endpoint your version actually uses. Umami v1 collected on /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 matcher that locks out your own LAN

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.

That public IP is probably dynamic. Which means this allowlist is now one more copy of an address that changes without warning, and when it changes the dashboard 404s for you with no obvious link to the cause. Reconcile it automatically rather than by hand. See one source of truth for a dynamic home IP.

Behind a CDN, the matcher needs to be told who the proxy is

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.

Verify from off-LAN, in both directions

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.

When this isn't your problem