Self-hosting Umami with Cloudflare, Fly, and Supabase
This is a guide for running your own privacy-friendly web analytics: cookieless, first-party so ad blockers do not eat it, and cheap to run. It works for static sites and dynamic ones alike. I’ve implemented it across my own sites ( hjerpbakk.com, a Jekyll blog on GitHub Pages and a handful of SvelteKit sites on Cloudflare Pages), and I use those as worked examples throughout.
What you want from analytics
A reasonable set of goals:
- Privacy-friendly. Cookieless, no personal data, so no consent banner under EU and UK rules.
- Cheap. A few dollars a month, not a subscription that scales with traffic.
- Lightweight. A tracking script that does not slow the pages down.
- Multiple users. You and anyone else who should see the numbers.
- The data stays yours. Hosted by you, not rented from a third party.
If you run more than a few sites, the cheap analytics cloud plans that cap you at three sites push you towards self-hosting anyway, and once you self-host, owning the data comes for free.
Why Umami
I’ve used Plausible before, but stopped for 2 reasons:
- It became “expensive” while running many websites
- I did not use the analytics to drive changes to my sites
Revisited this now since i plan on actually improving(!!!) some of my sites and want to be datadriven when doing so. Also, I’m still genuinely curious about which card games are the most popular in the nordic countries.
I chose Umami since it’s open source, cookieless, and small. The tracking script is about a kilobyte, and the dashboard answers the everyday questions: which pages, how many visitors, where they came from. It has real user accounts, so other people can get a login without any workaround too.
The ad-blocker problem
Most analytics scripts load from a third-party host on a predictable path. Block lists like EasyPrivacy know those hosts and paths, so a share of visitors never get counted. The fix is to serve the tracking first-party: from your own domain, on a path you choose, so the browser only ever talks to your site. Cloudflare is good at this, which is why it earns a place in the stack even though it does not host the app.
Why not run all of it on Cloudflare
It is tempting to keep everything on one platform. But since Umami runs on PostgreSQL or MySQL only, there is no official SQLite or Cloudflare D1 support.
The database has to live somewhere that speaks Postgres, and since I use Supabase already, that was the natural choice for me. Other serverless Postgres like Neon would work just as well i think, but examples below assume Supabase.
Cloudflare’s compute does not fit the app either. Workers are V8 isolates, not a Node server, so Umami does not run there unless you fork it and patch its database layer, which then breaks on every Umami update. Cloudflare Containers can run the official image, but they need the paid Workers plan and are built to sleep when idle, so the first pageview after a quiet spell can be lost. For one small always-on box, a tiny Fly.io machine at a few dollars a month is the better fit.
So Cloudflare does the job it is best at (the first-party edge), and the app runs where it is happy (a normal container on Fly).
The architecture
Three pieces, each with one job:
- Supabase holds the Postgres database.
- Fly.io runs the official Umami container, connected to Supabase. It serves the dashboard, the tracking script, and the ingest endpoint.
- A Cloudflare Worker on your domain proxies the script and the ingest under a neutral path (this guide uses
/np/), so tracking is first-party and the well-known Umami paths are hidden.
The flow when someone loads a page:
Visitor loads a page on yourdomain.com
│ <script src="https://yourdomain.com/np/x.js" data-website-id=...>
▼
Cloudflare Worker (route: yourdomain.com/np/*)
│ /np/x.js → Umami /script.js (cached at the edge)
│ /np/api/send → Umami /api/send
▼
Umami on Fly.io ──writes──▶ Supabase (Postgres)
Everything on the site except the /np/ path is untouched. The browser sees a first-party script on your own domain, which is what the block lists do not flag.
A note on Subresource Integrity: do not put an integrity hash on this script tag. The script is first-party, not a third-party CDN file, and its contents change whenever you upgrade Umami, so a pinned hash would break the tracker on every release. SRI is the right tool for third-party CDN scripts, not for a self-hosted one.
Setting it up
The order that works:
- In your Postgres host, copy the connection string. With Supabase, use the Session pooler string (port 5432) which supports the migrations Umami runs on startup.
- Deploy the official Umami image (
ghcr.io/umami-software/umami:postgresql-latest) to Fly with two secrets: the database connection string and a randomAPP_SECRET. We want one small machine that is always on so ingest never cold-starts. Umami creates its own tables on first boot. - Log in at the Fly URL, change the default password, and optionally add a more users for anyone else who needs access.
- Register your site in Umami to get its
website-id. - Deploy a small Cloudflare Worker (see the code below) that maps
/np/x.jsto the origin/script.jsand forwards/np/api/sendto the origin/api/send, passing the visitor’s IP and user agent through. Add a route foryourdomain.com/np/*. - Add the one-line script tag to your site’s HTML head, then deploy the site.
The Worker is the only custom code, and it is short: it rewrites the path, copies a few headers, sets X-Forwarded-For from CF-Connecting-IP so Umami gets the real visitor, and caches the script at the edge.
Here is the complete Worker. Set ORIGIN to your Fly URL, then deploy it with a route for yourdomain.com/np/*:
export interface Env {
ORIGIN: string;
}
const PREFIX = "/np";
// Map an incoming /np/* path to the Umami origin path.
// Returns null for anything outside the /np prefix.
export function mapPath(pathname: string): string | null {
if (pathname !== PREFIX && !pathname.startsWith(PREFIX + "/")) return null;
const rest = pathname.slice(PREFIX.length); // "", "/x.js", "/api/send"
if (rest === "/x.js") return "/script.js";
return rest === "" ? "/" : rest;
}
// Copy only the headers Umami needs, and pass the real visitor IP through.
function forwardHeaders(request: Request): Headers {
const out = new Headers();
for (const name of ["user-agent", "accept-language", "content-type"]) {
const value = request.headers.get(name);
if (value) out.set(name, value);
}
const ip = request.headers.get("cf-connecting-ip");
if (ip) {
out.set("x-forwarded-for", ip);
out.set("x-real-ip", ip);
}
return out;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const originPath = mapPath(url.pathname);
if (originPath === null) return new Response("Not found", { status: 404 });
const origin = env.ORIGIN.replace(/\/+$/, "");
const originUrl = origin + originPath + url.search;
const isScript = request.method === "GET" && originPath === "/script.js";
const init: RequestInit = {
method: request.method,
headers: forwardHeaders(request),
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = await request.arrayBuffer();
}
if (isScript) {
init.cf = { cacheTtl: 3600, cacheEverything: true };
}
const resp = await fetch(originUrl, init);
const out = new Response(resp.body, resp);
if (isScript) out.headers.set("cache-control", "public, max-age=3600");
return out;
},
};
A few things worth knowing
A handful of details that cost me time when I first set this up:
- Fly wants a payment method on file before the first deploy, even inside the free allowance. Add a card first, or the deploy stops partway with a billing error.
- Fly defaults to two machines for high availability. For a single analytics box that is double the cost with no real gain, so scale it down to one.
- The Supabase session pooler string worked without extra flags for me. If a deploy fails with an SSL error reaching the database, append
?sslmode=requireto the connection string and redeploy. - Umami filters bots at the ingest endpoint, and the way it does so is easy to misread. A request that does not look like a real browser gets a
200response with the body{"beep":"boop"}, and the hit is quietly dropped. It is a honeypot, so a bot cannot tell it was filtered. Acurltest with a made-up user agent, or a headless browser that reportsHeadlessChrome, both get this treatment, which makes a synthetic check look broken while real visitors are recorded fine. Test with a real browser, or send a browser-like user agent. - If the tracking script and ingest are first-party but a visit still does not show up, check the site’s own caching and try a hard refresh. A stale cached page can serve the old HTML without the snippet.
Adding it to your sites
The snippet is the same everywhere. Point it at your own domain and include data-domains so a local dev build never sends data:
<script defer
src="https://yourdomain.com/np/x.js"
data-website-id="YOUR_WEBSITE_ID"
data-host-url="https://yourdomain.com/np"
data-domains="yourdomain.com"></script>
Where the snippet goes depends on the stack. Two common cases:
- A Jekyll site on GitHub Pages, fronted by Cloudflare. Put the snippet in the layout’s
<head>(for example_layouts/default.html). The Worker route onyourdomain.com/np/*intercepts those requests before they reach GitHub Pages. My own blog is set up this way. - A SvelteKit site on Cloudflare Pages. Put the snippet in the
<head>ofsrc/app.html. A useful detail: a Worker route takes precedence over a Cloudflare Pages custom domain on the same hostname, so the same Worker still serves/np/here without any per-repo proxy code. My card-game sites run this way.
Because a Worker route only attaches to zones in the Worker’s own Cloudflare account, a site hosted on a different account needs its own proxy. The tidy way is a Cloudflare Pages Function (functions/np/[[path]].ts) with the same logic, which ships with that site and does not depend on your Worker. I have one site on a teammate’s account that uses exactly this.
Keeping it current
Self-hosting means you own the upkeep, but it’s not too much work:
- Pin a specific Umami version in the Fly config rather than tracking
latest, so a deploy is predictable. - Before upgrading, take a database backup with
pg_dumpor don’t, if old events are not useful to you. Umami runs migrations on boot, and not every migration is reversible, so the backup can be a safety net. - To upgrade, bump the version, deploy, and check the dashboard still loads and records a visit. If something is wrong, redeploy the previous version and, if needed, restore the backup.
- Watch the Umami releases on GitHub for new features.
- The Cloudflare Worker rarely changes. Now and then refresh its compatibility date and the
wranglerversion, then redeploy. - Keep an eye on your Postgres free limit. If the data grows, Umami’s retention settings can prune old events.