JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
Next

Zenith — Self-Hosted, Cookieless Analytics for Every Site You Manage (Complete Guide)

The complete guide to Zenith, my open-source multi-site analytics and SEO platform. Deploy it once with Docker Compose, install the zenith-analytics npm package in each Next.js site, and give every client a password-protected analytics dashboard on their OWN domain — no cookies, no consent banner, no per-site SaaS bill. Covers deployment, the two-key security model, the domain-native dashboard proxy, custom events, headless-Chromium SEO audits, automatic monthly email reports, and the daily-rotating visitor hash that makes it privacy-first. The site you're reading is tracked by it.

Zenith — Self-Hosted, Cookieless Analytics for Every Site You Manage

By JB (Muke Johnbaptist) · July 2026 — the full setup guide for Zenith, my open-source analytics and SEO platform. And a disclosure that doubles as a demo: the site you are reading right now is tracked by Zenith. Every pageview on jb.desishub.com flows through exactly the setup described below.

The problem: you manage five sites, not one

If you're a freelancer or run a small agency, your analytics situation probably looks like mine did:

  • Google Analytics on everything — which means a cookie consent banner on every site, clients confused by GA4's interface, and data living on Google's servers.
  • Or a privacy SaaS (Plausible, Fathom) — lovely products, but they charge per site. Ten client sites is a real monthly bill, forever, for something that is fundamentally a counter.
  • And either way, when a client asks "how's my traffic?", you screenshot a dashboard from a domain they've never heard of, or create yet another account for them on a tool they'll log into twice.

Zenith is my answer to all three at once:

  • Self-hosted, deploy once. One Docker Compose stack on a VPS you already have. Every site you manage reports into it. Site number eleven costs you nothing.
  • Cookieless by construction. No cookies, no fingerprinting, no persistent identifier — so no consent banner. (The "how" is genuinely interesting; there's a whole section on it below.)
  • The domain-native dashboard — the part nobody else does. Your client reads their analytics at theirsite.com/analytics-dashboard, password-protected, looking like a page of their own site. They never visit a Zenith URL, never make an account, and never learn Zenith exists.

Plus two things that turn it from a counter into a service you can sell: on-demand SEO audits (real headless Chromium, Core Web Vitals included) and automatic monthly email reports to each site owner.

The architecture in one picture

your sites                        your VPS
─────────────                     ──────────────────────────────────────
site-a.com  ──┐                   ┌───────────────────────────────────┐
site-b.com  ──┼── events ───────► │  core (Go, ~200MB)                │
client-c.com ─┘   /api/collect    │   ingestion · stats API · auth    │
                                  │   scheduler · console (React SPA) │
      ▲                           │                                   │
      │ password-protected        │   DuckDB (events)                 │
      │ dashboard proxied         │   SQLite (users, sites, settings) │
      │ onto each domain          └────────────────┬──────────────────┘
      │                                            │ audit jobs
client's browser                  ┌────────────────▼──────────────────┐
                                  │  audit-worker (Go + Chromium, ~1GB)│
                                  │   headless SEO audits              │
                                  └────────────────────────────────────┘

Two deliberate decisions worth knowing before you deploy:

  • Two services, not one. Chromium is about 1GB, and an out-of-memory audit must never be able to take analytics ingestion down with it. Skip the audit worker entirely and everything else still works.
  • Two stores, not one. DuckDB is columnar and eats GROUP BY over large event volumes for breakfast; SQLite is transactional and holds users, sites, and settings. Events never go in SQLite; app data never goes in DuckDB.

Part 1 — Deploy the platform

Prerequisite: a VPS with Docker, and a reverse proxy for TLS (you need one anyway — more in the safety section).

git clone https://github.com/MUKE-coder/zenith.git
cd zenith
cp .env.example .env

Fill in two things — a signing secret and your admin account:

# .env
ZENITH_JWT_SECRET=          # openssl rand -base64 32
ZENITH_ADMIN_EMAIL=you@example.com
ZENITH_ADMIN_PASSWORD=      # 12+ characters

There is no default secret. Zenith refuses to start without one, because a fallback secret would be the key signing every token in every deployment that forgot to change it. Keep it stable — changing it signs everyone out (which is also how you sign everyone out if it ever leaks).

cd deploy
docker compose --env-file ../.env up                  # core only
docker compose --env-file ../.env --profile seo up    # with SEO audits

Check it's alive:

curl http://localhost:8080/health
# {"status":"ok"}

The console is at http://localhost:8080/dashboard/ — sign in with the admin credentials from .env.

Put TLS in front with your reverse proxy of choice. Caddy is two lines:

zenith.example.com {
    reverse_proxy localhost:8080
}

Mine lives at zenith.gritframework.dev, behind exactly this kind of proxy.

The one thing to back up: everything stateful lives in the zenith-data Docker volume — both databases. docker compose down keeps it; down -v destroys it. Snapshot that volume and you've backed up the whole platform.

Part 2 — Add a site and meet the two keys

In the console, click Add site (or use the API):

curl -X POST https://zenith.example.com/api/sites \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Client Site","domain":"client.com","owner_email":"owner@client.com"}'

You get back two keys, and the split is the security model in miniature:

KeyVisibilityAuthorizes
site_keyPublic — ships in the snippetWriting events only
api_keySecret — server-side onlyReading that one site's analytics

Writing events and reading analytics have completely different threat models, so they get different keys. Treat the site_key as readable by anyone — it's in the page source of every tracked site. The worst a leaked one can do is send junk events to that one site; it can never read anyone's traffic. The api_key belongs on the server and must never reach a browser.

Domains are normalized (https://www.Client.com/ and client.com are the same site), which is what keeps the self-referral filter working.

Part 3 — Install tracking in a Next.js site

This is the zenith-analytics npm package — about 1KB of inlined snippet on the wire.

cd their-nextjs-project
npm install zenith-analytics
npx zenith init      # writes zenith.config.js, scaffolds the dashboard route
npx zenith hash      # prints a bcrypt hash for the dashboard password

zenith init also adds zenith.config.js to .gitignore, because it holds two secrets. Fill in the keys from Add site:

// zenith.config.js
module.exports = {
  backendUrl: "https://zenith.example.com",
  siteKey: "zk_...", // public — ships in the snippet
  apiKey: process.env.ZENITH_API_KEY, // secret — server-side only
  dashboardPath: "/analytics-dashboard",
  protected: true,
  passwordHash: process.env.ZENITH_PW_HASH, // from `npx zenith hash`
  jwtSecret: process.env.ZENITH_JWT_SECRET,
  siteDomain: "client.com",
};

Then the whole tracking integration is one component in the root layout:

import { Analytics } from "zenith-analytics/next";
import config from "../zenith.config.js";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics config={config} />
      </body>
    </html>
  );
}

That's it. Pageviews — including client-side route changes — are recorded from then on. Load a page, open the console: the live counter updates every 15 seconds.

One rule matters here: render it on the server, which a layout already does. Two reasons. First, the snippet is inlined into the HTML and finds its configuration through document.currentScript — a script inserted later by client-side React would never run. Second, the config holds apiKey and jwtSecret; inside a "use client" component, React would serialize every field into the browser payload. The component itself reads only backendUrl and siteKey, so rendered server-side, no secret can leak by construction.

How this site does it

On jb.desishub.com I keep the config in a typed TypeScript module instead of zenith.config.js, with the two public values in code and the three secrets from the environment — and I gate the component on NODE_ENV so my dev sessions don't pollute the stats:

// src/config/zenith.ts — public values are safe in the repo by design
export const ZENITH_PUBLIC = {
  backendUrl: "https://zenith.gritframework.dev",
  siteKey: "zk_...",
};
 
// src/app/layout.tsx
{
  process.env.NODE_ENV === "production" && <Analytics config={ZENITH_PUBLIC} />;
}

View source on this page and you'll find the snippet with its data-site-key — the public key doing exactly what public keys do.

Not using Next.js?

The core is framework-agnostic. trackerScriptProps(config) returns the script-tag props for any React app, and trackerScriptTag(config) returns plain HTML for everything else. The snippet itself is dependency-free JavaScript: it uses sendBeacon so a visitor who clicks away immediately still counts, and it patches history.pushState so SPA route changes are tracked.

Part 4 — Custom events

Pageviews tell you traffic; custom events tell you what worked.

import { track } from "zenith-analytics/client";
 
track("signup", { plan: "pro" });

zenith-analytics/client is browser-safe: it holds no secrets and never throws — a failed analytics call can't break the page it measures. Calls made before the snippet loads are queued and sent once it does, so a click handler that fires during hydration isn't silently dropped.

On this site, the contact buttons on my booking page fire track("book_click", { cta: "whatsapp" }) and friends — so I can see which contact method actually converts, not just that the page was visited. In the console, the events view breaks each event down by its properties.

Part 5 — The domain-native dashboard

This is Zenith's signature move. npx zenith init scaffolded a route file; in the App Router it looks like this:

// app/analytics-dashboard/[[...zenith]]/route.ts
import { createZenithRoute } from "zenith-analytics/next";
import config from "../../../zenith.config.js";
 
export const { GET, POST } = createZenithRoute(config);
export const dynamic = "force-dynamic";

(force-dynamic matters: without it Next may statically render the route at build time and serve every visitor the same cached page — which for a password gate would mean serving one client's dashboard to whoever asks.)

Now client.com/analytics-dashboard asks for a password and shows their analytics. Here's what's actually happening:

Owner's browser ──► theirsite.com/analytics-dashboard   (their domain, first-party cookie)
                          │  the proxy runs inside their app
                          │  password gate → signed HttpOnly cookie
                          ▼
                    Zenith service          (server-to-server, X-Zenith-API-Key)

Every data request the page makes is same-origin against their own server. The proxy re-authenticates it, then forwards it to Zenith with the site's secret api_key attached server-side. The browser never sees the key — and because the key itself names the site, a tampered ?site= parameter changes nothing. The password is verified against the bcrypt hash in the config; Zenith itself never learns it.

The client handoff

You send your client two lines:

Your analytics are at https://theirsite.com/analytics-dashboard Password: (whatever you hashed with npx zenith hash)

That's the entire handoff. What they get: a read-only page on their own domain, showing only their site — no settings to break, no site switcher to suggest other clients exist, no Zenith branding, no account. A session lasts 12 hours by default (sessionTtl).

To change their password, run npx zenith hash again and swap the hash. And yes — this site has one at /zenith. You'll hit the password gate, which is rather the point.

Part 6 — SEO audits

Optional, off by default, and the reason the audit worker is a separate service:

cd deploy
docker compose --profile seo up

Click Run audit in the console. The worker reads the site's sitemap.xml, renders each page in a real headless Chromium, and reports:

  • On-page — title, meta description, H1s, heading order, image alt text, canonical, robots.
  • Broken links — internal and outbound, with the URLs and what they returned.
  • Structured data — JSON-LD presence and validity.
  • Core Web Vitals from the actual render — LCP, FCP, TTFB, CLS.

Each page gets a score out of 100 (errors cost more than warnings); the site score is the average. Audits never block — enqueueing returns in milliseconds and the console polls. A crashed worker doesn't strand a job: anything left running is requeued on worker start and every five minutes after.

If you read my Search Console cleanup post, you know how much of SEO is just finding the broken things. An audit per client per month, attached to a maintenance contract, is exactly the kind of recurring service this enables.

Part 7 — Monthly reports on autopilot

Each site owner gets last month's numbers emailed on the 1st at 09:00 UTC, automatically — the scheduler is built into core, no cron to configure. Setup, once, in Settings:

  1. A Resend API key — stored server-side and never returned to the browser again, not even masked.
  2. A verified send-from address.
  3. An owner email per site (clear it to stop that site's report).

Send test delivers a report immediately so you can see exactly what your client will see — and deliberately doesn't record the send, so the real report still goes out on the 1st. Sends are idempotent by construction: a UNIQUE (site_id, period) constraint means a restart on the 1st can't mail a client twice, and a failed month is retried with the failure reason (Resend's own words) surfaced in Settings.

For a lot of clients, this email is the product. They never open the dashboard at all — and that's fine.

How the privacy actually works

"Privacy-first" is usually a slogan. Here it's a specific mechanism:

visitor_hash = HMAC-SHA256(daily_salt, date | site_id | ip | user_agent)

The same person on the same site on the same day hashes to the same value — so they're counted once. But the salt lives in memory only and rotates at UTC midnight. It's never written to disk, a log, or either database. That's what makes yesterday's visitor unlinkable to today's: the key that would prove they're the same person no longer exists anywhere.

The raw IP and user agent are used to derive that hash and a coarse country/device/browser bucket, then dropped. There is no IP column in the schema — and a test enforces that one can't be added. site_id is inside the hash, so the same person on two of your sites can't be linked across them. Referrers are stored as hostnames only, never full URLs, because referring URLs routinely carry private context in their query strings.

The honest trade: restarting core starts a new salt, so a visitor already counted today may be counted once more. Deliberate. Over-counting a few visitors is a rounding error; a salt recoverable from disk would be a permanent way to de-anonymize everyone in the database.

No cookies, no fingerprinting, no persistent identifier — no consent banner.

The stats API, if you'd rather build your own views

Everything the console shows comes from a plain JSON API — useful if you want client stats inside your own product (say, a Module-11-style dashboard from my C++ course):

EndpointAnswers
GET /api/stats/summaryPageviews, unique visitors, sessions
GET /api/stats/timeseriesTraffic over time, bucketed
GET /api/stats/pagesTop pages, plus entry and exit pages
GET /api/stats/referrersReferral sources and the UTM breakdown
GET /api/stats/geoCountries
GET /api/stats/techDevices, browsers, operating systems
GET /api/stats/eventsCustom events; ?name= adds its properties
GET /api/stats/realtimeVisitors active in the last 5 minutes
curl -H "Authorization: Bearer $TOKEN" \
  "https://zenith.example.com/api/stats/summary?site=<id>&from=2026-07-01&to=2026-07-31&compare=true"

Nice details: compare=true adds the equal-length previous period and the percent change (null when the previous period was zero — growth from nothing has no percentage). Sessions aren't stored, they're derived: a run of one visitor's events with no gap longer than 30 minutes. Empty timeseries buckets come back as zeroes, so a quiet day shows as a dip instead of vanishing from the chart.

Running it safely — the three rules

  1. Put it behind HTTPS. Zenith doesn't terminate TLS — that's your reverse proxy's job. It reads X-Forwarded-Proto and X-Forwarded-For, so a standard proxy setup works unchanged.
  2. Don't expose it directly to the internet. It trusts X-Forwarded-For (it has to — behind a proxy, every visitor would otherwise appear to come from the proxy). Directly reachable, anyone can forge that header and slip past the per-IP rate limit on /api/collect. Worst case is junk in one site's numbers, not a data leak — but put a proxy in front. You need one for TLS anyway.
  3. Back up the zenith-data volume. It's the only stateful thing in the deployment.

What Zenith enforces for you, so you don't have to: bcrypt passwords (12+ chars, never logged, never returned), HS256 sessions with alg=none rejected and immediate logout revocation, a Resend key that's never returned once saved, site scoping where an owner's site comes from their signed token and never a URL parameter, per-IP rate limiting on collection, and body caps with unknown-field rejection on every endpoint. There's even a test that drives every flow at debug log level and asserts no secret leaks into the logs.

Zenith vs. the alternatives, honestly

  • vs. Google Analytics: no consent banner, no Google, and your client can actually read the dashboard. GA4 is free and does far more — if you need funnels, audiences, and ads integration, use GA4.
  • vs. Plausible/Fathom: philosophically siblings — cookieless, simple, privacy-first. The differences: Zenith is yours (one VPS, unlimited sites, no per-site pricing), and the domain-native client dashboard + SEO audits + monthly reports are built for the "I manage sites for clients" workflow specifically.
  • vs. self-hosting Plausible/Umami: entirely reasonable choices! Zenith's pitch is the client handoff story — a dashboard on their domain with no account, plus audits and reports in the same stack. If you're only tracking your own sites, the gap is smaller.

Start here

git clone https://github.com/MUKE-coder/zenith.git
cd zenith && cp .env.example .env
# fill in ZENITH_JWT_SECRET, ZENITH_ADMIN_EMAIL, ZENITH_ADMIN_PASSWORD
cd deploy && docker compose --env-file ../.env up
  • Platform (open source, MIT): github.com/MUKE-coder/zenith — v1 is done, with 314 tests across the two Go services, the npm package, and the dashboard, and every claim in this post verified end-to-end against a live docker compose up.
  • The npm package: zenith-analytics — also on my packages page.
  • Living proof: the page you just read sent a cookieless pageview to a Zenith instance the moment you opened it. No banner asked you anything. That's the product.

Questions, bugs, ideas? Open an issue or book a session — happy to help you get your first deployment live.