Analytics Without Surveillance

A Private Network, a Reverse Proxy, and Ad Blockers

15.04.2026 | 27 Shawwal 1447
9 min read

بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ

You Have a Website. Do You Know What Happens on It?

I didn’t. This site was live. Pages were being added, blog posts written, content translated into five languages. But I had no idea whether anyone was reading any of it. No page view counts, no referrer data, no way to tell if the landing page was doing its job or if visitors were bouncing immediately.

The obvious answer is Google Analytics. Drop a script tag in, get a dashboard. Most websites do exactly that. But Google Analytics isn’t free. You pay with your visitors’ data. Google combines every page visit with everything else it knows about that person: search history, YouTube habits, location data. Your website becomes another data point in a profile your visitor never consented to building.

There’s also the GDPR problem. If your business serves visitors from the EU, Google Analytics requires a legally compliant consent banner. Most implementations get this wrong. The legal risk is real.

I needed analytics. I wasn’t going to use Google for it.

What the Alternative Looks Like

The tool is called Umami.

external link umami.is

Open source, MIT-licensed, privacy-focused, cookie-free. It runs on your own server. The data will inshallah never leave your infrastructure. No consent banner needed because there’s nothing to consent to. No cookies, no personal data, no cross-site tracking.

What it gives you: page views, referrers, browser and device breakdown, country of origin, and custom events. Clean dashboard, real-time data. A business owner can open it and understand what’s happening inshallah without training.

What it doesn’t give you: individual visitor profiles, session recordings, or heatmaps. It doesn’t identify individual visitors. It tells you what happened on your site.

Umami was already running on my server, hosted behind a private VPN. That’s where the interesting decisions started.

Who Can See the Dashboard?

Most self-hosted analytics guides end with “visit your-domain.com and log in.” Your analytics dashboard is now on the public internet. Anyone can find it. Automated scanners will discover it. Bots will attempt default credentials.

The dashboard is an admin tool. It has a login page, a web application, and a database behind it. Every one of those is an attack surface. If you miss a security update, the dashboard, and potentially the server, is exposed.

The Umami instance on this site doesn’t exist on the public internet. It runs behind a private network (NetBird).

external link netbird.io

The dashboard is only accessible from devices that are part of that network. To everyone else, there’s no login page, no error message, no response at all. The domain resolves, but the connection is refused. There is nothing to attack because there is nothing to find.

This is a principle that applies far beyond analytics: the highest form of security is not being visible in the first place.

But Visitors Need to Reach It

If the analytics service is hidden from the public internet, how do visitors’ browsers send data to it?

They don’t talk to the analytics service directly. They talk to the website itself. The web server (Caddy) acts as a proxy, forwarding two specific paths to the analytics service internally:

(jav_umami) {
handle /assets/js/i18n.js {
rewrite * /i18n.js
reverse_proxy 127.0.0.1:8089
}
handle /api/locale {
reverse_proxy 127.0.0.1:8089
}
}

One path serves the tracking script. The other receives the analytics data. Both are imported into the site’s Caddy block:

javedab.com {
bind 159.195.69.203
import jav_tls
root * /srv/web/javedab.com/main/current
import jav_umami
import jav_static
}

From the visitor’s perspective, both requests go to the same domain as the website. No third-party domain, no external service, no cross-origin request. The browser loads a script from the website and sends data back to the website.

The analytics service processes everything behind the scenes, completely invisible to the visitor and completely unreachable from the outside.

For a business, this means your analytics setup has no external dependency. No third-party service to trust, no external domain to maintain, no data leaving your infrastructure.

The Invisible Problem: Ad Blockers

Here’s something I didn’t expect: ad blockers block privacy-respecting analytics too.

Umami doesn’t use cookies. It doesn’t track individuals. It doesn’t share data with anyone. But major blocklists (EasyPrivacy, uBlock Origin, AdGuard) include rules targeting Umami specifically. The blockers don’t evaluate ethics. They match patterns:

Depending on your audience, 10 to 40 percent of visitors run an ad blocker. For a tech-savvy audience, it’s closer to the upper end. Those visitors will never appear in your analytics. You’ll make decisions based on incomplete data, and you won’t know it’s incomplete.

Self-hosting solves the data sovereignty problem. It doesn’t solve the ad-blocker problem. For that, I needed to go further.

Making Analytics Disappear

The goal: make the analytics requests indistinguishable from normal website assets. No special domain, no analytics-sounding paths, no recognizable script names.

This site is multilingual: English, German, Spanish, French, Chinese. A multilingual Astro site loading an internationalization script and calling a locale endpoint is exactly what you’d expect. So the tracking script is served at /assets/js/i18n.js and the collection endpoint lives at /api/locale. Both are same-origin requests. Nothing in the URL suggests analytics, tracking, or data collection.

The naming wasn’t random. I went through several options:

OptionScriptEndpointProblem
Obvious/analytics/script.js/api/sendEvery pattern on blocklists
Better/assets/js/main.js/api/logGeneric, but main.js implies a JS-heavy app
Site-specific/assets/js/i18n.js/api/localePlausible for this multilingual site

The last option wins because it tells a coherent story. An ad blocker scanning network requests from a five-language website sees an i18n script and a locale API call. Nothing to flag.

Umami supports this through two environment variables. TRACKER_SCRIPT_NAME renames the script path. COLLECT_API_ENDPOINT renames the collection endpoint. Caddy rewrites the public path to whatever Umami expects:

handle /assets/js/i18n.js {
rewrite * /i18n.js
reverse_proxy 127.0.0.1:8089
}

The browser requests /assets/js/i18n.js. Caddy rewrites it to /i18n.js and forwards it to Umami. Umami serves the tracker script. The script then posts analytics data to /api/locale, which Caddy forwards directly. The entire flow looks like normal site traffic.

When the Configuration Lies to You

This is where I spent the most time, and where the difference between “I followed a guide” and “I understand the system” showed up.

I set both environment variables, restarted the container, tested the endpoints. The server-side routing worked: curl http://127.0.0.1:8089/i18n.js returned the script, curl -X POST http://127.0.0.1:8089/api/locale returned 400 (expected, empty body). Everything looked correct.

But in the browser, the tracker script was posting to the old endpoint. /api/send, not /api/locale. The proxy didn’t know about /api/send, so the data went nowhere. The dashboard showed zero visitors. No errors anywhere. Everything looked fine. Nothing worked.

I read the source code. The tracker script is built by Rollup during the Docker image build:

rollup.tracker.config.js
plugins: [
replace({
__COLLECT_API_ENDPOINT__: process.env.COLLECT_API_ENDPOINT || '/api/send',
}),
]

The endpoint is compiled as a string literal into the JavaScript output. At runtime, the environment variable changes where the server listens, but the browser runs pre-compiled code with the old path baked in.

The official Docker image from ghcr.io/umami-software/umami ships with /api/send hardcoded in the tracker script. Setting COLLECT_API_ENDPOINT=/api/locale in the .env file makes the server accept requests on /api/locale, but the tracker script still tells the browser to post to /api/send. The two sides disagree silently.

The fix: build the image from source with the endpoint set during compilation. Two lines added to the Dockerfile’s builder stage:

ARG COLLECT_API_ENDPOINT=/api/send
ENV COLLECT_API_ENDPOINT=$COLLECT_API_ENDPOINT

Then build with the custom value:

Terminal window
podman build --build-arg COLLECT_API_ENDPOINT=/api/locale \
-t umami-custom:latest .

The build produces the same image as the official one, with one string different in one JavaScript file. After transferring the image to the service account’s Podman storage (rootless Podman users have isolated image stores, so an image built under one account is invisible to another), the tracker script posts to /api/locale. The data flows through the proxy. The dashboard works.

Verifying the fix:

Terminal window
# The tracker script should contain /api/locale, not /api/send
curl -s http://127.0.0.1:8089/i18n.js | grep -o '/api/[a-z]*'
/api/locale

The Astro Side

The tracking script needs to load on every page. In Astro, that means adding it to the shared layout:

<script is:inline defer
src="/assets/js/i18n.js"
data-website-id="..."
data-host-url="/">
</script>

Three things to note. is:inline tells Astro to leave the script tag exactly as written and not process, bundle, or deduplicate it. Astro implicitly does this for any script with attributes beyond src, but marking it explicitly makes the intent clear.

defer loads the script without blocking page rendering. Analytics should never slow down the page.

data-host-url="/" overrides where the tracker sends data. Without it, the script derives the host from its own URL. Since the script lives at /assets/js/i18n.js, it would construct the endpoint as /assets/js/api/locale instead of /api/locale. Setting the host to / fixes the base path.

This site also has a standalone 404.html that sits outside the Astro pipeline (a workaround for a framework routing bug, documented in a separate post).

internal link The 404 Page That Kept Disappearing

The same script tag is added there manually since it doesn’t go through the shared layout.

Why Not Partytown?

If you’ve looked into web performance, you’ve probably seen Partytown recommended for offloading third-party scripts. The idea is sound: move analytics, chat widgets, and tracking pixels into a web worker so they can’t block the main thread. Your page stays fast while the heavy scripts run in the background.

The question is whether the script you’re offloading is actually heavy.

The Umami tracker on this site is 2.63 KB uncompressed. After gzip, it’s 1.43 KB over the wire. It loads with defer, fires one POST request, and exits. The browser’s Performance API reports zero long tasks from it. The entire page loads four JavaScript files totaling 3.42 KB.

Partytown’s own runtime is roughly 12 KB. Adding it to offload a 1.43 KB script means shipping eight times more JavaScript than the script itself. The net effect on performance would be negative.

There’s also a complexity cost. Partytown works by intercepting DOM access through synchronous XMLHttpRequest and Atomics, proxying calls between the web worker and main thread. That mechanism is clever but fragile. Scripts that access document in specific ways can break. Debugging moves from the browser console to a worker context with limited visibility. And on the server side, Caddy would need additional CORS and cross-origin headers to serve the worker files correctly.

None of this is worth it for a script that weighs 1.43 KB, fires one request, and never touches the main thread.

Partytown solves a real problem. Google Tag Manager, Facebook Pixel, Intercom: scripts that are 50 to 200 KB, that poll the DOM, that inject elements and run continuously. Those are worth offloading. A 1.43 KB fire-and-forget tracker is not.

What This Comes Down To

Every piece of this setup solves a specific problem. Umami replaces Google Analytics without the surveillance. The private network keeps the dashboard off the public internet. The Caddy proxy lets visitors reach the analytics through the website itself. The renamed endpoints prevent ad blockers from eating the data. The custom build bakes the right endpoint into the tracker script. Each decision is small.

Together, they shape whether your website respects the people who visit it — and whether you actually know what happens on it.

That’s inshallah worth getting right.

Aerospace engineer

Ethical entrepreneur in public

You handle your business

I handle the digital side

Work with me
  • AI with honesty
  • Private infrastructure
  • Websites that perform

Tell me about your situation:

javed@javedab.com More about me