Chris Lorenzo

homelab and infrastructure notes · stayz3ro

entry-52

Umami behind a Cloudflare-only firewall

I deployed self-hosted analytics with a public tracker and a private dashboard. The work was in a DNS record that disagreed with the firewall, an Astro script tag that lost its configuration, and an old analytics beacon filling the CSP report.

revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 6 min read

I deployed Umami on the edge VPS for the blog and portfolio. I wanted visitors to reach the tracker while keeping the dashboard on the tailnet. Caddy already handled the public services on that host, and Uptime Kuma already had a private admin page through Tailscale Serve. The deployment fit that existing arrangement.

The containers were only part of the work. My DNS instructions disagreed with the firewall I had installed earlier in the month. Then the blog needed a second change because Astro transformed the tracker tag. Once tracking worked, the browser’s content security policy reports led me to an analytics beacon I no longer needed.

The stack is Umami 3.4.0 with PostgreSQL 16, both pinned by image digest. PostgreSQL has no published port. Umami’s dashboard is published on 127.0.0.1:3000, with Tailscale Serve providing HTTPS access on port 8444. Kuma keeps its existing Serve route on 8443. That gives me separate admin addresses without adding another public dashboard.

The public hostname, a.chrisalorenzo.com, exposes two paths through Caddy: /script.js for the tracker and /api/send for events. The root, login page and management API paths return 404. I created separate Umami websites for the blog and portfolio, so each site’s script tag identifies the right destination for its pageviews.

My original DNS instruction was to create a DNS-only record. That was copied from an older arrangement for the status page, and it was already wrong by the time I used it. On October 5, I had moved the status and ntfy records behind Cloudflare and restricted access to the public web ports to Cloudflare’s address ranges.

That earlier firewall change had its own lesson. Replacing broad UFW rules did not protect Docker’s published Caddy ports. The traffic went through Docker’s forwarding path, where the effective rules belonged in DOCKER-USER. I added the Cloudflare allowlist there and persisted it. The external check was straightforward: a request forced directly at the origin timed out, while a request through the normal domain worked.

The Umami plan had kept the old DNS choice without accounting for that new restriction. With the record set to DNS only, the certificate challenge went toward an origin that would not accept it. Let’s Encrypt timed out. Visitors using the same direct route would have hit the same firewall.

I changed the record to proxied in Cloudflare. The certificate issued on the next attempt. I did not need to open the origin to arbitrary clients to get the new service working. I needed the DNS record to use the route the firewall already allowed.

That also made the client address setting part of the deployment. I added CLIENT_IP_HEADER: CF-Connecting-IP to the Umami Compose configuration so it would use the visitor address supplied by Cloudflare instead of Cloudflare’s address. The proxy setting, firewall policy and application header setting now describe the same request path. I should have reviewed those three together before trying to issue a certificate.

After the server was working, I added the tracker to the two sites. The blog went first. Its tag lives in an Astro layout, and the first version did not behave like the plain HTML script tag I thought I had added. Astro bundled it into a module, dropped the data-* attributes and left document.currentScript null. Those attributes carried the website ID and tracking options. The tracker silently did nothing.

The fix was is:inline. My blog tag now has this shape, with the actual website ID filled in for the blog:

<script is:inline defer src="https://a.chrisalorenzo.com/script.js"
        data-website-id="BLOG_WEBSITE_ID"
        data-domains="blog.chrisalorenzo.com"
        data-do-not-track="true"></script>

The src still points to the external tracker. Here, is:inline keeps Astro from processing the tag into the module form that broke this integration. That correction needed a second blog change after the first tracker deployment. The portfolio’s tag went into its HTML entry point.

I checked both sites in a private browser window. The useful evidence was the whole sequence: /script.js returned 200, /api/send returned 200, and the visitor appeared in the matching website’s Realtime view. A successful script download by itself would not have established that the tracker could send an event or that the event reached the right website.

I also wanted my normal visits excluded. Before the site deployment, I set the umami.disabled localStorage flag on both content origins in my regular browser profile. The flag belongs on the blog and portfolio, separately, rather than on the tracker hostname or admin page. With that flag set, the normal profile suppressed the send request. The private window provided the control visit, with Do Not Track off, no tracking blocker and no exclusion flag. I had a positive check and an exclusion check instead of trying to infer both from an empty chart.

The remaining browser noise came from the content security policy. I added the Umami origin to script-src and connect-src on both sites and kept the headers report-only. I wanted a week of observation before enforcing them. A working tracker was one check; understanding what else the page tried to load was another.

Every report-only violation I observed came from Cloudflare Web Analytics’ injected beacon. Pi-hole was blocking it as well. I had installed my own analytics while leaving the previous analytics integration enabled in both Cloudflare Pages projects.

I disabled Cloudflare Web Analytics on both projects. That removed the source of those reports without adding its beacon to the policy. I could then observe the policy around the resources I intended to keep. This was still the start of the observation period. The headers remain report-only, and changing them to enforced waits for a clean week and a separate change.

I added operational checks alongside the tracker. Kuma monitors /script.js, since the public root deliberately returns 404, and the monitor has a certificate expiry warning. The database backup timer runs at 03:30 UTC. Its first custom-format dump was verified; the backup procedure checks the dump with pg_restore --list and writes a checksum sidecar.

There is still unfinished work behind those backups. I chose twelve months of pageview retention, but the pruning is not implemented yet. The current local dump window is about fourteen days. Expiring dump files does not delete old rows from the running database. Offsite coverage, a restore drill and retention rules for backups containing old pageviews remain open. I have evidence for the first local dump, not a completed recovery story.

Next time I add a service to this host, I will start with the complete request path: DNS proxy status, the firewall that actually sees the traffic, Caddy’s exposed paths and the application’s client address header. I will also inspect the delivered script tag and the browser’s send request before treating a frontend integration as finished. In this deployment, the stale DNS precedent and the transformed tag each survived a reasonable-looking configuration review.

I would check the hosting platform’s injected scripts earlier too. The Cloudflare beacon was outside the tracker change I was concentrating on, but it was still part of the page the browser received. Disabling it made the CSP observation useful. The tracker is working on both sites now; retention, recovery evidence and the enforcement change are the next pieces I still need to finish.

Related posts: Moving the blog and status page to a new domain, One firewall bug, two hosts, and a weekly report, Self-hosted alerting with Uptime Kuma and ntfy, stayz3ro.dev on Cloudflare: one zone, a static blog and a VPS.