Self-hosting Analytics Behind Cloudflare Access

Umami Cloud’s free tier allows three websites and 100k events a month. I have more sites than that and nowhere near that much traffic, so the website count is what ran out.

The box I run everything else on is a Hetzner CAX, 4 vCPU and 8GB, already carrying about 24 containers behind Dokploy. Umami adds two containers and roughly 256MB. The migration removed the website cap at no additional cost, on hardware already running.

Here we will explore the three things that did not work on the first attempt:

  • The tracker path cannot be renamed at runtime, whatever the environment variable suggests
  • Mirroring the tracker first-party changes where events get posted
  • Cloudflare Access has two setup steps that are invisible until you hit them

TRACKER_SCRIPT_NAME does nothing on the prebuilt image

Umami serves its tracker at /script.js, which is the exact path adblock lists match on. Umami documents TRACKER_SCRIPT_NAME as the way to change it, so I set it and redeployed.

It had no effect. /u.js and /umami.js both returned 404, /script.js still returned 200. The variable is consumed by a Next.js rewrite that is evaluated at build time, so setting it at runtime on docker.umami.is/umami-software/umami:postgresql-latest is a no-op. To use it you would have to build the image yourself.

The alternative is to stop asking the analytics server to hide the path and have each site serve its own copy. A build step downloads the tracker into the site’s public/:

// scripts/mirror-umami.mjs
const SRC = 'https://analytics.example.com/script.js';
const OUT = resolve(process.cwd(), 'public/u.js');

const res = await fetch(SRC);
if (!res.ok) {
  console.error(`mirror-umami: ${res.status} ${res.statusText}`);
  process.exit(1);
}
writeFileSync(OUT, await res.text(), 'utf-8');

The browser then loads /u.js from the site’s own origin and the blocklists miss it.

Mirroring moves the collect endpoint too

The mirrored copy did not record anything at first. The Umami tracker contains no hardcoded host. It reads document.currentScript.src and derives its collect endpoint from wherever it was served, so a copy served from the site’s own domain posts events to that domain’s /api/send, which does not exist.

data-host-url is what puts it back:

<script is:inline defer src="/u.js"
  data-host-url="https://analytics.example.com"
  data-website-id="WEBSITE_ID"></script>

Loading directly from the analytics origin needs no data-host-url, because served and collected are the same host. Mirroring requires it. Some of my sites mirror and some load direct, and the attribute is the only difference between them.

Cloudflare Access needs an org and an identity provider

The dashboard now sits on a public domain with a login form on it. Cloudflare Access is free for up to 50 users, so I put it in front.

Two steps are not obvious from the API docs. First, the account had never had Access enabled, and every account-scoped call returned access.api.error.not_enabled telling me to click a button in the dashboard. Creating the Zero Trust organisation is what “enabling” means, and it can be done over the API:

curl -X POST -H "Authorization: Bearer $TOKEN" \
  --data '{"name":"example","auth_domain":"example.cloudflareaccess.com"}' \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/access/organizations"

Second, one-time PIN is not on by default. I created the applications, loaded the domain, and got a Cloudflare Access page reading “There are no login methods available for this account.” The onetimepin identity provider has to be created explicitly, and once it exists the email PIN flow works with no external IdP.

Three applications cover the domain, and the order they are created in matters:

PathDecision
/api/sendbypass, everyone
/script.jsbypass, everyone
/ catch-allallow, my email

Event ingestion and the tracker file have to stay open to the internet, otherwise every visitor to every site gets bounced to a login page instead of being counted. Create the catch-all first and tracking stops on every site until the bypasses land.

One more condition: Access only enforces on a proxied record. The A record for analytics had been left grey-clouded during setup so Let’s Encrypt could complete HTTP-01. If it ever goes back to grey for a cert renewal, the dashboard is open again with no visible sign that anything changed.

Verifying is two curls:

curl -o /dev/null -w '%{http_code}\n' https://analytics.example.com/script.js   # 200
curl -o /dev/null -w '%{http_code}\n' https://analytics.example.com/            # 302

The failure mode of running it yourself

On the day I finished the migration, the box filled its disk. /dev/sda1 hit 100% of 38G, mostly Dokploy build cache, and Postgres could no longer write. Every database-backed route on Dokploy returned 500 and API key validation returned 401, while /api/health kept returning 200 because it touches nothing. That combination reads like a broken application rather than a full disk, and I spent longer than I should have looking at the wrong layer.

docker builder prune -af recovered 7.9GB and Dokploy restarted itself within a minute. The build cache had regrown to 3.9GB by the time I checked again, so it is now on a daily cron:

#!/bin/sh
docker builder prune -af --filter until=48h >/dev/null 2>&1
docker image prune -af --filter until=168h >/dev/null 2>&1
journalctl --vacuum-size=200M >/dev/null 2>&1

What this does not replace

Umami counts pages. It does not do error tracking, and its funnel support is thin.

Two of the sites are products rather than content sites, and for those I still expect to want error tracking and product analytics that Umami cannot provide. PostHog’s free tier covers 1M events a month, which would cover a signup funnel. Sentry is the same argument for exceptions.

One of them is the exception to all of it. It is a supervision product for counsellors, its forms carry clinical content, and its users are in the UK, so GDPR special-category data is in scope. Session replay records form fields, which rules out the feature and the tools that lead with it. That domain stays on page-level Umami with no custom events carrying user input, and the reasoning is written into a comment in its build config so I do not undo it later.