Analytics Without Surveillance

What Google Analytics Actually Costs You

15.04.2026 | 27 Shawwal 1447
12 min read

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

You have a website. Do you know what happens on it?

Most don’t. This one didn’t either. The site was live, pages going up, posts getting written, content translated into five languages. None of that produced a single signal of whether anyone was reading. There were no page view counts or referrer data, and 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.

The site needed analytics. Not from Google.

Quick definitions
What does self-hosted mean?

Software that runs on a server you control, not on a third party’s platform. The data, the configuration, and the access stay with you.

What is privacy-respecting analytics?

A way to count and understand visitors to your site without identifying them as individuals. No cross-site tracking, no profile of their other browsing, no third party touching the data. You see what happens on your site; no one else does.

What is a private network?

A network that only members can see. Devices outside the network do not know the services on it exist, so admin tools (like an analytics dashboard) can run there without showing up to the public internet at all.

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 inshallah open it and understand what’s happening, 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 the server, 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 to an IP that only exists inside the VPN; from the public internet there is nothing to connect to. 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.

What runs underneath

The analytics service runs as two containers in a Podman pod: PostgreSQL and the Next.js app, sharing a network namespace so the app reaches the database without ever exposing it. Everything runs as a dedicated service-account user with no shell, monitored alongside the rest of the server’s services. Beszel tracks resource health (CPU, RAM, disk, network); Dozzle handles logs (what the service is doing, what errors it hit). Resources and logs answer different questions, and a service you trust in production needs both.

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. With Umami’s documented defaults, that looks something like this:

(umami_proxy) {
handle /umami.js {
reverse_proxy 127.0.0.1:8089
}
handle /api/send {
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:

example.com {
import tls_config
root * /var/www/example.com
import umami_proxy
import static_files
}
Not reachable from public internetHTTPStwo proxied pathsNetBird tunnelVisitor browserAdmin device on NetBirdCaddy reverse proxyUmami appPostgreSQL

From the visitor’s perspective, both requests go to the same domain as the website, with no external service involved and 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, no vendor sits between you and your analytics. Pricing inshallah can’t change overnight, features can’t be deprecated, and the product can’t be acquired and shut down. The historical data stays on the server you control, queryable and exportable on your terms.

The invisible problem: ad blockers

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:

Estimates of ad-blocker usage commonly fall in the 10 to 40 percent range, higher for younger and more tech-savvy audiences. 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. The fix has to go further.

Making analytics disappear

The fix to the ad-blocker problem is conceptual, not technical. Make the analytics requests look like ordinary site assets. A different name on the script, a different path on the endpoint, both blending with whatever else the site already loads. The blocklist patterns don’t match anymore.

A reverse proxy maps a public path to whatever the application expects internally. The browser sees an ordinary same-origin request; the analytics service receives the data behind it.

When the configuration lies to you

This took the longest. It’s where the difference between “I followed a guide” and “I understand the system” shows up.

Umami exposes two environment variables for the rename. TRACKER_SCRIPT_NAME controls the filename the server serves the tracker script under. COLLECT_API_ENDPOINT controls the path the tracker posts data to. Both look like ordinary runtime configuration.

TRACKER_SCRIPT_NAME is. The script’s filename is decided when the server responds to a request. Set the env var, restart, the server serves the script under the new name. Done.

COLLECT_API_ENDPOINT is the trap. After setting it and restarting, the server-side routing obeyed. It accepted requests at the new path. Everything looked correct. But in the browser, the tracker was still posting to /api/send. The proxy didn’t know about that path, so the data went nowhere. The dashboard showed zero visitors. No errors anywhere. Everything looked fine. Nothing worked.

The source code explains why. 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',
}),
]
external link github.com/umami-software/umami/blob/master/rollup.tracker.config.js

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 to a new value in the .env file makes the server accept requests at the new path, but the tracker still tells the browser to post to /api/send. The two sides disagree silently.

GET /umami.jsforwardtracker script (with /api/send baked in)POST /api/sendPre-compiled path ignores the runtime configNo handler matches this pathDashboard: zero visitors, no errorsBrowserCaddyUmami

The fix is to build the image from source with the new value baked in at compile time. Umami’s Dockerfile doesn’t expose this variable as a build arg out of the box, but two lines in the builder stage close that gap:

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

ARG declares a build-time variable; ENV makes it visible to Rollup so the value gets compiled into the tracker script. With those two lines in place, building with a custom value is a single flag:

Terminal window
podman build --build-arg COLLECT_API_ENDPOINT=<your-endpoint> -t umami-custom:latest .

The result is the same image as the official one, with one string different in one JavaScript file.

If the host runs Podman rootlessly with a separate service-account user, the image built under your developer user isn’t visible to that service user. Each rootless user has an isolated image store. Transfer is two commands:

Terminal window
podman save localhost/umami-custom:latest -o /tmp/umami-custom.tar
cd /tmp && sudo -u umami podman load -i /tmp/umami-custom.tar

The cd /tmp matters. sudo -u umami inherits the caller’s current working directory by default. If your shell is sitting inside your developer home, the load fails with cannot chdir: Permission denied because the service user can’t read into your home. The error points at chdir, not at sudo or podman, so the cause isn’t obvious the first time it bites.

That covers what a custom Umami build looks like in general. What this site sets <your-endpoint> to, and the matching script path the public proxy rewrites to, isn’t in this post.

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="/umami.js"
data-website-id="..."
data-host-url="/">
</script>

A few things to note.

is:inline tells Astro to leave the script tag exactly as written and not process or bundle it. The tracker is a fire-and-forget third-party script; it doesn’t need Astro’s bundling pipeline.

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

The Astro reference adds a note that reads broader than its actual scope:

Will not be bundled into an external file. This means that attributes like defer which control the loading of an external file will have no effect.

external link docs.astro.build/en/reference/directives-reference

That describes the inline-content case, where there’s no external file to defer. For a script with src, is:inline only tells Astro not to process the tag. The dist output emits it verbatim with defer intact, and the browser applies the attribute normally.

data-host-url="/" overrides the base URL the tracker uses to build its endpoint. Without it, the tracker derives the base from the script’s own location. A script at a subdirectory path (say /path/to/umami.js) would post to /path/to/api/... instead of /api/.... Setting the value to / (or whatever the site root is) keeps it correct.

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 the recipe stays private

The conceptual fix was already named: make the script and the endpoint look like ordinary site assets. This site does that. The specific names, and the reasoning that ties name choice to a site’s content profile, are not in this post.

The cost of running this is real. The upstream image can be updated with podman pull. A custom build means rebuilding from source on every upstream release.

Ad blockers exist because trackers earned the distrust. Most analytics on the public internet really is surveillance: third-party scripts, fingerprinting, data passed downstream. The blockers can’t tell which sites are honest. They block the patterns.

A pattern-matching countermeasure helps honest sites and dishonest ones equally. A public post with a working recipe is the same key handed to a dentist counting appointments and a tracking firm trying to put its pixel back on someone’s screen. The first use is fine. The second is what made the blockers necessary.

So the post stops at the principle.

The full setup, specific names and the reasoning behind how they’re chosen for a given site, stays out of public documentation for the reason above. If that’s relevant to your situation, the About page is where to start.

What about page speed?

A fast site keeps visitors. Slow pages bounce, drop in search ranking, and feel unprofessional. Analytics has to weigh against that cost.

There’s a known fix for heavy analytics scripts: Partytown. It moves the script into a background thread so the main page stays responsive while the tracker does its work. For sites loading something like Google Tag Manager or Facebook Pixel (50 to 200 KB of JavaScript that polls and injects continuously), Partytown is a real speed win.

This setup doesn’t need it.

The Umami tracker is 2.63 KB uncompressed, 1.43 KB after gzip. It loads with defer, fires one POST request when a page is visited, and exits.

Adding Partytown would actually make things slower. Its runtime is around 15 KB gzipped, all of it loaded into the visitor’s browser: a 1.2 KB loader plus a 13.6 KB worker that intercepts requests in the background. None of it runs on the server. Wrapping a 1.43 KB script in 15 KB of overhead is ten times more JavaScript than the script itself.

Gzipped script size: Umami tracker vs Partytown runtime (loader + worker)

The honest answer is the simpler one. A tracker small enough that the page stays fast because nothing is slowing it down.

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 ad-blocker problem has a fix this post points at without paving. Each decision is small.

Together, they decide two things: whether you know what happens on your site, and whether the relationship with your visitors stays yours.

That can inshallah be worth getting right.

FAQ
Do I need the ad-blocker bypass, or can I self-host and call it done?

Self-hosting alone solves the data sovereignty side. The ad-blocker problem is independent: with default endpoints, a known share of visitors still won't show up in your numbers.

If your only goal is to stop handing data to Google, a default Umami install is enough. If you want the visitor count to reflect reality, the proxy-rename approach is what closes that gap.

What does this cost compared to Google Analytics?

Server cost, not subscription. The footprint is a database and a small Node.js app running alongside whatever else you host. There is no per-visit, per-event, or per-feature pricing. The model is "pay for the server you already need."

Is Umami GDPR-compliant out of the box?

Closer than Google Analytics, but not automatic. Umami doesn't use cookies and doesn't share data with third parties, which removes the most common compliance failures.

Some parts still need attention from you: your privacy policy, the country your server runs in, and whether you store IP addresses.

Can I migrate my Google Analytics history?

Generally no. Umami starts collecting from the moment it's installed; GA's historical data stays inside Google's product. Some people export GA reports for archival reference, but the metrics don't line up cleanly enough to merge with Umami going forward.

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