Google Analytics with real consent mode on a self-hosted site
Most of my traffic was bots, one script injected by nginx covers every page, and nothing reaches Google before the click
Google Analytics said my blog had about one visitor. I assumed tracking was broken. It was not: of 265 outside addresses that fetched pages with a browser user agent in 24 hours, only 21 actually rendered a page, and most of those were archive bots and crawlers. About five were people. Analytics was right.
That investigation led to a decision I had avoided for months: adding Google Analytics to my main site, www.shuffle-on.com, which until then promised “no analytics, no trackers”. I did it in late October 2026, with real consent: nothing is sent to Google until a visitor clicks “Allow”. The whole thing is one small script, injected by nginx into every page, including pages generated by other tools. This post explains how, and what I learned about counting visitors on a self-hosted site.
Contents · 11 sections
TL;DR #
- On a small self-hosted site, most traffic is bots. Count visitors who render a page, not requests.
- Analytics tools filter bots and lag by one to two days; check the real-time view before assuming something is broken.
- Basic consent mode means the Google tag does not load at all before consent. That is the honest default for a privacy-minded site.
- Inject the consent script at the web server (nginx
sub_filter), so every HTML page gets it, including generated ones. - Cache assets as immutable and version the file name when the banner changes.
- Send custom events only after consent, and say so in your privacy text.
- If your LAN uses a DNS blocker, test from outside, with real DNS.
How many visitors, really? #
The blog runs WordPress with Google’s Site Kit plugin, which installs the Google tag for Analytics 4. I saw about one visitor and suspected a broken tag.
So I checked from the outside, in a headless browser using public DNS: the tag loaded, the page_view hit was sent and answered with 204, the _ga cookie was set. Microsoft Clarity’s collector answered 204 as well. Tracking worked for internet visitors.
Then I counted from the other side, in the edge access logs. In 24 hours, 265 external addresses sent requests with a browser user agent. Only 21 of them also loaded the theme’s CSS, which a real browser rendering the page always does. Of those 21: 11 were the Internet Archive’s headless browser, 5 were Googlebot, and about 5 were humans.
Analytics filters known bots, my LAN’s visits never reach Google (more on that below), and Analytics 4 reports lag by 24 to 48 hours. One to five human visitors a day was simply the truth. A user-agent string is not a visitor.
A cheap way to count real page views from your own logs is exactly that test: a client that fetched the HTML and a render-only asset such as the main stylesheet within a short window.
Why add analytics at all #
My main site is about running infrastructure yourself. Adding Google’s tracker to it is a contradiction, and I said so when it came up. Self-hosted alternatives such as Umami or Plausible, or a human count from the edge logs, were on the table.
In the end I chose Google Analytics anyway, under strict rules: nothing before consent, every advertising consent type permanently denied, an easy way to change your mind, and a privacy section that says exactly what happens. If you make the same choice, those rules are the minimum.
Consent mode, basic versus advanced #
Google’s consent mode has two flavours:
- Advanced: the tag loads for everyone with consent set to “denied”. Before consent it still sends cookieless pings, which Google uses for modelling.
- Basic: the tag does not load at all until the visitor consents. No request goes to Google before the click.
I chose basic. The script sets the consent defaults to denied first, as Google requires, and only injects the tag after “Allow”:
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("consent", "default", {
ad_storage: "denied", ad_user_data: "denied",
ad_personalization: "denied", analytics_storage: "denied"
});
function loadGA() {
gtag("consent", "update", { analytics_storage: "granted" });
const s = document.createElement("script");
s.async = true;
s.src = "https://www.googletagmanager.com/gtag/js?id=" + GA_ID;
document.head.appendChild(s);
gtag("js", new Date());
gtag("config", GA_ID);
}
const choice = (() => { try { return localStorage.getItem("site-consent"); } catch (e) { return null; } })();
if (choice === "granted") loadGA();
else if (choice !== "denied") showBanner(); // ask once
The choice is stored in localStorage (not a cookie). The privacy page has a “Change my analytics choice” button that reopens the banner. Withdrawing consent deletes the _ga cookies and sets consent back to denied:
function forget() {
document.cookie.split(";").forEach(c => {
const n = c.split("=")[0].trim();
if (n === "_ga" || n.startsWith("_ga_") || n === "_gid") {
["", "; domain=.example.com"].forEach(d =>
document.cookie = n + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/" + d);
}
});
gtag("consent", "update", { analytics_storage: "denied" });
}
The banner has two buttons of equal weight, “No thanks” and “Allow”, and a link to the details. No dark patterns, no pre-ticked boxes.
One script for every page: nginx sub_filter #
The site has hand-written pages and hundreds of pages generated by a dossier pipeline. Editing every template would miss something sooner or later. nginx can insert the script into every HTML response as it is served:
location / {
# every HTML page, hand-written or generated, gets the consent script
sub_filter '</head>' '<script src="/assets/consent-v2.js" defer></script></head>';
sub_filter_once on;
}
Two details matter. sub_filter works on text/html by default, which is what you want. And it cannot rewrite a response that arrives already compressed from an upstream; for static files served by nginx itself, that is not an issue.
Versioned file names #
Static assets on the site are served with a 30-day immutable cache. A visitor’s browser will not even ask for consent-v1.js again for a month. So the banner is never edited in place. A change means publishing a new file and updating the sub_filter line. That is why the script is called consent-v2.js: version 2 replaced version 1 on the same day.
Key events, after consent only #
Version 2 added three custom events that tell me whether the site does its job: a click on a contact link, a click on a donation link, and a click on the published book. Each carries the link and the page, and each is sent only if consent was granted:
document.addEventListener("click", e => {
const a = e.target.closest("a[href]");
if (!a || getChoice() !== "granted") return;
if (/linkedin\.com|^mailto:/.test(a.href)) gtag("event", "contact_click", { link_url: a.href, page_path: location.pathname });
}, true);
They were verified end to end in a headless browser with real DNS: page_view, two donate_click and one contact_click reached Google’s collector. The privacy text mentions link-click measurement explicitly. In the Analytics admin, those events still have to be marked as key events by hand.
A small extra: the visible privacy text gives only a shortened measurement ID, with the full one in the script. Keyword scrapers that harvest IDs from prose then have nothing to match.
Testing when your LAN blocks Google #
My LAN’s DNS goes through Pi-hole, whose blocklist answers 0.0.0.0 for the Google tag and collection domains. From inside the house, the tag never loads, which is good for privacy and useless for testing. My own visits are never counted either.
The reliable test was a headless browser in a container with host networking and explicit host entries for the Google domains, waiting about 12 seconds for the tag to fire. A headless browser on the host, even with resolver overrides, did not show the tag load. I will write about the Pi-hole side of this separately.
Checklist #
- Count visitors who render a page, not addresses that send requests.
- Check real-time reports before declaring tracking broken; daily reports lag.
- Use basic consent mode with all consent types denied by default.
- Offer equal “Allow” and “No” buttons and a way to change the choice later.
- Delete analytics cookies when consent is withdrawn.
- Inject the script centrally at the web server.
- Version immutable asset names instead of editing them.
- Send custom events only after consent, and disclose them.
- Test from outside your DNS filter.
Frequently asked questions #
What is the difference between basic and advanced consent mode? #
In basic mode the Google tag is not loaded until the user consents, so nothing is sent before. In advanced mode the tag loads with consent denied and sends cookieless pings, which Google uses to model conversions.
Why does Google Analytics show almost no visitors on my self-hosted site? #
Usually because there are few human visitors. Most requests come from bots, which Analytics filters. Reports also lag by a day or two, and visits from networks that block Google’s domains never arrive.
How do I add a script to every page served by nginx? #
Use sub_filter to replace </head> with your script tag plus </head>, with sub_filter_once on. It applies to HTML responses by default and needs uncompressed upstream content.
Do I need consent before loading Google Analytics in the EU? #
Analytics cookies generally require prior consent under the ePrivacy rules and GDPR. Basic consent mode, with nothing loaded before the click, is the conservative way to meet that.
The lesson #
The best thing analytics taught me came before I installed it: my site’s traffic was mostly machines, and “one visitor” was an accurate count, not a bug. If you add a tracker anyway, make consent mean something: nothing before the click, an easy way back, cookies removed on the way out, and every page covered without relying on anyone remembering a template.