Self-Hosting Umami Analytics: Adding Privacy-First Tracking to Every Property, Including phpBB
We self-hosted Umami on Hetzner + Coolify and got it running across all our properties, including a phpBB forum where the normal script tag silently does nothing.

We wanted analytics on our properties without handing data to Google. No cookies, no consent banners, no third-party scripts on our visitors' machines. Umami was the obvious choice. Self-hosting it turned out to be straightforward. Getting it to work on a phpBB forum was not.
Here's exactly what we did, including the parts that bit us.
The setup: Hetzner + Coolify + Cloudflare Tunnel
The Umami instance runs on a Hetzner Cloud VPS we already had running Coolify. Coolify is a self-hosted PaaS built on Docker. You deploy services through a dashboard and it handles containers, reverse proxying via Traefik, and SSL certs automatically.
The server is locked down: ports 80 and 443 are closed at both the Hetzner firewall and UFW level. All web traffic flows through a Cloudflare Tunnel, an outbound-only connection from the server to Cloudflare's edge. The server IP can't be targeted directly.
Deploying Umami on Coolify takes a few minutes: add the service, set the environment variables, configure the domain. Done.
One gotcha if you're running Coolify behind a Cloudflare Tunnel: the Coolify App URL in Settings must be http://, not https://. Cloudflare handles HTTPS externally. If you set https://, Traefik adds its own HTTP->HTTPS redirect, which loops infinitely when combined with the tunnel. Cloudflare SSL mode should be Full (not Flexible, not Full Strict) for the same reason.
Protecting the dashboard without breaking tracking
We put the Umami dashboard behind Cloudflare Access, so you have to authenticate to reach it. Fine for us. Problem for the tracker.
Umami's /script.js and /api/send paths need to be reachable by any browser without authentication. If they're behind Access, visitors get redirected to a login page instead of a JS file. The symptom is a MIME type error in the console: text/html served where application/javascript was expected.
The fix: a separate Cloudflare Access application scoped to just those two paths, with a Bypass / Everyone policy. Dashboard stays locked; tracking endpoints are public.
One thing worth noting: always test analytics in a private/incognito window. If you're logged into Cloudflare Access, your session cookie lets you through everything, so you won't reproduce the MIME error even if regular visitors are hitting it.
We also set ALLOWED_DOMAINS in Coolify's environment variables to our list of domains. This adds a layer of friction against event spoofing, though it's worth noting that Umami checks the hostname field in the POST body, which is client-controlled. It's not a hard guarantee. The website ID is visible in page source. That's normal for any client-side analytics tool; it's not a secret worth hiding.
Adding Umami to standard websites
For a standard web property, it's a single script tag:
<script
src="https://your-umami-instance.com/script.js"
data-website-id="your-website-id"
defer
></script>In our Next.js site, this goes in the root layout.tsx using Next.js's <Script> component with strategy="afterInteractive". That's the whole integration.
Umami tracks page views, browsers, devices, OS, and referrers without setting any cookies. No GDPR consent banner required on any of the sites using it.
The phpBB problem
The Noxbourne community forum (community.noxbourne.com) runs phpBB with a custom theme that has a "Custom JavaScript" field in the ACP under theme settings. Whatever you put there gets injected on every page. No template editing, no SSH required.
The obvious approach: dynamically load Umami's script tag.
var s = document.createElement('script');
s.src = 'https://your-umami-instance.com/script.js';
s.setAttribute('data-website-id', 'your-website-id');
document.head.appendChild(s);This does nothing. The script loads, but Umami's tracker never initialises. No errors, no warnings, just silence.
The reason: Umami's tracker checks document.currentScript on startup to read its data-* configuration attributes. When a script loads dynamically via createElement + appendChild, currentScript is always null. The tracker finds no configuration, assumes something is wrong, and exits immediately. No fallback, no error thrown. It just stops.
The tracker is built to be loaded via a static <script> tag in the HTML source. phpBB's custom JS field injects code at runtime, not via a static tag, so currentScript never has a value.
The fix: skip the tracker and call the API directly
Instead of loading the script, we POST directly to Umami's /api/send endpoint. The tracker is just a thin wrapper around this endpoint anyway.
var payload = {
website: 'your-website-id',
url: window.location.pathname + window.location.search,
referrer: document.referrer,
title: document.title,
hostname: window.location.hostname,
language: navigator.language,
screen: screen.width + 'x' + screen.height
};
fetch('https://your-umami-instance.com/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'event', payload: payload })
});This works. Page views start appearing in the dashboard immediately.
The query param problem
phpBB uses query parameters for everything. The forum index is /index.php. A board is /viewforum.php?f=2. A topic is /viewtopic.php?t=5. The URL path for every topic on the entire forum is the same: /viewtopic.php.
Here's the issue: Umami stores URLs across two separate database columns: url_path and url_query. But the entire dashboard UI, every report, every table, every chart, displays only url_path. The query string is stored but never surfaced anywhere.
In the Pages report, every single topic shows up as /viewtopic.php. One line, combined visit count for all topics. There's no way to see which topics people are actually reading.
The workaround: send a custom event alongside each page view
Umami's Events tab works differently. When you send an event with a name field, that name shows up verbatim in the Events report. So we send a second request alongside the page view, using the full URL (path + query string) as the event name.
var payload = {
website: 'your-website-id',
url: window.location.pathname + window.location.search,
referrer: document.referrer,
title: document.title,
hostname: window.location.hostname,
language: navigator.language,
screen: screen.width + 'x' + screen.height
};
// Page view - populates Pages, Browsers, OS, Referrers, Devices
fetch('https://your-umami-instance.com/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'event', payload: payload })
});
// Custom event - full path + query string in the Events tab
fetch('https://your-umami-instance.com/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'event',
payload: Object.assign({}, payload, {
name: window.location.pathname + window.location.search
})
})
});Two requests per page load. The first populates all the standard Umami reports. The second gives per-topic and per-forum granularity in the Events tab: /viewtopic.php?t=5, /viewforum.php?f=2, etc.
It's a workaround. The real fix is for Umami to display the full URL in its Pages report. This is a known limitation, and when it's addressed, the second fetch can go away and be replaced with a static script tag. For now, this is what works.
Where we landed
Umami is running across all our properties. bytepounce.com uses the standard script tag. community.noxbourne.com uses the dual-fetch approach above, dropped into the Zeina theme's Custom JavaScript field. All data stays on our server, no cookies anywhere, no consent banner on any of the sites.
The dashboard is simple and fast. Traffic by page, country, device, referrer. The forum Events tab gives enough per-topic detail to see what people are actually reading. It does what analytics should do: tells you what's happening without tracking people.
If you're running something similar and hit the dynamic script issue, the MIME type error behind Cloudflare Access, or the query param problem with phpBB, hopefully this saves you some time. The fixes aren't complicated once you know what's happening. Getting there is the frustrating part.
If you're setting up self-hosted analytics or dealing with infrastructure like this and want a hand, get in touch. It's the kind of work we do.
