Privacy-first web analytics for indie blogs — self-host free (Docker anywhere) or use the managed cloud on the main site.
Cookieless pageviews · ScifiUI console · SQLite or Postgres · Docker-ready.
| Deploy | Who | STATSMAN_MODE |
STATSMAN_BILLING |
What / does |
|---|---|---|---|---|
| Self-host (any Docker host) | You run your own box | selfhost |
— | Login → dashboard (no marketing, no Stripe) |
| Cloud beta (Railway + Supabase) | Free product site | cloud |
beta |
Landing + Supabase Auth, no Stripe |
| Cloud paid (Railway + Supabase) | Paying customers | cloud |
normal |
Landing, email/password, plans, billing |
Two modes: selfhost (free, unlimited) or cloud (marketing + Supabase Auth). On cloud, STATSMAN_BILLING=beta (free, default) or normal (Starter/Indie/Creator via Stripe). Never set cloud on a customer's Docker box — that forces SaaS login and plan caps.
Same codebase. Cloud customers sign up on the main site. Self-host customers follow /self-host.
Local end-to-end regression tests use Chromium, a throwaway Statsman database, and real builds of the Come & Terry and TilBlog repositories:
# Once
npm run e2e:install
# Build Statsman + both blogs, then run the complete suite
npm run test:e2eThe blog repositories default to ../Come&Terry and ../TilBlog-TodayILearned and must already have their dependencies installed. Override them with E2E_BLOG_CT_DIR and E2E_BLOG_TIL_DIR. Set E2E_REBUILD_BLOGS=1 to force both cached blog fixtures to rebuild.
| Self-host (OSS) | Cloud (on main site) | |
|---|---|---|
| Storage | Docker volume (SQLite) or Postgres (DATABASE_URL) |
Postgres (Supabase) |
| Auth | Optional STATSMAN_ADMIN_TOKEN |
Supabase Auth (email + password) |
| Limits | Unlimited (your machine) | Founder seat unlimited · else Starter $3 / Indie $9 / Creator $19 |
| Deploy | Any Docker host | Railway (app) + Supabase (DB) |
cp .env.example .env
npm install
npm run devOpen http://localhost:5173. For local marketing UI set STATSMAN_MODE=cloud. Pure app UX: selfhost.
Step-by-step: /self-host. Full guide: SELF_HOSTING.md.
docker compose up -d --build
# or deploy the Dockerfile on Railway / Render / your VPSSTATSMAN_MODE=selfhost
PUBLIC_ORIGIN=https://YOUR_PUBLIC_HTTPS_URL
# SQLite (volume) — or Postgres:
# DATABASE_URL=postgres://...
STATSMAN_ADMIN_TOKEN=...
STATSMAN_SESSION_SECRET=...<script defer src="https://YOUR_PUBLIC_HTTPS_URL/tracker.js" data-site="SITE_ID"></script>Paste into WordPress/Ghost custom code settings.
Needs a long-running Node container (
adapter-node). Typical Vercel serverless is a poor fit unless you change adapters. Any Postgres (Supabase, Neon, RDS, …) works viaDATABASE_URL.
In npm run dev, a Demo Site is auto-created (domain localhost): the landing page tracks itself into it, and a fake blog lives at /demo — every click fires a real event. The demo site is backfilled with ~30 days of synthetic traffic so the dashboard looks alive on first boot.
| Env | Default | Purpose |
|---|---|---|
STATSMAN_DEMO |
on in dev, off in prod | Enable/disable /demo + dogfooding. Set 1 to ship the demo to prod, 0 to mute it in dev. |
STATSMAN_DEMO_SEED |
on in dev, off in prod | Backfill synthetic demo traffic (no-op once the site is busy). |
Two billing tiers, controlled by STATSMAN_BILLING (selfhost is always free regardless):
beta(default) — cloud is free, one tier (1 site, 3k views/mo). No Stripe UI/caps.normal— Starter ($3) / Indie ($9) / Creator ($19) plans via Stripe. Requires Stripe keys + prices.
Paid track (normal): sign up (Supabase Auth email + password) → subscribe on /subscribe (Stripe Payment Element) → manage on /billing (plan change, cancel, update card). No subscription = no sites.
- Supabase — create a project; copy the Postgres connection (URL or
PG*vars). Prefer the pooled host for the app. - Supabase Auth — Authentication → Providers → Email on. URL config:
- Site URL =
PUBLIC_ORIGIN(e.g.https://statsman.xyz) - Redirect URLs:
{PUBLIC_ORIGIN}/auth/callback,{PUBLIC_ORIGIN}/auth/reset - Redirect allow-list must include
/auth/callback(and/auth/reset). Default confirm links put tokens in the URL hash — Statsman reads those in the browser. - Branded emails — enable custom SMTP (Resend:
smtp.resend.com/ port465/ userresend/ pass = API key), then paste HTML fromemail-templates/into Authentication → Email → Templates. See that folder’s README.
- Site URL =
- Railway — new service from this repo (uses
Dockerfile+railway.toml). Set env:
STATSMAN_MODE=cloud
PUBLIC_ORIGIN=https://statsman.xyz
ORIGIN=https://statsman.xyz
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=host
ADDRESS_HEADER=x-forwarded-for
STATSMAN_SESSION_SECRET=long-random-string
DATABASE_URL=postgres://... # or PGHOST/PGUSER/PGPASSWORD/...
SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
SUPABASE_PUBLISHABLE_KEY=eyJ... # anon / publishable key (Auth only; RLS not used for app DB)
# Billing tier — `beta` (default, free cloud) or `normal` (Starter/Indie/Creator via Stripe):
# STATSMAN_BILLING=normal
STATSMAN_FOUNDER_EMAILS=you@example.com # comma-separated; these emails get the Founder seat (unlimited, no Stripe)
# When STATSMAN_BILLING=normal (Stripe keys required):
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_INDIE=price_...
STRIPE_PRICE_CREATOR=price_...- Domain — attach
statsman.xyzon Railway; setPUBLIC_ORIGIN/ORIGINtohttps://statsman.xyz. Non-canonical hosts (e.g.*.up.railway.app) 301 to that origin (except/api/health). Point Supabase Auth Site URL + redirects at the same host. - Stripe (when
STATSMAN_BILLING=normal)- Create Starter ($3) + Indie ($9) + Creator ($19) recurring prices; paste IDs into
STRIPE_PRICE_*. - Webhook endpoint:
https://statsman.xyz/api/billing/webhook - Events:
customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_succeeded,invoice_payment.paid - Copy the endpoint signing secret →
STRIPE_WEBHOOK_SECRET
- Create Starter ($3) + Indie ($9) + Creator ($19) recurring prices; paste IDs into
- Smoke —
GET /api/healthshould show"authReady": true(and"billingReady": truewhenSTATSMAN_BILLING=normal). Sign up → confirm email if required → log in → open console.
Do not mix test and live keys/prices/webhook secrets.
Local cloud smoke (test mode):
STATSMAN_MODE=cloud
# test keys + prices in .env
stripe listen --forward-to localhost:5173/api/billing/webhookStripe webhook: POST /api/billing/webhook. Plan promotion happens on paid invoice events (not on bare subscription.updated).
Flow: Pricing / Settings → signup or login (next preserved through Auth redirects) → /subscribe?plan=indie|creator → dashboard → /billing to change plan, cancel, or update card.
Two special cases get unlimited use, then three paid plans for everyone else.
Special cases (unlimited, no Stripe):
| Plan | Sites | Pageviews / mo | Price | How |
|---|---|---|---|---|
| Self-host | practical unlimited | practical unlimited | $0 | Run the Docker box yourself (STATSMAN_MODE=selfhost) — follow /self-host |
| Founder | practical unlimited | practical unlimited | $0 | Operator seat on your own cloud — list your email in STATSMAN_FOUNDER_EMAILS |
Paid plans (cloud, via Stripe when STATSMAN_BILLING=normal):
| Plan | Sites | Pageviews / mo | Price |
|---|---|---|---|
| Starter | 1 | 3,000 | $3 |
| Indie | 3 | 100,000 | $9 |
| Creator | 10 | 1,000,000 | $19 |
Self-host and Founder are complimentary seats — Stripe webhooks never demote them.
Over-cap ingest returns 204 (blogs stay green); dashboard shows an upgrade banner.
- Domain allowlist on
/api/eventwhenOrigin/Refereris present (site domain must match the blog host) - Traffic exclusions — browser opt-out (
statsman_optout/statsman.disableTracking()), localhost/dev host ignore (default on), optional per-site excluded IPs (filter-only; never stored on events) - CSRF origin check disabled for tracker beacons (
text/plaincross-origin POSTs); allowlist above is the gate - Site ownership in cloud (users → sites)
- Optional
STATSMAN_ADMIN_TOKENlocks self-host dashboard + site CRUD - Tracker +
/api/eventstay public
- SvelteKit +
@sveltejs/adapter-node - Vendored
vendor/scifiui(@scifiui/core) - GSAP helpers via ScifiUI · Three.js landing field
better-sqlite3/postgres· Stripe · Resend- Dockerfile +
docker-compose.yml
<script defer src="https://YOUR_STATSMAN/tracker.js" data-site="TILBLOG_SITE_ID"></script>MIT