Vibecode Plausible
track this build7 phases, 21 steps, beginner friendly0%Basic pageview/event analytics are easy, and Plausible's own code is open source; the paid value is hosted reliability, maintenance, and support.
You are building a lean indie version of Plausible.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Plausible · indie build
Privacy-first analytics for your own sites: a tracker under 2 KB with no cookies, an ingest endpoint that never stores a raw IP, a daily-rotating visitor hash exactly as Plausible defines it, sessions and bounce rate computed at write time, and a fast dashboard. Honest at personal-site scale, and you can state the ceiling.
Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | an event is one INSERT; the dashboard is a handful of indexed GROUP BYs |
| Database | SQLite in WAL mode | fine to a few million events; say the ceiling out loud |
| Tracker | Hand-written vanilla JS under 2 KB | if it needs a bundler it is too big |
| Geo | MaxMind GeoLite2 country database, read locally | country from IP without sending the IP anywhere |
| Hosting | A small VPS behind Caddy | the ingest endpoint must be public and on HTTPS |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **The site (or sites) you want to measure** · free
- Why: The tracker is a script tag you add to each site; you need somewhere to add it.
- Get it: Write down each site's domain. Each becomes a site_id in SITES.
- [ ] **A MaxMind account and licence key for GeoLite2** · free
- Why: Country breakdowns need an IP-to-country database. GeoLite2 is free but requires an account to download and to receive updates.
- Get it: Sign up at maxmind.com/en/geolite2/signup, then Account > Manage License Keys > Generate new license key. Download GeoLite2-Country.mmdb (or use geoipupdate). Put the file path in .env.
- Verify: The .mmdb file exists at GEOIP_DB_PATH
- [ ] **Understand the daily-salt rule** · free
- Why: The privacy claim rests on one rule: the salt rotates daily and the old one is deleted. If you keep old salts, this is a tracker with extra steps.
- Get it: Read Plausible's data policy page once before Phase 1 so the design is yours, not a guess.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. Sites post to it from the visitor's browser, so it must be public.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: The collector needs an HTTPS address, e.g. stats.yourdomain.com.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Funnels, goals, revenue attribution and scheduled email reports.
- Bot filtering tuned on adversarial traffic. Yours is a list.
- Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible.
- battle-tested bot filtering
- fast queries at millions of pageviews
- email reports, funnels, goal tracking
- GDPR homework done for you
If one of those is essential to you, that is the reason to keep paying for Plausible, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Plausible
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a privacy-first web analytics tool like Plausible, at personal scale.
Build it in phases, in the order below. Do not write the whole system in one
pass. Finish a phase, run its "Done when" check, fix what fails, and only then
start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework, one process.
- The tracker is hand-written vanilla JS, no build step, and must stay under 2KB
minified. If it needs a bundler, it is too big.
- The dashboard is server-rendered HTML with inline SVG charts. No chart library.
### Data model (create this before Phase 1)
- `events`: id, site_id, name ('pageview' | custom), path, referrer_host,
screen_class ('mobile' | 'tablet' | 'desktop'), country, browser, os,
visitor_hash, session_id, timestamp
- `sessions`: id, site_id, visitor_hash, started_at, last_event_at, entry_path,
exit_path, event_count, is_bounce
- `salts`: day (date), salt, created_at
Index `events(site_id, timestamp)` and `events(site_id, path, timestamp)`.
Never store a raw IP address or a raw user-agent string in any table. Not in a
column you plan to drop later, not in a log file. This is the one rule that makes
the product what it claims to be.
### Phase 1 · Identity without cookies
Build: the daily-salt visitor hash, exactly as Plausible defines it:
`visitor_hash = hash(daily_salt + site_domain + ip_address + user_agent)`
- Generate a fresh random salt per UTC day.
- Delete the previous day's salt when it rotates. Deleting it is what makes
cross-day re-identification impossible even for you · keeping the old salts
turns this into a tracker with extra steps.
- Include the site domain so the same visitor on two of your sites is two hashes.
- Hash with SHA-256 or better; the IP and user agent exist only inside this
function and are never written anywhere.
Done when: two requests from the same IP and agent on the same day produce one
hash, the same pair on two different sites produce two hashes, the hash changes
after a forced salt rotation, and grepping the database file for your own IP
returns nothing.
Do not build yet: the tracker, sessions, dashboard.
### Phase 2 · Ingest endpoint
Build: `POST /api/event` accepting site_id, name, path (path only · strip query
strings and hashes unless a site explicitly opts into a query allowlist), and
referrer. Derive screen class, browser, os and country server-side, then discard
the inputs. Cap the body at 2KB, validate site_id against a configured list,
respond `202` with an empty body always · an analytics endpoint must never leak
information about itself and must never make a visitor's page wait.
Done when: a hand-crafted curl request writes one row with no raw identifiers
stored, an unknown site_id is silently accepted and dropped, and a malformed body
returns 202 without a stack trace.
### Phase 3 · The tracker
Build: the script · a pageview beacon on load, on `pushState`/`popstate` for SPA
routing, and a custom-event function on `window`. Use `navigator.sendBeacon` with
a fetch fallback. Respect Do Not Track and honour a `localStorage` opt-out flag.
Never read or write a cookie. Ship the file with a long cache header and a
version in the filename.
Done when: the minified file is under 2KB, a page load records exactly one
pageview (not two in development with a strict-mode double render), an SPA route
change records a second, and setting the opt-out flag records nothing at all.
### Phase 4 · Sessions and bounce rate
Build: sessionization · an event joins the open session for its visitor_hash when
the last event was under 30 minutes ago, otherwise it starts a new one. A session
with exactly one pageview is a bounce. Compute this at write time, not in the
dashboard query, and store entry and exit paths.
Done when: two pageviews 5 minutes apart are one session with `is_bounce` false,
two pageviews 31 minutes apart are two sessions both bouncing, and the bounce rate
matches a hand count on a seeded fixture.
### Phase 5 · Bot filtering
Build: a user-agent bot filter applied at ingest, plus a rule dropping events
with no referrer and an impossible screen class. Count what you filter into a
separate table rather than discarding it silently, so a sudden traffic drop can be
explained rather than guessed at.
Done when: a curl with a default user agent is filtered, a real browser is not,
and the filtered count is visible in the dashboard.
### Phase 6 · Dashboard
Build: `/dashboard` behind basic auth from `.env` · unique visitors, pageviews,
bounce rate and visit duration for today, 7 days and 30 days, with top pages, top
referrers, countries, browsers and devices, and a visitors-per-day bar chart as
inline SVG. Add a site switcher. Dark mode. Fast · every panel is one indexed
query, and none of them scans the whole events table.
Done when: every number reconciles with a hand-written SQL query on a seeded
fixture of 100,000 events, and the page renders in under 200ms on that fixture.
### Phase 7 · Retention and deploy
Build: a retention job deleting raw events older than a configurable window while
keeping daily rollups, a `/healthz` endpoint, a nightly backup command, a systemd
unit, and the README.
Done when: retention runs without breaking historical charts, and a reader goes
from clone to a tracked site using only the README.
### Out of scope (and why)
- Funnels, goals, revenue attribution and scheduled email reports.
- Battle-tested bot filtering. Theirs is tuned against real adversarial traffic
and yours is a user-agent list · say so rather than implying parity.
- Fast queries at millions of pageviews. SQLite on one box is honest for a
personal site and dishonest for a business. State the ceiling in the README and
point at self-hosting the real Plausible above it.
### README must contain
- The snippet to paste, with the site_id.
- A plain-language data policy: what is collected, what is derived, what is never
stored, and the fact that the daily salt is deleted.
- The traffic ceiling this build is honest at, as a number.
===== AGENTS.md =====
# Agent instructions · Plausible indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, Hand-written vanilla JS under 2 KB, MaxMind GeoLite2 country database, read locally, A small VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
===== BUILD_PLAN.md =====
# Build plan · Plausible
Privacy-first analytics for your own sites: a tracker under 2 KB with no cookies, an ingest endpoint that never stores a raw IP, a daily-rotating visitor hash exactly as Plausible defines it, sessions and bounce rate computed at write time, and a fast dashboard. Honest at personal-site scale, and you can state the ceiling.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Identity without cookies
The visitor hash and its salt, done exactly right, before anything else exists.
### Steps
1. Create the project and the salts table
salts (day, salt, created_at). Generate 32 random bytes per UTC day on first use.
Files: `server.mjs`, `identity.mjs`
```sh
mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Implement visitorHash(site, ip, userAgent)
sha256(daily_salt + site_domain + ip + user_agent). Include the domain so the same visitor on two of your sites is two hashes. The ip and user agent exist only inside this function.
3. Rotate and delete
At UTC midnight create tomorrow's salt and delete yesterday's row. Deleting is what makes cross-day re-identification impossible even for you.
### Done when
- [ ] Two calls with the same ip and agent on the same day produce one hash
- [ ] The same pair on two site ids produce two hashes
- [ ] After a forced rotation the hash changes and the old salt row is gone
- [ ] grep of the database file for your own IP returns nothing
## Phase 2 · Ingest endpoint
POST /api/event stores derived facts only and never makes a visitor's page wait.
### Steps
1. Create the events table with its indexes
events (id, site_id, name, path, referrer_host, screen_class, country, browser, os, visitor_hash, session_id, timestamp). Index (site_id, timestamp) and (site_id, path, timestamp).
2. Derive, then discard
Screen class from a width the tracker sends, browser and os from the user agent, country from the GeoLite2 lookup. Strip query strings and hashes from path. Then drop the inputs.
3. Always answer 202 with an empty body
Unknown site ids are accepted and dropped. Malformed bodies get 202 too. Cap the body at 2 KB. An analytics endpoint must leak nothing about itself.
### Done when
- [ ] A hand-crafted curl writes one row with no raw identifiers
- [ ] An unknown site_id returns 202 and stores nothing
- [ ] A malformed body returns 202 without a stack trace in the log
- [ ] Country resolves for a known public IP when GEOIP_DB_PATH is set
## Phase 3 · The tracker
Under 2 KB, no cookies, counts SPA route changes once, honours opt-out.
### Steps
1. Write public/p.js
On load send a pageview with path, referrer and innerWidth via navigator.sendBeacon with a fetch fallback. Hook pushState and popstate for SPA routes. Expose window.plausible-style track(name) for custom events.
2. Respect Do Not Track and a localStorage opt-out flag
If either is set, send nothing at all.
3. Serve it with a long cache header and a version in the filename
p.v1.js; bump on change.
4. Add the snippet to one real site and watch events arrive
```sh
<script defer data-site="yoursite.com" src="https://stats.yourdomain.com/p.v1.js"></script>
```
### Done when
- [ ] The minified file is under 2 KB
- [ ] One page load records exactly one pageview, including under React strict mode
- [ ] An SPA route change records a second pageview
- [ ] With the opt-out flag set nothing is sent
## Phase 4 · Sessions and bounce rate
Sessionize at write time so the dashboard never does it.
### Steps
1. Create the sessions table
sessions (id, site_id, visitor_hash, started_at, last_event_at, entry_path, exit_path, event_count, is_bounce).
2. Join or open a session on each event
If the visitor's last event on this site was under 30 minutes ago, update that session; otherwise open a new one. A session with one pageview is a bounce.
### Done when
- [ ] Two pageviews 5 minutes apart are one session with is_bounce false
- [ ] Two pageviews 31 minutes apart are two sessions, both bouncing
- [ ] Bounce rate matches a hand count on a seeded fixture
## Phase 5 · Bot filtering
Drop obvious bots at ingest and count what you dropped.
### Steps
1. Filter by user agent and impossible screen classes
A short bot list, plus events with no referrer and a zero width.
2. Count filtered events in a separate table
filtered (day, site_id, reason, n). A traffic drop you can explain beats one you guess at.
### Done when
- [ ] A curl with a default user agent is filtered
- [ ] A real browser is not
- [ ] The filtered count is visible on the dashboard
## Phase 6 · Dashboard
Every panel one indexed query; every number reconcilable with SQL.
### Steps
1. Build /dashboard behind basic auth with a site switcher
Unique visitors, pageviews, bounce rate and visit duration for today, 7 and 30 days.
2. Add top pages, referrers, countries, browsers, devices
Each a GROUP BY with a LIMIT.
3. Draw visitors per day as inline SVG
No chart library. Dark mode.
4. Seed 100,000 events and time every panel
```sh
node scripts/seed.mjs 100000
```
### Done when
- [ ] Every number reconciles with a hand-written query on the seeded fixture
- [ ] The page renders in under 200 ms on 100,000 events
- [ ] Switching sites changes every panel
## Phase 7 · Retention and deploy
Old events rolled up, live on your domain, documented with the ceiling stated.
### Steps
1. Write the nightly rollup
daily (day, site_id, visitors, pageviews, bounces) computed from events, then delete events older than RETENTION_DAYS. Charts read daily for old ranges.
2. Add /healthz, systemd, Caddy and a backup command
Files: `deploy/analytics.service`, `Caddyfile`
3. Write the README and the data policy
The snippet, a plain-language policy (collected, derived, never stored, salt deleted daily), and the traffic ceiling as a number.
Files: `README.md`, `PRIVACY.md`
### Done when
- [ ] Retention runs without breaking historical charts
- [ ] A reader goes from clone to a tracked site using only the README
- [ ] PRIVACY.md states that the daily salt is deleted
## Not in this build
- Funnels, goals, revenue attribution and scheduled email reports.
- Bot filtering tuned on adversarial traffic. Yours is a list.
- Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible.
## After v1, if you want it
- A public shareable dashboard link per site
- Goal tracking as a custom event with a conversion panel
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file. Back it up.
DATABASE_PATH=./data/analytics.db
# Required. Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped.
SITES=yoursite.com,blog.yoursite.com
# Optional. The MaxMind file from the prerequisites.
GEOIP_DB_PATH=./data/GeoLite2-Country.mmdb
# Optional. Raw events older than this are rolled up and deleted nightly.
RETENTION_DAYS=400
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Required. Public base URL of the collector, used in the snippet.
SITE_URL=https://stats.yourdomain.com
You are building a lean indie version of Plausible.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Plausible · indie build
Privacy-first analytics for your own sites: a tracker under 2 KB with no cookies, an ingest endpoint that never stores a raw IP, a daily-rotating visitor hash exactly as Plausible defines it, sessions and bounce rate computed at write time, and a fast dashboard. Honest at personal-site scale, and you can state the ceiling.
Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | an event is one INSERT; the dashboard is a handful of indexed GROUP BYs |
| Database | SQLite in WAL mode | fine to a few million events; say the ceiling out loud |
| Tracker | Hand-written vanilla JS under 2 KB | if it needs a bundler it is too big |
| Geo | MaxMind GeoLite2 country database, read locally | country from IP without sending the IP anywhere |
| Hosting | A small VPS behind Caddy | the ingest endpoint must be public and on HTTPS |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **The site (or sites) you want to measure** · free
- Why: The tracker is a script tag you add to each site; you need somewhere to add it.
- Get it: Write down each site's domain. Each becomes a site_id in SITES.
- [ ] **A MaxMind account and licence key for GeoLite2** · free
- Why: Country breakdowns need an IP-to-country database. GeoLite2 is free but requires an account to download and to receive updates.
- Get it: Sign up at maxmind.com/en/geolite2/signup, then Account > Manage License Keys > Generate new license key. Download GeoLite2-Country.mmdb (or use geoipupdate). Put the file path in .env.
- Verify: The .mmdb file exists at GEOIP_DB_PATH
- [ ] **Understand the daily-salt rule** · free
- Why: The privacy claim rests on one rule: the salt rotates daily and the old one is deleted. If you keep old salts, this is a tracker with extra steps.
- Get it: Read Plausible's data policy page once before Phase 1 so the design is yours, not a guess.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. Sites post to it from the visitor's browser, so it must be public.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: The collector needs an HTTPS address, e.g. stats.yourdomain.com.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- Funnels, goals, revenue attribution and scheduled email reports.
- Bot filtering tuned on adversarial traffic. Yours is a list.
- Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible.
- battle-tested bot filtering
- fast queries at millions of pageviews
- email reports, funnels, goal tracking
- GDPR homework done for you
If one of those is essential to you, that is the reason to keep paying for Plausible, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Plausible
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a privacy-first web analytics tool like Plausible, at personal scale.
Build it in phases, in the order below. Do not write the whole system in one
pass. Finish a phase, run its "Done when" check, fix what fails, and only then
start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework, one process.
- The tracker is hand-written vanilla JS, no build step, and must stay under 2KB
minified. If it needs a bundler, it is too big.
- The dashboard is server-rendered HTML with inline SVG charts. No chart library.
### Data model (create this before Phase 1)
- `events`: id, site_id, name ('pageview' | custom), path, referrer_host,
screen_class ('mobile' | 'tablet' | 'desktop'), country, browser, os,
visitor_hash, session_id, timestamp
- `sessions`: id, site_id, visitor_hash, started_at, last_event_at, entry_path,
exit_path, event_count, is_bounce
- `salts`: day (date), salt, created_at
Index `events(site_id, timestamp)` and `events(site_id, path, timestamp)`.
Never store a raw IP address or a raw user-agent string in any table. Not in a
column you plan to drop later, not in a log file. This is the one rule that makes
the product what it claims to be.
### Phase 1 · Identity without cookies
Build: the daily-salt visitor hash, exactly as Plausible defines it:
`visitor_hash = hash(daily_salt + site_domain + ip_address + user_agent)`
- Generate a fresh random salt per UTC day.
- Delete the previous day's salt when it rotates. Deleting it is what makes
cross-day re-identification impossible even for you · keeping the old salts
turns this into a tracker with extra steps.
- Include the site domain so the same visitor on two of your sites is two hashes.
- Hash with SHA-256 or better; the IP and user agent exist only inside this
function and are never written anywhere.
Done when: two requests from the same IP and agent on the same day produce one
hash, the same pair on two different sites produce two hashes, the hash changes
after a forced salt rotation, and grepping the database file for your own IP
returns nothing.
Do not build yet: the tracker, sessions, dashboard.
### Phase 2 · Ingest endpoint
Build: `POST /api/event` accepting site_id, name, path (path only · strip query
strings and hashes unless a site explicitly opts into a query allowlist), and
referrer. Derive screen class, browser, os and country server-side, then discard
the inputs. Cap the body at 2KB, validate site_id against a configured list,
respond `202` with an empty body always · an analytics endpoint must never leak
information about itself and must never make a visitor's page wait.
Done when: a hand-crafted curl request writes one row with no raw identifiers
stored, an unknown site_id is silently accepted and dropped, and a malformed body
returns 202 without a stack trace.
### Phase 3 · The tracker
Build: the script · a pageview beacon on load, on `pushState`/`popstate` for SPA
routing, and a custom-event function on `window`. Use `navigator.sendBeacon` with
a fetch fallback. Respect Do Not Track and honour a `localStorage` opt-out flag.
Never read or write a cookie. Ship the file with a long cache header and a
version in the filename.
Done when: the minified file is under 2KB, a page load records exactly one
pageview (not two in development with a strict-mode double render), an SPA route
change records a second, and setting the opt-out flag records nothing at all.
### Phase 4 · Sessions and bounce rate
Build: sessionization · an event joins the open session for its visitor_hash when
the last event was under 30 minutes ago, otherwise it starts a new one. A session
with exactly one pageview is a bounce. Compute this at write time, not in the
dashboard query, and store entry and exit paths.
Done when: two pageviews 5 minutes apart are one session with `is_bounce` false,
two pageviews 31 minutes apart are two sessions both bouncing, and the bounce rate
matches a hand count on a seeded fixture.
### Phase 5 · Bot filtering
Build: a user-agent bot filter applied at ingest, plus a rule dropping events
with no referrer and an impossible screen class. Count what you filter into a
separate table rather than discarding it silently, so a sudden traffic drop can be
explained rather than guessed at.
Done when: a curl with a default user agent is filtered, a real browser is not,
and the filtered count is visible in the dashboard.
### Phase 6 · Dashboard
Build: `/dashboard` behind basic auth from `.env` · unique visitors, pageviews,
bounce rate and visit duration for today, 7 days and 30 days, with top pages, top
referrers, countries, browsers and devices, and a visitors-per-day bar chart as
inline SVG. Add a site switcher. Dark mode. Fast · every panel is one indexed
query, and none of them scans the whole events table.
Done when: every number reconciles with a hand-written SQL query on a seeded
fixture of 100,000 events, and the page renders in under 200ms on that fixture.
### Phase 7 · Retention and deploy
Build: a retention job deleting raw events older than a configurable window while
keeping daily rollups, a `/healthz` endpoint, a nightly backup command, a systemd
unit, and the README.
Done when: retention runs without breaking historical charts, and a reader goes
from clone to a tracked site using only the README.
### Out of scope (and why)
- Funnels, goals, revenue attribution and scheduled email reports.
- Battle-tested bot filtering. Theirs is tuned against real adversarial traffic
and yours is a user-agent list · say so rather than implying parity.
- Fast queries at millions of pageviews. SQLite on one box is honest for a
personal site and dishonest for a business. State the ceiling in the README and
point at self-hosting the real Plausible above it.
### README must contain
- The snippet to paste, with the site_id.
- A plain-language data policy: what is collected, what is derived, what is never
stored, and the fact that the daily salt is deleted.
- The traffic ceiling this build is honest at, as a number.
===== AGENTS.md =====
# Agent instructions · Plausible indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, Hand-written vanilla JS under 2 KB, MaxMind GeoLite2 country database, read locally, A small VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
===== BUILD_PLAN.md =====
# Build plan · Plausible
Privacy-first analytics for your own sites: a tracker under 2 KB with no cookies, an ingest endpoint that never stores a raw IP, a daily-rotating visitor hash exactly as Plausible defines it, sessions and bounce rate computed at write time, and a fast dashboard. Honest at personal-site scale, and you can state the ceiling.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Identity without cookies
The visitor hash and its salt, done exactly right, before anything else exists.
### Steps
1. Create the project and the salts table
salts (day, salt, created_at). Generate 32 random bytes per UTC day on first use.
Files: `server.mjs`, `identity.mjs`
```sh
mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Implement visitorHash(site, ip, userAgent)
sha256(daily_salt + site_domain + ip + user_agent). Include the domain so the same visitor on two of your sites is two hashes. The ip and user agent exist only inside this function.
3. Rotate and delete
At UTC midnight create tomorrow's salt and delete yesterday's row. Deleting is what makes cross-day re-identification impossible even for you.
### Done when
- [ ] Two calls with the same ip and agent on the same day produce one hash
- [ ] The same pair on two site ids produce two hashes
- [ ] After a forced rotation the hash changes and the old salt row is gone
- [ ] grep of the database file for your own IP returns nothing
## Phase 2 · Ingest endpoint
POST /api/event stores derived facts only and never makes a visitor's page wait.
### Steps
1. Create the events table with its indexes
events (id, site_id, name, path, referrer_host, screen_class, country, browser, os, visitor_hash, session_id, timestamp). Index (site_id, timestamp) and (site_id, path, timestamp).
2. Derive, then discard
Screen class from a width the tracker sends, browser and os from the user agent, country from the GeoLite2 lookup. Strip query strings and hashes from path. Then drop the inputs.
3. Always answer 202 with an empty body
Unknown site ids are accepted and dropped. Malformed bodies get 202 too. Cap the body at 2 KB. An analytics endpoint must leak nothing about itself.
### Done when
- [ ] A hand-crafted curl writes one row with no raw identifiers
- [ ] An unknown site_id returns 202 and stores nothing
- [ ] A malformed body returns 202 without a stack trace in the log
- [ ] Country resolves for a known public IP when GEOIP_DB_PATH is set
## Phase 3 · The tracker
Under 2 KB, no cookies, counts SPA route changes once, honours opt-out.
### Steps
1. Write public/p.js
On load send a pageview with path, referrer and innerWidth via navigator.sendBeacon with a fetch fallback. Hook pushState and popstate for SPA routes. Expose window.plausible-style track(name) for custom events.
2. Respect Do Not Track and a localStorage opt-out flag
If either is set, send nothing at all.
3. Serve it with a long cache header and a version in the filename
p.v1.js; bump on change.
4. Add the snippet to one real site and watch events arrive
```sh
<script defer data-site="yoursite.com" src="https://stats.yourdomain.com/p.v1.js"></script>
```
### Done when
- [ ] The minified file is under 2 KB
- [ ] One page load records exactly one pageview, including under React strict mode
- [ ] An SPA route change records a second pageview
- [ ] With the opt-out flag set nothing is sent
## Phase 4 · Sessions and bounce rate
Sessionize at write time so the dashboard never does it.
### Steps
1. Create the sessions table
sessions (id, site_id, visitor_hash, started_at, last_event_at, entry_path, exit_path, event_count, is_bounce).
2. Join or open a session on each event
If the visitor's last event on this site was under 30 minutes ago, update that session; otherwise open a new one. A session with one pageview is a bounce.
### Done when
- [ ] Two pageviews 5 minutes apart are one session with is_bounce false
- [ ] Two pageviews 31 minutes apart are two sessions, both bouncing
- [ ] Bounce rate matches a hand count on a seeded fixture
## Phase 5 · Bot filtering
Drop obvious bots at ingest and count what you dropped.
### Steps
1. Filter by user agent and impossible screen classes
A short bot list, plus events with no referrer and a zero width.
2. Count filtered events in a separate table
filtered (day, site_id, reason, n). A traffic drop you can explain beats one you guess at.
### Done when
- [ ] A curl with a default user agent is filtered
- [ ] A real browser is not
- [ ] The filtered count is visible on the dashboard
## Phase 6 · Dashboard
Every panel one indexed query; every number reconcilable with SQL.
### Steps
1. Build /dashboard behind basic auth with a site switcher
Unique visitors, pageviews, bounce rate and visit duration for today, 7 and 30 days.
2. Add top pages, referrers, countries, browsers, devices
Each a GROUP BY with a LIMIT.
3. Draw visitors per day as inline SVG
No chart library. Dark mode.
4. Seed 100,000 events and time every panel
```sh
node scripts/seed.mjs 100000
```
### Done when
- [ ] Every number reconciles with a hand-written query on the seeded fixture
- [ ] The page renders in under 200 ms on 100,000 events
- [ ] Switching sites changes every panel
## Phase 7 · Retention and deploy
Old events rolled up, live on your domain, documented with the ceiling stated.
### Steps
1. Write the nightly rollup
daily (day, site_id, visitors, pageviews, bounces) computed from events, then delete events older than RETENTION_DAYS. Charts read daily for old ranges.
2. Add /healthz, systemd, Caddy and a backup command
Files: `deploy/analytics.service`, `Caddyfile`
3. Write the README and the data policy
The snippet, a plain-language policy (collected, derived, never stored, salt deleted daily), and the traffic ceiling as a number.
Files: `README.md`, `PRIVACY.md`
### Done when
- [ ] Retention runs without breaking historical charts
- [ ] A reader goes from clone to a tracked site using only the README
- [ ] PRIVACY.md states that the daily salt is deleted
## Not in this build
- Funnels, goals, revenue attribution and scheduled email reports.
- Bot filtering tuned on adversarial traffic. Yours is a list.
- Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible.
## After v1, if you want it
- A public shareable dashboard link per site
- Goal tracking as a custom event with a conversion panel
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file. Back it up.
DATABASE_PATH=./data/analytics.db
# Required. Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped.
SITES=yoursite.com,blog.yoursite.com
# Optional. The MaxMind file from the prerequisites.
GEOIP_DB_PATH=./data/GeoLite2-Country.mmdb
# Optional. Raw events older than this are rolled up and deleted nightly.
RETENTION_DAYS=400
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Required. Public base URL of the collector, used in the snippet.
SITE_URL=https://stats.yourdomain.com
You are building a production product version of Plausible.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# Plausible · product brief
## Problem
Basic pageview/event analytics are easy, and Plausible's own code is open source; the paid value is hosted reliability, maintenance, and support.
## Product outcome
An analytics collector you can point several small sites at with a privacy policy you can defend, and a stated scale at which you would move to Plausible itself.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- hosted server
- database
- tracker script
- domain/SSL
- bot filtering
- backups
## Explicit non-goals for v1
- Funnels, goals, revenue attribution and scheduled email reports.
- Bot filtering tuned on adversarial traffic. Yours is a list.
- Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible.
- battle-tested bot filtering
- fast queries at millions of pageviews
- email reports, funnels, goal tracking
- GDPR homework done for you
## Success criteria
- Identity tests pass and the database contains no raw IP or user agent
- Dashboard under 200 ms at 100,000 events
- PRIVACY.md published with the ceiling
- One restore drill performed and dated
===== BRIEF.md =====
# Build brief · Plausible
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a privacy-first web analytics tool like Plausible, at personal scale.
Build it in phases, in the order below. Do not write the whole system in one
pass. Finish a phase, run its "Done when" check, fix what fails, and only then
start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework, one process.
- The tracker is hand-written vanilla JS, no build step, and must stay under 2KB
minified. If it needs a bundler, it is too big.
- The dashboard is server-rendered HTML with inline SVG charts. No chart library.
### Data model (create this before Phase 1)
- `events`: id, site_id, name ('pageview' | custom), path, referrer_host,
screen_class ('mobile' | 'tablet' | 'desktop'), country, browser, os,
visitor_hash, session_id, timestamp
- `sessions`: id, site_id, visitor_hash, started_at, last_event_at, entry_path,
exit_path, event_count, is_bounce
- `salts`: day (date), salt, created_at
Index `events(site_id, timestamp)` and `events(site_id, path, timestamp)`.
Never store a raw IP address or a raw user-agent string in any table. Not in a
column you plan to drop later, not in a log file. This is the one rule that makes
the product what it claims to be.
### Phase 1 · Identity without cookies
Build: the daily-salt visitor hash, exactly as Plausible defines it:
`visitor_hash = hash(daily_salt + site_domain + ip_address + user_agent)`
- Generate a fresh random salt per UTC day.
- Delete the previous day's salt when it rotates. Deleting it is what makes
cross-day re-identification impossible even for you · keeping the old salts
turns this into a tracker with extra steps.
- Include the site domain so the same visitor on two of your sites is two hashes.
- Hash with SHA-256 or better; the IP and user agent exist only inside this
function and are never written anywhere.
Done when: two requests from the same IP and agent on the same day produce one
hash, the same pair on two different sites produce two hashes, the hash changes
after a forced salt rotation, and grepping the database file for your own IP
returns nothing.
Do not build yet: the tracker, sessions, dashboard.
### Phase 2 · Ingest endpoint
Build: `POST /api/event` accepting site_id, name, path (path only · strip query
strings and hashes unless a site explicitly opts into a query allowlist), and
referrer. Derive screen class, browser, os and country server-side, then discard
the inputs. Cap the body at 2KB, validate site_id against a configured list,
respond `202` with an empty body always · an analytics endpoint must never leak
information about itself and must never make a visitor's page wait.
Done when: a hand-crafted curl request writes one row with no raw identifiers
stored, an unknown site_id is silently accepted and dropped, and a malformed body
returns 202 without a stack trace.
### Phase 3 · The tracker
Build: the script · a pageview beacon on load, on `pushState`/`popstate` for SPA
routing, and a custom-event function on `window`. Use `navigator.sendBeacon` with
a fetch fallback. Respect Do Not Track and honour a `localStorage` opt-out flag.
Never read or write a cookie. Ship the file with a long cache header and a
version in the filename.
Done when: the minified file is under 2KB, a page load records exactly one
pageview (not two in development with a strict-mode double render), an SPA route
change records a second, and setting the opt-out flag records nothing at all.
### Phase 4 · Sessions and bounce rate
Build: sessionization · an event joins the open session for its visitor_hash when
the last event was under 30 minutes ago, otherwise it starts a new one. A session
with exactly one pageview is a bounce. Compute this at write time, not in the
dashboard query, and store entry and exit paths.
Done when: two pageviews 5 minutes apart are one session with `is_bounce` false,
two pageviews 31 minutes apart are two sessions both bouncing, and the bounce rate
matches a hand count on a seeded fixture.
### Phase 5 · Bot filtering
Build: a user-agent bot filter applied at ingest, plus a rule dropping events
with no referrer and an impossible screen class. Count what you filter into a
separate table rather than discarding it silently, so a sudden traffic drop can be
explained rather than guessed at.
Done when: a curl with a default user agent is filtered, a real browser is not,
and the filtered count is visible in the dashboard.
### Phase 6 · Dashboard
Build: `/dashboard` behind basic auth from `.env` · unique visitors, pageviews,
bounce rate and visit duration for today, 7 days and 30 days, with top pages, top
referrers, countries, browsers and devices, and a visitors-per-day bar chart as
inline SVG. Add a site switcher. Dark mode. Fast · every panel is one indexed
query, and none of them scans the whole events table.
Done when: every number reconciles with a hand-written SQL query on a seeded
fixture of 100,000 events, and the page renders in under 200ms on that fixture.
### Phase 7 · Retention and deploy
Build: a retention job deleting raw events older than a configurable window while
keeping daily rollups, a `/healthz` endpoint, a nightly backup command, a systemd
unit, and the README.
Done when: retention runs without breaking historical charts, and a reader goes
from clone to a tracked site using only the README.
### Out of scope (and why)
- Funnels, goals, revenue attribution and scheduled email reports.
- Battle-tested bot filtering. Theirs is tuned against real adversarial traffic
and yours is a user-agent list · say so rather than implying parity.
- Fast queries at millions of pageviews. SQLite on one box is honest for a
personal site and dishonest for a business. State the ceiling in the README and
point at self-hosting the real Plausible above it.
### README must contain
- The snippet to paste, with the site_id.
- A plain-language data policy: what is collected, what is derived, what is never
stored, and the fact that the daily salt is deleted.
- The traffic ceiling this build is honest at, as a number.
===== ARCHITECTURE.md =====
# Architecture · Plausible
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | an event is one INSERT; the dashboard is a handful of indexed GROUP BYs |
| Database | SQLite in WAL mode | fine to a few million events; say the ceiling out loud |
| Tracker | Hand-written vanilla JS under 2 KB | if it needs a bundler it is too big |
| Geo | MaxMind GeoLite2 country database, read locally | country from IP without sending the IP anywhere |
| Hosting | A small VPS behind Caddy | the ingest endpoint must be public and on HTTPS |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Identity | the daily salt and the hash | The one module that must not change casually; its test suite is the privacy claim |
| Ingest | /api/event, derivation, bot filter | Any listener writing the same rows |
| Sessionizer | session join/open at write time | Could move to a batch job; the schema stays |
| Dashboard | queries and SVG | Any UI over events, sessions and daily |
| Rollup | retention and daily aggregates | Adjust the window; the daily table is the long-term record |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port; Caddy proxies to it.
- `DATABASE_PATH` · required · SQLite file. Back it up.
- `SITES` · required · Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped.
- `GEOIP_DB_PATH` · optional · The MaxMind file from the prerequisites.
- `RETENTION_DAYS` · optional · Raw events older than this are rolled up and deleted nightly.
- `ADMIN_USER` · required · Any username for the basic-auth admin pages.
- `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password.
- `SITE_URL` · required · Public base URL of the collector, used in the snippet.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · Plausible product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, Hand-written vanilla JS under 2 KB, MaxMind GeoLite2 country database, read locally, A small VPS behind Caddy.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
===== MILESTONES.md =====
# Delivery milestones · Plausible
Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Identity without cookies
The visitor hash and its salt, done exactly right, before anything else exists.
### Steps
1. Create the project and the salts table
salts (day, salt, created_at). Generate 32 random bytes per UTC day on first use.
Files: `server.mjs`, `identity.mjs`
```sh
mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module
mkdir data public && cp .env.example .env
```
2. Implement visitorHash(site, ip, userAgent)
sha256(daily_salt + site_domain + ip + user_agent). Include the domain so the same visitor on two of your sites is two hashes. The ip and user agent exist only inside this function.
3. Rotate and delete
At UTC midnight create tomorrow's salt and delete yesterday's row. Deleting is what makes cross-day re-identification impossible even for you.
### Done when
- [ ] Two calls with the same ip and agent on the same day produce one hash
- [ ] The same pair on two site ids produce two hashes
- [ ] After a forced rotation the hash changes and the old salt row is gone
- [ ] grep of the database file for your own IP returns nothing
## M2 · Ingest endpoint
POST /api/event stores derived facts only and never makes a visitor's page wait.
### Steps
1. Create the events table with its indexes
events (id, site_id, name, path, referrer_host, screen_class, country, browser, os, visitor_hash, session_id, timestamp). Index (site_id, timestamp) and (site_id, path, timestamp).
2. Derive, then discard
Screen class from a width the tracker sends, browser and os from the user agent, country from the GeoLite2 lookup. Strip query strings and hashes from path. Then drop the inputs.
3. Always answer 202 with an empty body
Unknown site ids are accepted and dropped. Malformed bodies get 202 too. Cap the body at 2 KB. An analytics endpoint must leak nothing about itself.
### Done when
- [ ] A hand-crafted curl writes one row with no raw identifiers
- [ ] An unknown site_id returns 202 and stores nothing
- [ ] A malformed body returns 202 without a stack trace in the log
- [ ] Country resolves for a known public IP when GEOIP_DB_PATH is set
## M3 · The tracker
Under 2 KB, no cookies, counts SPA route changes once, honours opt-out.
### Steps
1. Write public/p.js
On load send a pageview with path, referrer and innerWidth via navigator.sendBeacon with a fetch fallback. Hook pushState and popstate for SPA routes. Expose window.plausible-style track(name) for custom events.
2. Respect Do Not Track and a localStorage opt-out flag
If either is set, send nothing at all.
3. Serve it with a long cache header and a version in the filename
p.v1.js; bump on change.
4. Add the snippet to one real site and watch events arrive
```sh
<script defer data-site="yoursite.com" src="https://stats.yourdomain.com/p.v1.js"></script>
```
### Done when
- [ ] The minified file is under 2 KB
- [ ] One page load records exactly one pageview, including under React strict mode
- [ ] An SPA route change records a second pageview
- [ ] With the opt-out flag set nothing is sent
## M4 · Sessions and bounce rate
Sessionize at write time so the dashboard never does it.
### Steps
1. Create the sessions table
sessions (id, site_id, visitor_hash, started_at, last_event_at, entry_path, exit_path, event_count, is_bounce).
2. Join or open a session on each event
If the visitor's last event on this site was under 30 minutes ago, update that session; otherwise open a new one. A session with one pageview is a bounce.
### Done when
- [ ] Two pageviews 5 minutes apart are one session with is_bounce false
- [ ] Two pageviews 31 minutes apart are two sessions, both bouncing
- [ ] Bounce rate matches a hand count on a seeded fixture
## M5 · Bot filtering
Drop obvious bots at ingest and count what you dropped.
### Steps
1. Filter by user agent and impossible screen classes
A short bot list, plus events with no referrer and a zero width.
2. Count filtered events in a separate table
filtered (day, site_id, reason, n). A traffic drop you can explain beats one you guess at.
### Done when
- [ ] A curl with a default user agent is filtered
- [ ] A real browser is not
- [ ] The filtered count is visible on the dashboard
## M6 · Dashboard
Every panel one indexed query; every number reconcilable with SQL.
### Steps
1. Build /dashboard behind basic auth with a site switcher
Unique visitors, pageviews, bounce rate and visit duration for today, 7 and 30 days.
2. Add top pages, referrers, countries, browsers, devices
Each a GROUP BY with a LIMIT.
3. Draw visitors per day as inline SVG
No chart library. Dark mode.
4. Seed 100,000 events and time every panel
```sh
node scripts/seed.mjs 100000
```
### Done when
- [ ] Every number reconciles with a hand-written query on the seeded fixture
- [ ] The page renders in under 200 ms on 100,000 events
- [ ] Switching sites changes every panel
## M7 · Retention and deploy
Old events rolled up, live on your domain, documented with the ceiling stated.
### Steps
1. Write the nightly rollup
daily (day, site_id, visitors, pageviews, bounces) computed from events, then delete events older than RETENTION_DAYS. Charts read daily for old ranges.
2. Add /healthz, systemd, Caddy and a backup command
Files: `deploy/analytics.service`, `Caddyfile`
3. Write the README and the data policy
The snippet, a plain-language policy (collected, derived, never stored, salt deleted daily), and the traffic ceiling as a number.
Files: `README.md`, `PRIVACY.md`
### Done when
- [ ] Retention runs without breaking historical charts
- [ ] A reader goes from clone to a tracked site using only the README
- [ ] PRIVACY.md states that the daily salt is deleted
## M8 · Operate it like a product (production only)
Only for the product-builder path: know when the collector is down, never lose the database, and keep the server patched.
### Steps
1. Add a /healthz endpoint and an external uptime check against it
Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices.
2. Write structured request logs and rotate them
One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight.
3. Back the SQLite file up off the machine nightly and test a restore
SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it.
```sh
sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'"
rclone copy /tmp/app-$(date +%F).db remote:backups/
```
4. Lock the box down
Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure.
### Done when
- [ ] Stopping the service triggers an uptime alert within a few minutes
- [ ] A restore from last night's backup contains yesterday's data
- [ ] A port scan from another machine shows only 22, 80 and 443
===== OPERATIONS.md =====
# Operations · Plausible
## Backup
SQLite .backup nightly; the daily table is small and is the part worth keeping for years.
## Restore
Copy back, start, confirm the dashboard totals for last month match the last known figure.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Uptime on /healthz, and an alert if events per hour drops to zero for a site that normally has traffic.
## Incident checklist
If raw IPs were ever logged by mistake, delete the log, rotate the salt, and record the incident in PRIVACY.md.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] Identity tests pass and the database contains no raw IP or user agent
- [ ] Dashboard under 200 ms at 100,000 events
- [ ] PRIVACY.md published with the ceiling
- [ ] One restore drill performed and dated
## Launch constraint
Do not market omitted Plausible capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file. Back it up.
DATABASE_PATH=./data/analytics.db
# Required. Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped.
SITES=yoursite.com,blog.yoursite.com
# Optional. The MaxMind file from the prerequisites.
GEOIP_DB_PATH=./data/GeoLite2-Country.mmdb
# Optional. Raw events older than this are rolled up and deleted nightly.
RETENTION_DAYS=400
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Required. Public base URL of the collector, used in the snippet.
SITE_URL=https://stats.yourdomain.com
# Plausible · indie build Privacy-first analytics for your own sites: a tracker under 2 KB with no cookies, an ingest endpoint that never stores a raw IP, a daily-rotating visitor hash exactly as Plausible defines it, sessions and bounce rate computed at write time, and a fast dashboard. Honest at personal-site scale, and you can state the ceiling. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | an event is one INSERT; the dashboard is a handful of indexed GROUP BYs | | Database | SQLite in WAL mode | fine to a few million events; say the ceiling out loud | | Tracker | Hand-written vanilla JS under 2 KB | if it needs a bundler it is too big | | Geo | MaxMind GeoLite2 country database, read locally | country from IP without sending the IP anywhere | | Hosting | A small VPS behind Caddy | the ingest endpoint must be public and on HTTPS | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The site (or sites) you want to measure** · free - Why: The tracker is a script tag you add to each site; you need somewhere to add it. - Get it: Write down each site's domain. Each becomes a site_id in SITES. - [ ] **A MaxMind account and licence key for GeoLite2** · free - Why: Country breakdowns need an IP-to-country database. GeoLite2 is free but requires an account to download and to receive updates. - Get it: Sign up at maxmind.com/en/geolite2/signup, then Account > Manage License Keys > Generate new license key. Download GeoLite2-Country.mmdb (or use geoipupdate). Put the file path in .env. - Verify: The .mmdb file exists at GEOIP_DB_PATH - [ ] **Understand the daily-salt rule** · free - Why: The privacy claim rests on one rule: the salt rotates daily and the old one is deleted. If you keep old salts, this is a tracker with extra steps. - Get it: Read Plausible's data policy page once before Phase 1 so the design is yours, not a guess. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. Sites post to it from the visitor's browser, so it must be public. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: The collector needs an HTTPS address, e.g. stats.yourdomain.com. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module mkdir data public && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Funnels, goals, revenue attribution and scheduled email reports. - Bot filtering tuned on adversarial traffic. Yours is a list. - Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible. - battle-tested bot filtering - fast queries at millions of pageviews - email reports, funnels, goal tracking - GDPR homework done for you If one of those is essential to you, that is the reason to keep paying for Plausible, and the README should say so rather than pretend.
# Build brief · Plausible
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a privacy-first web analytics tool like Plausible, at personal scale.
Build it in phases, in the order below. Do not write the whole system in one
pass. Finish a phase, run its "Done when" check, fix what fails, and only then
start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework, one process.
- The tracker is hand-written vanilla JS, no build step, and must stay under 2KB
minified. If it needs a bundler, it is too big.
- The dashboard is server-rendered HTML with inline SVG charts. No chart library.
### Data model (create this before Phase 1)
- `events`: id, site_id, name ('pageview' | custom), path, referrer_host,
screen_class ('mobile' | 'tablet' | 'desktop'), country, browser, os,
visitor_hash, session_id, timestamp
- `sessions`: id, site_id, visitor_hash, started_at, last_event_at, entry_path,
exit_path, event_count, is_bounce
- `salts`: day (date), salt, created_at
Index `events(site_id, timestamp)` and `events(site_id, path, timestamp)`.
Never store a raw IP address or a raw user-agent string in any table. Not in a
column you plan to drop later, not in a log file. This is the one rule that makes
the product what it claims to be.
### Phase 1 · Identity without cookies
Build: the daily-salt visitor hash, exactly as Plausible defines it:
`visitor_hash = hash(daily_salt + site_domain + ip_address + user_agent)`
- Generate a fresh random salt per UTC day.
- Delete the previous day's salt when it rotates. Deleting it is what makes
cross-day re-identification impossible even for you · keeping the old salts
turns this into a tracker with extra steps.
- Include the site domain so the same visitor on two of your sites is two hashes.
- Hash with SHA-256 or better; the IP and user agent exist only inside this
function and are never written anywhere.
Done when: two requests from the same IP and agent on the same day produce one
hash, the same pair on two different sites produce two hashes, the hash changes
after a forced salt rotation, and grepping the database file for your own IP
returns nothing.
Do not build yet: the tracker, sessions, dashboard.
### Phase 2 · Ingest endpoint
Build: `POST /api/event` accepting site_id, name, path (path only · strip query
strings and hashes unless a site explicitly opts into a query allowlist), and
referrer. Derive screen class, browser, os and country server-side, then discard
the inputs. Cap the body at 2KB, validate site_id against a configured list,
respond `202` with an empty body always · an analytics endpoint must never leak
information about itself and must never make a visitor's page wait.
Done when: a hand-crafted curl request writes one row with no raw identifiers
stored, an unknown site_id is silently accepted and dropped, and a malformed body
returns 202 without a stack trace.
### Phase 3 · The tracker
Build: the script · a pageview beacon on load, on `pushState`/`popstate` for SPA
routing, and a custom-event function on `window`. Use `navigator.sendBeacon` with
a fetch fallback. Respect Do Not Track and honour a `localStorage` opt-out flag.
Never read or write a cookie. Ship the file with a long cache header and a
version in the filename.
Done when: the minified file is under 2KB, a page load records exactly one
pageview (not two in development with a strict-mode double render), an SPA route
change records a second, and setting the opt-out flag records nothing at all.
### Phase 4 · Sessions and bounce rate
Build: sessionization · an event joins the open session for its visitor_hash when
the last event was under 30 minutes ago, otherwise it starts a new one. A session
with exactly one pageview is a bounce. Compute this at write time, not in the
dashboard query, and store entry and exit paths.
Done when: two pageviews 5 minutes apart are one session with `is_bounce` false,
two pageviews 31 minutes apart are two sessions both bouncing, and the bounce rate
matches a hand count on a seeded fixture.
### Phase 5 · Bot filtering
Build: a user-agent bot filter applied at ingest, plus a rule dropping events
with no referrer and an impossible screen class. Count what you filter into a
separate table rather than discarding it silently, so a sudden traffic drop can be
explained rather than guessed at.
Done when: a curl with a default user agent is filtered, a real browser is not,
and the filtered count is visible in the dashboard.
### Phase 6 · Dashboard
Build: `/dashboard` behind basic auth from `.env` · unique visitors, pageviews,
bounce rate and visit duration for today, 7 days and 30 days, with top pages, top
referrers, countries, browsers and devices, and a visitors-per-day bar chart as
inline SVG. Add a site switcher. Dark mode. Fast · every panel is one indexed
query, and none of them scans the whole events table.
Done when: every number reconciles with a hand-written SQL query on a seeded
fixture of 100,000 events, and the page renders in under 200ms on that fixture.
### Phase 7 · Retention and deploy
Build: a retention job deleting raw events older than a configurable window while
keeping daily rollups, a `/healthz` endpoint, a nightly backup command, a systemd
unit, and the README.
Done when: retention runs without breaking historical charts, and a reader goes
from clone to a tracked site using only the README.
### Out of scope (and why)
- Funnels, goals, revenue attribution and scheduled email reports.
- Battle-tested bot filtering. Theirs is tuned against real adversarial traffic
and yours is a user-agent list · say so rather than implying parity.
- Fast queries at millions of pageviews. SQLite on one box is honest for a
personal site and dishonest for a business. State the ceiling in the README and
point at self-hosting the real Plausible above it.
### README must contain
- The snippet to paste, with the site_id.
- A plain-language data policy: what is collected, what is derived, what is never
stored, and the fact that the daily salt is deleted.
- The traffic ceiling this build is honest at, as a number.# Agent instructions · Plausible indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, Hand-written vanilla JS under 2 KB, MaxMind GeoLite2 country database, read locally, A small VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
# Build plan · Plausible Privacy-first analytics for your own sites: a tracker under 2 KB with no cookies, an ingest endpoint that never stores a raw IP, a daily-rotating visitor hash exactly as Plausible defines it, sessions and bounce rate computed at write time, and a fast dashboard. Honest at personal-site scale, and you can state the ceiling. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Identity without cookies The visitor hash and its salt, done exactly right, before anything else exists. ### Steps 1. Create the project and the salts table salts (day, salt, created_at). Generate 32 random bytes per UTC day on first use. Files: `server.mjs`, `identity.mjs` ```sh mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module mkdir data public && cp .env.example .env ``` 2. Implement visitorHash(site, ip, userAgent) sha256(daily_salt + site_domain + ip + user_agent). Include the domain so the same visitor on two of your sites is two hashes. The ip and user agent exist only inside this function. 3. Rotate and delete At UTC midnight create tomorrow's salt and delete yesterday's row. Deleting is what makes cross-day re-identification impossible even for you. ### Done when - [ ] Two calls with the same ip and agent on the same day produce one hash - [ ] The same pair on two site ids produce two hashes - [ ] After a forced rotation the hash changes and the old salt row is gone - [ ] grep of the database file for your own IP returns nothing ## Phase 2 · Ingest endpoint POST /api/event stores derived facts only and never makes a visitor's page wait. ### Steps 1. Create the events table with its indexes events (id, site_id, name, path, referrer_host, screen_class, country, browser, os, visitor_hash, session_id, timestamp). Index (site_id, timestamp) and (site_id, path, timestamp). 2. Derive, then discard Screen class from a width the tracker sends, browser and os from the user agent, country from the GeoLite2 lookup. Strip query strings and hashes from path. Then drop the inputs. 3. Always answer 202 with an empty body Unknown site ids are accepted and dropped. Malformed bodies get 202 too. Cap the body at 2 KB. An analytics endpoint must leak nothing about itself. ### Done when - [ ] A hand-crafted curl writes one row with no raw identifiers - [ ] An unknown site_id returns 202 and stores nothing - [ ] A malformed body returns 202 without a stack trace in the log - [ ] Country resolves for a known public IP when GEOIP_DB_PATH is set ## Phase 3 · The tracker Under 2 KB, no cookies, counts SPA route changes once, honours opt-out. ### Steps 1. Write public/p.js On load send a pageview with path, referrer and innerWidth via navigator.sendBeacon with a fetch fallback. Hook pushState and popstate for SPA routes. Expose window.plausible-style track(name) for custom events. 2. Respect Do Not Track and a localStorage opt-out flag If either is set, send nothing at all. 3. Serve it with a long cache header and a version in the filename p.v1.js; bump on change. 4. Add the snippet to one real site and watch events arrive ```sh <script defer data-site="yoursite.com" src="https://stats.yourdomain.com/p.v1.js"></script> ``` ### Done when - [ ] The minified file is under 2 KB - [ ] One page load records exactly one pageview, including under React strict mode - [ ] An SPA route change records a second pageview - [ ] With the opt-out flag set nothing is sent ## Phase 4 · Sessions and bounce rate Sessionize at write time so the dashboard never does it. ### Steps 1. Create the sessions table sessions (id, site_id, visitor_hash, started_at, last_event_at, entry_path, exit_path, event_count, is_bounce). 2. Join or open a session on each event If the visitor's last event on this site was under 30 minutes ago, update that session; otherwise open a new one. A session with one pageview is a bounce. ### Done when - [ ] Two pageviews 5 minutes apart are one session with is_bounce false - [ ] Two pageviews 31 minutes apart are two sessions, both bouncing - [ ] Bounce rate matches a hand count on a seeded fixture ## Phase 5 · Bot filtering Drop obvious bots at ingest and count what you dropped. ### Steps 1. Filter by user agent and impossible screen classes A short bot list, plus events with no referrer and a zero width. 2. Count filtered events in a separate table filtered (day, site_id, reason, n). A traffic drop you can explain beats one you guess at. ### Done when - [ ] A curl with a default user agent is filtered - [ ] A real browser is not - [ ] The filtered count is visible on the dashboard ## Phase 6 · Dashboard Every panel one indexed query; every number reconcilable with SQL. ### Steps 1. Build /dashboard behind basic auth with a site switcher Unique visitors, pageviews, bounce rate and visit duration for today, 7 and 30 days. 2. Add top pages, referrers, countries, browsers, devices Each a GROUP BY with a LIMIT. 3. Draw visitors per day as inline SVG No chart library. Dark mode. 4. Seed 100,000 events and time every panel ```sh node scripts/seed.mjs 100000 ``` ### Done when - [ ] Every number reconciles with a hand-written query on the seeded fixture - [ ] The page renders in under 200 ms on 100,000 events - [ ] Switching sites changes every panel ## Phase 7 · Retention and deploy Old events rolled up, live on your domain, documented with the ceiling stated. ### Steps 1. Write the nightly rollup daily (day, site_id, visitors, pageviews, bounces) computed from events, then delete events older than RETENTION_DAYS. Charts read daily for old ranges. 2. Add /healthz, systemd, Caddy and a backup command Files: `deploy/analytics.service`, `Caddyfile` 3. Write the README and the data policy The snippet, a plain-language policy (collected, derived, never stored, salt deleted daily), and the traffic ceiling as a number. Files: `README.md`, `PRIVACY.md` ### Done when - [ ] Retention runs without breaking historical charts - [ ] A reader goes from clone to a tracked site using only the README - [ ] PRIVACY.md states that the daily salt is deleted ## Not in this build - Funnels, goals, revenue attribution and scheduled email reports. - Bot filtering tuned on adversarial traffic. Yours is a list. - Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible. ## After v1, if you want it - A public shareable dashboard link per site - Goal tracking as a custom event with a conversion panel
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up. DATABASE_PATH=./data/analytics.db # Required. Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped. SITES=yoursite.com,blog.yoursite.com # Optional. The MaxMind file from the prerequisites. GEOIP_DB_PATH=./data/GeoLite2-Country.mmdb # Optional. Raw events older than this are rolled up and deleted nightly. RETENTION_DAYS=400 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string # Required. Public base URL of the collector, used in the snippet. SITE_URL=https://stats.yourdomain.com
# Plausible · product brief ## Problem Basic pageview/event analytics are easy, and Plausible's own code is open source; the paid value is hosted reliability, maintenance, and support. ## Product outcome An analytics collector you can point several small sites at with a privacy policy you can defend, and a stated scale at which you would move to Plausible itself. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted server - database - tracker script - domain/SSL - bot filtering - backups ## Explicit non-goals for v1 - Funnels, goals, revenue attribution and scheduled email reports. - Bot filtering tuned on adversarial traffic. Yours is a list. - Millions of pageviews. SQLite on one box is honest for personal sites; above that, self-host Plausible. - battle-tested bot filtering - fast queries at millions of pageviews - email reports, funnels, goal tracking - GDPR homework done for you ## Success criteria - Identity tests pass and the database contains no raw IP or user agent - Dashboard under 200 ms at 100,000 events - PRIVACY.md published with the ceiling - One restore drill performed and dated
# Build brief · Plausible
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a privacy-first web analytics tool like Plausible, at personal scale.
Build it in phases, in the order below. Do not write the whole system in one
pass. Finish a phase, run its "Done when" check, fix what fails, and only then
start the next phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`, or Python 3.12 with stdlib
`http.server` and `sqlite3`. Pick one, no web framework, one process.
- The tracker is hand-written vanilla JS, no build step, and must stay under 2KB
minified. If it needs a bundler, it is too big.
- The dashboard is server-rendered HTML with inline SVG charts. No chart library.
### Data model (create this before Phase 1)
- `events`: id, site_id, name ('pageview' | custom), path, referrer_host,
screen_class ('mobile' | 'tablet' | 'desktop'), country, browser, os,
visitor_hash, session_id, timestamp
- `sessions`: id, site_id, visitor_hash, started_at, last_event_at, entry_path,
exit_path, event_count, is_bounce
- `salts`: day (date), salt, created_at
Index `events(site_id, timestamp)` and `events(site_id, path, timestamp)`.
Never store a raw IP address or a raw user-agent string in any table. Not in a
column you plan to drop later, not in a log file. This is the one rule that makes
the product what it claims to be.
### Phase 1 · Identity without cookies
Build: the daily-salt visitor hash, exactly as Plausible defines it:
`visitor_hash = hash(daily_salt + site_domain + ip_address + user_agent)`
- Generate a fresh random salt per UTC day.
- Delete the previous day's salt when it rotates. Deleting it is what makes
cross-day re-identification impossible even for you · keeping the old salts
turns this into a tracker with extra steps.
- Include the site domain so the same visitor on two of your sites is two hashes.
- Hash with SHA-256 or better; the IP and user agent exist only inside this
function and are never written anywhere.
Done when: two requests from the same IP and agent on the same day produce one
hash, the same pair on two different sites produce two hashes, the hash changes
after a forced salt rotation, and grepping the database file for your own IP
returns nothing.
Do not build yet: the tracker, sessions, dashboard.
### Phase 2 · Ingest endpoint
Build: `POST /api/event` accepting site_id, name, path (path only · strip query
strings and hashes unless a site explicitly opts into a query allowlist), and
referrer. Derive screen class, browser, os and country server-side, then discard
the inputs. Cap the body at 2KB, validate site_id against a configured list,
respond `202` with an empty body always · an analytics endpoint must never leak
information about itself and must never make a visitor's page wait.
Done when: a hand-crafted curl request writes one row with no raw identifiers
stored, an unknown site_id is silently accepted and dropped, and a malformed body
returns 202 without a stack trace.
### Phase 3 · The tracker
Build: the script · a pageview beacon on load, on `pushState`/`popstate` for SPA
routing, and a custom-event function on `window`. Use `navigator.sendBeacon` with
a fetch fallback. Respect Do Not Track and honour a `localStorage` opt-out flag.
Never read or write a cookie. Ship the file with a long cache header and a
version in the filename.
Done when: the minified file is under 2KB, a page load records exactly one
pageview (not two in development with a strict-mode double render), an SPA route
change records a second, and setting the opt-out flag records nothing at all.
### Phase 4 · Sessions and bounce rate
Build: sessionization · an event joins the open session for its visitor_hash when
the last event was under 30 minutes ago, otherwise it starts a new one. A session
with exactly one pageview is a bounce. Compute this at write time, not in the
dashboard query, and store entry and exit paths.
Done when: two pageviews 5 minutes apart are one session with `is_bounce` false,
two pageviews 31 minutes apart are two sessions both bouncing, and the bounce rate
matches a hand count on a seeded fixture.
### Phase 5 · Bot filtering
Build: a user-agent bot filter applied at ingest, plus a rule dropping events
with no referrer and an impossible screen class. Count what you filter into a
separate table rather than discarding it silently, so a sudden traffic drop can be
explained rather than guessed at.
Done when: a curl with a default user agent is filtered, a real browser is not,
and the filtered count is visible in the dashboard.
### Phase 6 · Dashboard
Build: `/dashboard` behind basic auth from `.env` · unique visitors, pageviews,
bounce rate and visit duration for today, 7 days and 30 days, with top pages, top
referrers, countries, browsers and devices, and a visitors-per-day bar chart as
inline SVG. Add a site switcher. Dark mode. Fast · every panel is one indexed
query, and none of them scans the whole events table.
Done when: every number reconciles with a hand-written SQL query on a seeded
fixture of 100,000 events, and the page renders in under 200ms on that fixture.
### Phase 7 · Retention and deploy
Build: a retention job deleting raw events older than a configurable window while
keeping daily rollups, a `/healthz` endpoint, a nightly backup command, a systemd
unit, and the README.
Done when: retention runs without breaking historical charts, and a reader goes
from clone to a tracked site using only the README.
### Out of scope (and why)
- Funnels, goals, revenue attribution and scheduled email reports.
- Battle-tested bot filtering. Theirs is tuned against real adversarial traffic
and yours is a user-agent list · say so rather than implying parity.
- Fast queries at millions of pageviews. SQLite on one box is honest for a
personal site and dishonest for a business. State the ceiling in the README and
point at self-hosting the real Plausible above it.
### README must contain
- The snippet to paste, with the site_id.
- A plain-language data policy: what is collected, what is derived, what is never
stored, and the fact that the daily salt is deleted.
- The traffic ceiling this build is honest at, as a number.# Architecture · Plausible ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | an event is one INSERT; the dashboard is a handful of indexed GROUP BYs | | Database | SQLite in WAL mode | fine to a few million events; say the ceiling out loud | | Tracker | Hand-written vanilla JS under 2 KB | if it needs a bundler it is too big | | Geo | MaxMind GeoLite2 country database, read locally | country from IP without sending the IP anywhere | | Hosting | A small VPS behind Caddy | the ingest endpoint must be public and on HTTPS | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Identity | the daily salt and the hash | The one module that must not change casually; its test suite is the privacy claim | | Ingest | /api/event, derivation, bot filter | Any listener writing the same rows | | Sessionizer | session join/open at write time | Could move to a batch job; the schema stays | | Dashboard | queries and SVG | Any UI over events, sessions and daily | | Rollup | retention and daily aggregates | Adjust the window; the daily table is the long-term record | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port; Caddy proxies to it. - `DATABASE_PATH` · required · SQLite file. Back it up. - `SITES` · required · Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped. - `GEOIP_DB_PATH` · optional · The MaxMind file from the prerequisites. - `RETENTION_DAYS` · optional · Raw events older than this are rolled up and deleted nightly. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. - `SITE_URL` · required · Public base URL of the collector, used in the snippet. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Plausible product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, SQLite in WAL mode, Hand-written vanilla JS under 2 KB, MaxMind GeoLite2 country database, read locally, A small VPS behind Caddy. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone.
# Delivery milestones · Plausible Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Identity without cookies The visitor hash and its salt, done exactly right, before anything else exists. ### Steps 1. Create the project and the salts table salts (day, salt, created_at). Generate 32 random bytes per UTC day on first use. Files: `server.mjs`, `identity.mjs` ```sh mkdir analytics && cd analytics && git init && npm init -y && npm pkg set type=module mkdir data public && cp .env.example .env ``` 2. Implement visitorHash(site, ip, userAgent) sha256(daily_salt + site_domain + ip + user_agent). Include the domain so the same visitor on two of your sites is two hashes. The ip and user agent exist only inside this function. 3. Rotate and delete At UTC midnight create tomorrow's salt and delete yesterday's row. Deleting is what makes cross-day re-identification impossible even for you. ### Done when - [ ] Two calls with the same ip and agent on the same day produce one hash - [ ] The same pair on two site ids produce two hashes - [ ] After a forced rotation the hash changes and the old salt row is gone - [ ] grep of the database file for your own IP returns nothing ## M2 · Ingest endpoint POST /api/event stores derived facts only and never makes a visitor's page wait. ### Steps 1. Create the events table with its indexes events (id, site_id, name, path, referrer_host, screen_class, country, browser, os, visitor_hash, session_id, timestamp). Index (site_id, timestamp) and (site_id, path, timestamp). 2. Derive, then discard Screen class from a width the tracker sends, browser and os from the user agent, country from the GeoLite2 lookup. Strip query strings and hashes from path. Then drop the inputs. 3. Always answer 202 with an empty body Unknown site ids are accepted and dropped. Malformed bodies get 202 too. Cap the body at 2 KB. An analytics endpoint must leak nothing about itself. ### Done when - [ ] A hand-crafted curl writes one row with no raw identifiers - [ ] An unknown site_id returns 202 and stores nothing - [ ] A malformed body returns 202 without a stack trace in the log - [ ] Country resolves for a known public IP when GEOIP_DB_PATH is set ## M3 · The tracker Under 2 KB, no cookies, counts SPA route changes once, honours opt-out. ### Steps 1. Write public/p.js On load send a pageview with path, referrer and innerWidth via navigator.sendBeacon with a fetch fallback. Hook pushState and popstate for SPA routes. Expose window.plausible-style track(name) for custom events. 2. Respect Do Not Track and a localStorage opt-out flag If either is set, send nothing at all. 3. Serve it with a long cache header and a version in the filename p.v1.js; bump on change. 4. Add the snippet to one real site and watch events arrive ```sh <script defer data-site="yoursite.com" src="https://stats.yourdomain.com/p.v1.js"></script> ``` ### Done when - [ ] The minified file is under 2 KB - [ ] One page load records exactly one pageview, including under React strict mode - [ ] An SPA route change records a second pageview - [ ] With the opt-out flag set nothing is sent ## M4 · Sessions and bounce rate Sessionize at write time so the dashboard never does it. ### Steps 1. Create the sessions table sessions (id, site_id, visitor_hash, started_at, last_event_at, entry_path, exit_path, event_count, is_bounce). 2. Join or open a session on each event If the visitor's last event on this site was under 30 minutes ago, update that session; otherwise open a new one. A session with one pageview is a bounce. ### Done when - [ ] Two pageviews 5 minutes apart are one session with is_bounce false - [ ] Two pageviews 31 minutes apart are two sessions, both bouncing - [ ] Bounce rate matches a hand count on a seeded fixture ## M5 · Bot filtering Drop obvious bots at ingest and count what you dropped. ### Steps 1. Filter by user agent and impossible screen classes A short bot list, plus events with no referrer and a zero width. 2. Count filtered events in a separate table filtered (day, site_id, reason, n). A traffic drop you can explain beats one you guess at. ### Done when - [ ] A curl with a default user agent is filtered - [ ] A real browser is not - [ ] The filtered count is visible on the dashboard ## M6 · Dashboard Every panel one indexed query; every number reconcilable with SQL. ### Steps 1. Build /dashboard behind basic auth with a site switcher Unique visitors, pageviews, bounce rate and visit duration for today, 7 and 30 days. 2. Add top pages, referrers, countries, browsers, devices Each a GROUP BY with a LIMIT. 3. Draw visitors per day as inline SVG No chart library. Dark mode. 4. Seed 100,000 events and time every panel ```sh node scripts/seed.mjs 100000 ``` ### Done when - [ ] Every number reconciles with a hand-written query on the seeded fixture - [ ] The page renders in under 200 ms on 100,000 events - [ ] Switching sites changes every panel ## M7 · Retention and deploy Old events rolled up, live on your domain, documented with the ceiling stated. ### Steps 1. Write the nightly rollup daily (day, site_id, visitors, pageviews, bounces) computed from events, then delete events older than RETENTION_DAYS. Charts read daily for old ranges. 2. Add /healthz, systemd, Caddy and a backup command Files: `deploy/analytics.service`, `Caddyfile` 3. Write the README and the data policy The snippet, a plain-language policy (collected, derived, never stored, salt deleted daily), and the traffic ceiling as a number. Files: `README.md`, `PRIVACY.md` ### Done when - [ ] Retention runs without breaking historical charts - [ ] A reader goes from clone to a tracked site using only the README - [ ] PRIVACY.md states that the daily salt is deleted ## M8 · Operate it like a product (production only) Only for the product-builder path: know when the collector is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443
# Operations · Plausible ## Backup SQLite .backup nightly; the daily table is small and is the part worth keeping for years. ## Restore Copy back, start, confirm the dashboard totals for last month match the last known figure. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime on /healthz, and an alert if events per hour drops to zero for a site that normally has traffic. ## Incident checklist If raw IPs were ever logged by mistake, delete the log, rotate the salt, and record the incident in PRIVACY.md. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Identity tests pass and the database contains no raw IP or user agent - [ ] Dashboard under 200 ms at 100,000 events - [ ] PRIVACY.md published with the ceiling - [ ] One restore drill performed and dated ## Launch constraint Do not market omitted Plausible capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. Back it up. DATABASE_PATH=./data/analytics.db # Required. Comma-separated site ids accepted by the ingest endpoint. Anything else is dropped. SITES=yoursite.com,blog.yoursite.com # Optional. The MaxMind file from the prerequisites. GEOIP_DB_PATH=./data/GeoLite2-Country.mmdb # Optional. Raw events older than this are rolled up and deleted nightly. RETENTION_DAYS=400 # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string # Required. Public base URL of the collector, used in the snippet. SITE_URL=https://stats.yourdomain.com
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay because analytics must be boring, fast, and legally safer without ops.
xbattle-tested bot filtering
xfast queries at millions of pageviews
xemail reports, funnels, goal tracking
xGDPR homework done for you
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Plausible →· no votes, no pay-to-list · just what's real
Plausible pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| community edition (self-hosted) | $0/workspace | $0/workspace | Free software with no vendor-set site or traffic cap; hosting/database/operations are user-supplied. |
| starter (up to 10k monthly pageviews/events) | $9/workspace | $7.50/workspace | 1 website, 3 years of data retention and up to 10,000 combined pageviews/custom events per month. |
| growth (up to 10k monthly pageviews/events) | $14/workspace | $11.67/workspace | 3 websites, 3 team members, 3 years of retention and up to 10,000 combined pageviews/custom events per month. |
| business (up to 10k monthly pageviews/events) | $19/workspace | $15.83/workspace | 10 websites, 10 team members, 5 years of retention, 600 Stats API requests/hour and up to 10,000 combined pageviews/custom events per month. |
| enterprise | custom | — | Custom traffic; 10+ sites, 10+ team members, 600+ API requests/hour and 5+ years retention. |
free tierno free hosted plan; 30-day no-card trial; Community Edition is free to self-host
billingmonthly + annual (2 months free); hosted price scales by combined pageviews and custom events across all sites
hidden costsTraffic brackets jump from 10,000 to 100,000 monthly events/pageviews. Separate Plausible teams are billed separately. After 2 consecutive over-limit months, Plausible asks for an upgrade and can lock dashboard access after a further week while collection continues; it does not publish an immediate per-event overage fee.
verified 2026-08-11 · source ↗
Is Plausible free?
No free plan, just a 30-day trial; the open source version is free to self-host. Paid is Starter at $9/mo (checked 2026-08-07).
Vibecode Plausible
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Plausible replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Plausible cost?
Plausible costs about $9/month (Starter, checked 2026-08-07), which is $108 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Plausible?
Honestly: battle-tested bot filtering; fast queries at millions of pageviews; email reports, funnels, goal tracking; GDPR homework done for you. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Plausible?
Yes: Umami (The closest broad substitute: simple traffic analytics, events and goals with your data attached.) Plausible Community Edition (Plausible without the hosted bill; you keep the core dashboard and inherit the Compose stack.) Rybbit (Plausible's core plus replay and funnels, in exchange for a busier dashboard.) All 4 curated free alternatives are at vibecodeit.com/plausible/alternatives. The prompt is for when you want it exactly your way.