Cloudflare Workers and D1
Put Analytik and its dashboard on Cloudflare Workers with one command, a deploy button or by hand, with a D1 database, secrets, a custom domain, the free-plan limits and a first-party route for ad blockers.
This is the smallest deployment: one Worker with a D1 database. It can serve the dashboard, the tracker script and the API all by itself, so there is no other app to build.
One command
npx analytik@latest create my-stats
It makes the project folder, installs the packages, signs you in to Cloudflare if needed, creates the D1 database, deploys the Worker and sets both secrets. At the end it prints the address and a generated dashboard password (kept in .dev.vars, which is git-ignored). Open the address, sign in, add your site and paste the tracking snippet it shows.
Add --name shop-stats to choose the Worker and database name, or --no-deploy to write the files only and run the Wrangler steps yourself.
Deploy button
The button copies the template into your GitHub or GitLab account, creates the D1 database and deploys. Before you confirm, fill in the two secrets it asks for on its screen: ADMIN_PASSWORD is the password you will sign in with, and SESSION_SECRET is at least 32 random characters (openssl rand -hex 32). The Worker refuses to start without them; if you skipped them, add both under the Worker’s Settings, Variables and Secrets.
The files it makes
The project is a copy of the template; you can also write it by hand. The rest of this page explains each part, and shows the variant without the built-in dashboard, for when you embed the dashboard in your own app.
Project
Project
mkdir analytik-worker && cd analytik-worker
bun add analytik hono
bun add -d wrangler @cloudflare/workers-types typescript
mkdir -p public src
cp node_modules/analytik/dist/analytik.js public/js/analytik.js
The last line puts the tracker where Workers serves static files from. Repeat it when you upgrade the package.
The Worker
import { createAnalytik } from 'analytik/server'
import { d1Storage } from 'analytik/storage/d1'
interface Env {
DB: D1Database
ADMIN_PASSWORD: string
SESSION_SECRET: string
}
// Build the app once per isolate, not once per request.
let app: ReturnType<typeof createAnalytik>['app'] | undefined
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
app ??= createAnalytik({
storage: d1Storage(env.DB),
admin: { password: env.ADMIN_PASSWORD, secret: env.SESSION_SECRET },
}).app
return app.fetch(request, env, ctx)
},
}
Location comes from Cloudflare’s cf-ipcountry header and request.cf, so no geo option is needed.
With the dashboard built in
withDashboard() from analytik/standalone puts the dashboard and the tracker in front of the app: / is the dashboard, /js/analytik.js the tracker, and every other request goes to the app. Wrap the app and leave out the public folder and the assets block of wrangler.jsonc:
import { createAnalytik } from 'analytik/server'
import { withDashboard } from 'analytik/standalone'
import { d1Storage } from 'analytik/storage/d1'
interface Env {
DB: D1Database
ADMIN_PASSWORD: string
SESSION_SECRET: string
}
let handler: ReturnType<typeof withDashboard<[Env, ExecutionContext]>> | undefined
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
handler ??= withDashboard(
createAnalytik({
storage: d1Storage(env.DB),
admin: { password: env.ADMIN_PASSWORD, secret: env.SESSION_SECRET },
}).app,
)
return handler.fetch(request, env, ctx)
},
}
The dashboard is built into that module, so the Worker grows by about 1.4 MB (430 kB compressed), well inside the free plan’s 3 MB. withDashboard(app, { api, title }) takes the API path (default /api, set it when you use basePath) and the tab title. The page is noindex, framed nowhere, and its script and stylesheet are cached for a year under names that change with each release.
wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "analytik",
"main": "src/worker.ts",
"compatibility_date": "2026-10-01",
"observability": { "enabled": true },
"assets": {
"directory": "./public",
// Only API calls run the Worker. The tracker is a free static file.
"run_worker_first": ["/api/*"],
},
"d1_databases": [
{
"binding": "DB",
"database_name": "analytik",
// The id printed by `npx wrangler d1 create analytik`.
"database_id": "00000000-0000-0000-0000-000000000000",
},
],
"routes": [{ "pattern": "analytics.example.com", "custom_domain": true }],
}
The binding must be DB to match the code above. routes with custom_domain attaches the Worker to a hostname on a zone in your account and creates the DNS record and certificate; leave routes out to use the *.workers.dev address first.
Database and secrets
npx wrangler d1 create analytik # copy database_id into wrangler.jsonc
npx analytik migrate --print-sql sqlite > analytik-schema.sql
npx wrangler d1 execute analytik --remote --file analytik-schema.sql # create the schema
npx wrangler secret put ADMIN_PASSWORD # the dashboard password, not empty
npx wrangler secret put SESSION_SECRET # openssl rand -hex 32, at least 32 characters
npx wrangler deploy
The Analytik command line cannot reach D1, so the schema goes in through Wrangler. --print-sql sqlite prints every migration plus the rows that mark them as applied. The Worker also applies pending migrations on its first request (autoMigrate, on by default), so the explicit step mostly saves the first request some work, and after a package upgrade you can simply deploy.
createAnalytik() throws when ADMIN_PASSWORD is empty or SESSION_SECRET is shorter than 32 characters, so the Worker fails on its first request until both secrets are set properly. For local work, put the two secrets in .dev.vars and run npx wrangler dev.
Create the first site as shown in Getting started, or in the dashboard, and add the script:
<script defer src="https://analytics.example.com/js/analytik.js" data-site="SITE_ID"></script>
Allowed origins default to the registered site domain and its subdomains, so one deployment serves several sites; add each one as its own site.
Free plan
A small site fits in Cloudflare’s free plan, and several small sites often do. Check the current numbers on Cloudflare’s limits and pricing pages, because they change. At the time of writing:
- Workers Free: 100,000 requests per day across the account, and 10 ms of CPU per request. Static assets, including
analytik.js, are not billed as requests. - D1 Free: about 5 million rows read and 100,000 rows written per day, and 5 GB of storage in total.
Each tracked pageview costs one Worker request and a few written rows; engagement pings, goals and dashboard use add more. As a rough guide, the write limit is reached at a few tens of thousands of pageviews a day, whichever limit comes first. Core Web Vitals add one written row for each measured page view, on top of that, so on the free plan keep the share low (the default is 10%) or leave them off. When a limit is exceeded, requests fail until it resets, which for analytics means lost events, not a broken site: the tracker never blocks your page. If you outgrow the free plan, the paid Workers plan is a flat monthly fee with generous included usage.
Three things keep usage down: leave data-require-interaction on so that bounces from bots cost nothing, keep activity updates at their default of one every 4 minutes (or data-ping-interval="0" for the fewest requests), and set a retention so the database does not grow without bound. The dashboard keeps answers for a minute and loads the sites overview in one request; leave its Live switch off on a dashboard that stays open for hours, since every live refresh reads the whole range again.
Ad blockers and first-party routes
Some blockers drop requests to hosts they know from analytics lists, and some match on path names like /analytics. Serving the script and the endpoint from your own domain under a neutral path avoids both.
If your site is on Cloudflare, attach the same Worker to a path on the site’s zone instead of a separate hostname, so the real visitor IP reaches the server. Use a base path and keep the files under it:
createAnalytik({
storage: d1Storage(env.DB),
admin: { password: env.ADMIN_PASSWORD, secret: env.SESSION_SECRET },
basePath: '/s/api',
})
{
"assets": { "directory": "./public", "run_worker_first": ["/s/api/*"] },
"routes": [{ "pattern": "example.com/s/*", "zone_name": "example.com" }],
}
Put the tracker at public/s/analytik.js and point it at the base path with data-api, because without it the tracker uses /api on the script’s origin. The tracker adds /event itself:
<script defer src="/s/analytik.js" data-site="SITE_ID" data-api="/s/api"></script>
Mount the dashboard on api="/s/api". Pick any path you like instead of /s.
If your site is not on Cloudflare, use your host’s rewrite feature (a Vercel or Netlify rewrite, an nginx proxy_pass) to send the path to the Worker. A Worker that sits behind another proxy does not see the visitor’s address on its own: by default Analytik trusts only Cloudflare’s cf-connecting-ip (which, behind another proxy, is that proxy’s address) and ignores X-Forwarded-For, because any visitor could send that header themselves. Pick one of these:
trustProxy: 'forwarded'reads the rightmost public address inX-Forwarded-For. Use it only when the proxy in front overwrites or appends that header with the real client address.clientIp: (request) => request.headers.get('x-real-ip'), or whichever header your proxy sets, if it uses a different one. Make sure the proxy replaces that header and does not pass on a value from the visitor.
Without a client IP, strict mode tells visitors apart by user agent and site only, so every visitor with the same user agent is counted as one. Rate limits, including the login limit, are then shared by everyone.