Self-hosted, privacy-first web analytics in a single Rust binary. No cookies, no stored IPs, no consent banner. One SQLite file holds everything, and it runs on a Fly.io machine that sleeps when nobody's looking — a few cents a month for a personal site.
<script src="https://your-app.fly.dev/script.js"></script>That one tag is the whole integration. It works on any site — Zola, Hugo, Astro, Next.js, plain HTML — and it handles SPA route changes on its own.
| checkpulse | Plausible Cloud | GA4 | |
|---|---|---|---|
| Cost | Your Fly bill (cents/month) | From $9/month | Free |
| Where the data lives | Your SQLite file | Their servers | |
| Cookie/consent banner | Not needed | Not needed | Usually required |
| Script size | ~700 bytes | ~1 KB | ~50 KB |
| Setup | fly deploy |
Sign up | Tag manager, property config |
| Hackable | ~1,000 lines of Rust you own | No | No |
It exists because a personal site doesn't need session recording and a funnel builder — it needs to know which posts people read and where they came from. That fits in a binary you can read end to end in an afternoon.
A dashboard at / (basic auth, ?period=today|7d|30d) showing:
- Page views and approximate unique visitors
- A views-over-time chart
- Top pages, top referrers, top custom events (as
clicks / people) - Browser family (Chrome/Safari/Firefox/Edge/Other) and device type (desktop/mobile)
Three endpoints on one binary: /script.js (the tracker), /api/event (ingestion), / (the dashboard). Plus /health.
What you don't get: one deployment tracks one site. No funnels, goals, retention policies, scheduled email reports, or multi-user logins. If you need those, use Plausible.
You need a Fly.io account and flyctl.
git clone https://github.com/bbelderbos/checkpulse.git
cd checkpulse
fly launch --no-deploy # pick your own app name and region
fly volumes create checkpulse_data --region <your-region> --size 1
fly secrets set DASHBOARD_USER=admin DASHBOARD_PASSWORD='<a long random password>'
fly deployThen point fly.toml at your own site — these are the only values you must change:
[env]
DATABASE_PATH = "/data/checkpulse.db"
SITE_ID = "example.com" # your domain
ALLOWED_ORIGIN = "https://example.com" # rejects events from anywhere elseRedeploy, then add the script tag to your site's <head>:
<script src="https://your-app.fly.dev/script.js"></script>Visit https://your-app.fly.dev/ and log in with the credentials you set. Views should appear within seconds.
With auto_stop_machines = "suspend" and min_machines_running = 0 (already in fly.toml), the machine suspends when idle and wakes on the next request. A shared-cpu-1x 256 MB machine running full time is about $2/month, and the 1 GB volume is $0.15/month — a low-traffic site lands well under that. Check current Fly pricing before you trust any number in a README.
Nothing here is Fly-specific, though. It's a Dockerfile and a volume: any host that runs a container with persistent disk works.
cp .env.example .env # set DASHBOARD_PASSWORD
DASHBOARD_USER=admin DASHBOARD_PASSWORD=secret PORT=8099 cargo runThe dashboard is at http://localhost:8099/. The tracking snippet deliberately ignores localhost, so send a test event by hand:
curl -X POST localhost:8099/api/event \
-H 'User-Agent: Mozilla/5.0' \
-d '{"path":"/hello","referrer":"https://news.ycombinator.com/"}'(ALLOWED_ORIGIN defaults to a real domain, so set it empty locally if you want to post without matching headers.)
| Var | Default | Notes |
|---|---|---|
DATABASE_PATH |
checkpulse.db |
SQLite file path |
SITE_ID |
belderbos.dev |
Tag stored on every event; also used to drop self-referrals |
ALLOWED_ORIGIN |
https://belderbos.dev |
Events whose Origin/Referer doesn't match are rejected (empty = disabled) |
DASHBOARD_USER |
admin |
Dashboard basic auth username |
DASHBOARD_PASSWORD |
(required) | The app refuses to start if unset |
BIND / PORT |
0.0.0.0 / 8080 |
Listen address |
The snippet exposes window.checkpulse(name) for things that aren't page loads:
<button onclick="checkpulse('newsletter-signup')">Subscribe</button>
<a href="https://github.com/bbelderbos" onclick="checkpulse('outbound-github')">GitHub</a>Each call stores the event name and the current path — nothing else. Names are capped at 64 characters and stored verbatim, so keep personal data out of them. They show up in the dashboard's Top events panel, counted separately from page views.
Because every event row keeps the path it fired from, you can break any event down by article without tracking anything extra. See OPERATIONS.md for the just events queries.
The panel shows clicks / people. When those diverge, one visitor is clicking
the same thing repeatedly — usually a sign the link isn't doing what they
expect. just sessions prints the per-visitor timeline behind it.
Referrers come from the Referer header, reduced to a bare host. Mobile apps
strip that header often enough that a large "direct" bucket is normal.
To recover those visits, tag the link with utm_source:
https://example.com/pricing?utm_source=linkedin
The snippet forwards the query string and the server stores utm_source in the
referrer column when no real referrer is present — a genuine header always wins.
Values are lower-cased and limited to 32 characters of [a-z0-9_-]; anything
else is dropped. Only utm_source is read; the rest of the query string is
discarded and never stored.
No cookies, no localStorage, no stored IPs.
The visitor's IP feeds a daily-salted SHA-256 hash used for approximate unique counting, then is discarded. The salt is random, in-memory, and rotates every 24 hours and on restart — so visitors can't be correlated across days, and there's nothing on disk to recover them from. DNT: 1 requests are dropped before anything is recorded. Browser family and device type are coarse enough (five buckets, two buckets) to be useless for fingerprinting.
This is the Plausible model: privacy-respecting aggregate analytics. In most jurisdictions that means no consent banner, but this is not legal advice.
Properties verified by review:
- No SQL injection — every query uses bound parameters (sqlx prepared statements); request data is never concatenated into SQL.
- No stored XSS — dashboard output is auto-escaped (Askama); the only unescaped values are server-generated chart numbers and dates.
- No PII at rest — see the privacy model above.
- Authenticated dashboard — basic auth over forced HTTPS; the app refuses to start without
DASHBOARD_PASSWORD. Use a long random one: the dashboard is not rate-limited. - Abuse limits — per-IP rate limiting (120 req/min) on
/api/event,Origin/Refererallow-listing, a 4 KB request-body cap, and field-length caps on path, referrer, query string, and event name. - Bot filtering — requests without a browser-shaped User-Agent, and known crawler tokens, are dropped.
- Hardened runtime — runs as a non-root user in the container; secrets come from env / Fly secrets and are gitignored locally.
cargo fmt
cargo clippy --all-targets -- -D warnings
cargo test
just cov # coverage summaryDay-to-day operation of a deployed instance — logs, backups, password rotation, querying events, teardown — lives in OPERATIONS.md.
MIT