Build Self-Hosted Web Analytics on Cloudflare Workers + D1 — No Server, Setup to Dashboard
TL;DR: The visitor analytics on this site aren't GA4 or GoatCounter — they're self-hosted, on my own domain, with zero backend servers. Just logic running on top of Cloudflare Workers + D1. This post is the whole thing, end to end — from the D1/wrangler setup, to respond-first, log-later (ctx.waitUntil), fail-open, cookieless visitor counting, bot filtering, and a dashboard nobody else can open — all with the actual code.
Why build it yourself
Client-side analytics (GA4, GoatCounter, and friends) run JavaScript in the browser, so they quietly lose a big chunk of traffic to ad blockers, privacy browsers, and bots. You need to measure on the server to be accurate — but spinning up an EC2 box just to count page views is the tail wagging the dog. Cloudflare Workers fill exactly that gap: intercept the request at the edge, before it reaches your origin, log it to D1, and you have server-side analytics with no backend server.
The core sequence — respond first, log later
The whole thing hinges on one idea: hand the visitor their response first, and record the hit afterward, asynchronously. That's why adding analytics doesn't cost the visitor a single millisecond.
In code it's just this:
const response = await fetch(request); // proxy origin as-is
// ...decide if it's a human page view...
ctx.waitUntil(logHit(request, env, url, ua, bot, botName)); // record after responding
return response; // the visitor already has their page
ctx.waitUntil means "the response is sent, but keep the Worker alive until this background task finishes." So the D1 INSERT never blocks the visitor's response.
First, the skeleton — create D1 and deploy the Worker
Before the logic, you need the plumbing. When I first looked at this I had no idea where to start — "Worker? D1? which one first?" — but once you have the order, it's not much. Here's exactly the path I took.
You'll need — a Cloudflare account (free) · a domain on Cloudflare (nameservers pointed at Cloudflare so the proxy is on — required to attach a Worker to a route) · wrangler (Cloudflare's CLI).
npm install -D wrangler # install in the project
npx wrangler login # authenticate in the browser
1) Create D1
D1 is Cloudflare's serverless SQLite — SQL tables without running a server.
npx wrangler d1 create my_analytics
It prints a database_id. Copy it — you'll paste it into wrangler.toml next.
2) wrangler.toml — where the Worker attaches
In the project root. This is the actual file I use (swap the values):
name = "my-analytics"
main = "src/worker.js"
compatibility_date = "2024-11-01"
workers_dev = false
[[routes]]
pattern = "example.com/*" # every request on this route hits the Worker first
zone_name = "example.com"
[triggers]
crons = ["0 19 * * *"] # daily rollup (see ④)
[[d1_databases]]
binding = "DB" # accessed as env.DB in code
database_name = "my_analytics"
database_id = "xxxxxxxx-...." # from step 1
[[routes]] is the key to "server-side interception" — every request to example.com/* runs through this Worker first. Narrow it with /api/* if you want.
3) Secrets — never put them in code
Tokens and salts do not go in your code or wrangler.toml. Register them separately and they're only readable at runtime via env.XXX (and never committed to git).
npx wrangler secret put STATS_TOKEN # dashboard read token (long, random)
npx wrangler secret put HASH_SALT # visitor-hash salt (long, random)
4) Apply the schema + deploy
Put your CREATE TABLE in schema.sql, run it against D1, then deploy.
npx wrangler d1 execute my_analytics --remote --file=schema.sql # tables on the remote D1
npx wrangler deploy # go live
--remote targets the actual deployed D1. Forget it and only your local copy changes, so you'll hit "no such table" in production. After deploying, check three things — ① the site still returns 200 (fail-open), ② /_stats/query without a token returns 401, and ③ rows start landing in D1 after a few page views.
Three landmines I stepped on — ① forgetting --remote and wondering "why is there no data?" (local only). ② the build cache shipping stale code → rm -rf .wrangler node_modules/.cache and redeploy. ③ a route that's too broad adding overhead → let assets pass straight through and only log human HTML views.
That's the skeleton: one D1, one route, two secrets, one deploy. It ate half a day the first time; the second time it was ten minutes. Now for the part that actually matters — what logic you put on top.
① Observability must never sit above the service (fail-open)
A Worker is code that sits in front of every request. If a bug in the logging path throws, worst case your whole site starts returning 500s. Taking the site down to collect stats is insane, so the very first line is a safety net.
async fetch(request, env, ctx) {
ctx.passThroughOnException(); // on any exception, serve the origin response as-is
...
}
passThroughOnException() means whatever blows up, just pass the original response through. Even if logging fails entirely, the visitor gets a perfectly good page and you only lose that one hit's stat. Observability is optional; the service must stay up — that's the fail-open principle.
② Count visitors without storing any personal data
Server-side, you get the IP and UA in hand — but storing them raw means accumulating personal data. So I count visitors without cookies, and without storing the raw IP: hash the IP + UA together with a per-day salt and keep only the hash.
const salt = (env.HASH_SALT || 'salt') + '|' + day; // mix the "day" into the salt
const vh = await visitorHash(ip, ua, salt); // keep only the first 8 bytes of SHA-256
Mixing the day into the salt is the trick. The same person's hash changes completely once the date flips, so you can distinguish visitors within a day but can't link yesterday to today. The raw IP is never stored anywhere (same lineage as privacy tools like Plausible). The tradeoff is you can't count "pure uniques across several days" — I define a visitor as "the sum of daily uniques" and accept that.
③ "The US beats Korea" turned out to be bots
A few days after launch the data looked wrong. A Korean-audience site showing US 41% > Korea 31%? Datacenter bots were being counted as humans. Modern crawlers spoof a browser UA, so UA alone won't catch them. I filtered them with request.cf.asOrganization (the network the connection came from).
// UA says browser, but the connection is from a datacenter (AWS/GCP/Azure...) → spoofed bot
const org = cf?.asOrganization || '';
if (org && !HUMAN_ASN_RE.test(org) && DC_RE.test(org)) return 'datacenter: ' + org;
I tag each hit as human or bot and filter with a toggle in the dashboard. (I also whitelist real consumer ISPs so genuine users on those networks aren't mistaken for bots.)
④ A cron was enough for rollups
The raw hits table keeps growing, so once a day a rollup aggregates it into a per-day, per-path table and prunes old raw rows. A Cloudflare cron trigger handles it.
[triggers]
crons = ["0 19 * * *"] # daily at 19:00 UTC
Cloudflare has Workflows (durable, multi-step, retryable execution) too, but I didn't use it. A rollup gets heavy because the data grows and there's more to scan — wrapping it in Workflows doesn't shrink that. At my scale (hundreds to a few thousand views a day), a cron + one SQL statement is enough and simpler.
⑤ The dashboard is not for everyone — access control
Collecting the data is one thing; nobody but the operator should see it. Visit data is privacy-sensitive, so I locked it three ways.
(1) The query API returns 401 without a secret token. Every aggregation endpoint the dashboard calls (/_stats/query, etc.) has to pass a token check before it returns anything.
const token = url.searchParams.get('token') || request.headers.get('x-stats-token') || '';
if (!env.STATS_TOKEN || token !== env.STATS_TOKEN) return json({ error: 'unauthorized' }, 401);
The token comes in as a query param (?token=…) or the x-stats-token header, and only passes if it matches the STATS_TOKEN Worker secret. The token value never appears in code or in this post (leak it and it's wide open). No token, no data — just a 401.
(2) The dashboard page isn't indexable. The dashboard path is noindex + blocked in robots, and the Worker skips logging that path (so the operator's own visits don't pollute the stats).
if (url.pathname.startsWith('/dashboard')) {
// always fresh (bypass edge cache) + skip logging
...
return r;
}
A token in the URL is convenient but not airtight — it can leak via a shared link or a referer. So that's the "first lock," and for something stronger there's (3) Cloudflare Access (Zero Trust), which puts the dashboard path itself behind a login gate (email OTP / SSO). Only allowed people ever reach the page, so even a leaked token can't open it.
So: token (API) + noindex (discovery) + Cloudflare Access (page gate) — three layers. Access control is the thing people building their own analytics forget most often, and since the data is privacy, it's the thing to get right first.
So, the dashboard
On top of all this I put a per-page stats UI — KPIs (today / yesterday / all-time), a daily bar chart, referrer channels (search / social / direct), device and country breakdowns, a live "N people right now," even 404 traffic. All of it runs on my own domain, with zero servers.
No servers, and here we are. I nearly stood up a backend just to count visits, and instead ended up with accuracy intact, servers at zero on Cloudflare Workers + D1. If you run a static site and keep hitting "do I really need a server for this?" — it's worth a look. It's less code than you'd think.
Comments