Setup

End to end, this takes about fifteen minutes. You need a Linux server with Docker, and a domain you can point at it.

Clone the repository

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

Everything you deploy is in here: the core service, the optional SEO worker, and the compose files under deploy/.

Create your .env

cp .env.example .env

Fill in three values:

# Signs every session token. 32+ characters, no default.
# Generate with: openssl rand -base64 32
ZENITH_JWT_SECRET=

# The account you'll sign in with, created on first boot.
ZENITH_ADMIN_EMAIL=you@example.com
ZENITH_ADMIN_PASSWORD=       # 6+ characters

Everything else has a working default. The full list is in Configuration.

There is no fallback signing secret — Zenith refuses to start without ZENITH_JWT_SECRET, because a default would be the key signing every token in every deployment that forgot to change it. Keep it stable: changing it signs everyone out.

Point a domain at the server

Add an A record for the hostname you'll use — say zenith.example.com — pointing at your server's public IP. Do this before deploying, so the certificate can be issued on the first try.

Deploy

On Dokploy (or Coolify, or any platform that runs a compose file), create a Compose service from your fork of the repo and set:

SettingValue
Compose Path./deploy/docker-compose.dokploy.yml
Environmentthe three variables from step 2
Domainyour hostname → service core, port 8080, HTTPS, letsencrypt

That compose file publishes no host port on purpose: the platform's own reverse proxy reaches the container over its internal network, which is also what keeps core off the public internet.

On a plain Docker host, run it yourself:

cd deploy
docker compose --env-file ../.env up -d --build

Add -f docker-compose.caddy.yml alongside it for automatic HTTPS, or put your own reverse proxy in front.

The first build is slow — core compiles DuckDB, and the SEO worker pulls a Chromium image of about 1 GB. Later deploys reuse the layers.

Sign in

Open the root of your domain. It redirects to the console:

https://zenith.example.com/  →  /dashboard/

Sign in with the ZENITH_ADMIN_EMAIL and ZENITH_ADMIN_PASSWORD you set. That account was created on first boot, and an existing account is never overwritten — so leaving those variables set can't reset your password later.

This is your console, listing every site you manage. It's not the page you give a client; that's the domain-native dashboard, which has its own password.

Add your first site

In the sidebar, open Setup — or click Add site on the empty state — and fill in:

  • Name — what you'll call it in this console, e.g. Acme Marketing
  • Domain — the site being measured, e.g. acme.com
  • Owner email (optional) — who receives the monthly report

Saving lands you on the Setup tab for that site, where Zenith has generated your keys. You never invent them:

ValueWhat it is
ZENITH_URLYour console's own address
ZENITH_SITE_KEYPublic — ships in the page, writes events only
ZENITH_API_KEYSecret — reads analytics, server-side only

Each has a copy button, and the secret stays masked until you reveal it. The difference between them matters: see The two keys.

Install tracking in the site

For a Next.js app, the package does both tracking and the client dashboard:

pnpm add zenith-analytics
npx zenith init      # scaffolds config/zenith.ts + the dashboard route
npx zenith hash      # a password for the client's dashboard

The config holds no secrets — it names them and reads them from the environment, so the file is safe to commit:

import type { ZenithConfig } from "zenith-analytics";

// Public by design: both of these ship inside the tracking snippet.
export const ZENITH_PUBLIC = {
  backendUrl: process.env.ZENITH_URL || "https://zenith.example.com",
  siteKey: process.env.ZENITH_SITE_KEY || "zk_your_public_site_key",
};

// The public half plus the three secrets. Server-side only.
export const ZENITH_CONFIG: Partial<ZenithConfig> = {
  ...ZENITH_PUBLIC,
  apiKey: process.env.ZENITH_API_KEY,
  dashboardPath: "/zenith",
  protected: true,
  passwordHash: process.env.ZENITH_PW_HASH,   // from `npx zenith hash`
  jwtSecret: process.env.ZENITH_JWT_SECRET,   // any long random string
  siteDomain: "acme.com",
};

// createZenithRoute throws at module load when a secret is missing. The
// dashboard route only mounts the real handler when this is true.
export function zenithDashboardReady(): boolean {
  return Boolean(
    ZENITH_CONFIG.apiKey &&
      ZENITH_CONFIG.passwordHash &&
      ZENITH_CONFIG.jwtSecret
  );
}

Put the three secrets in the site's deployment environment — ZENITH_API_KEY, ZENITH_PW_HASH and ZENITH_JWT_SECRET — not in this file.

Then drop the component into your root layout, passing ZENITH_PUBLIC. The tracker never needs more than the public half:

import { Analytics } from "zenith-analytics/next";

import { ZENITH_PUBLIC } from "@/config/zenith";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics config={ZENITH_PUBLIC} />
      </body>
    </html>
  );
}

Not a React app? Use the plain script tag instead.

Create the dashboard route

Tracking works now — but the client's dashboard is a route you create yourself. It does not exist until this file does. In the site's app directory, create:

app/zenith/[[...zenith]]/route.ts

The folder name zenith must match the dashboardPath you set above (/zenith, minus the slash). [[...zenith]] is an optional catch-all — double brackets, three dots — so that one file serves both /zenith and everything under it. And it's route.ts, not page.tsx: this is a Route Handler, not a page.

import { createZenithRoute } from "zenith-analytics/next";

import { ZENITH_CONFIG, zenithDashboardReady } from "@/config/zenith";

// Without this, Next could statically render the route at build time and
// serve every visitor the same cached page — fatal for a password gate.
export const dynamic = "force-dynamic";

const notConfigured = () =>
  new Response("Zenith dashboard is not configured on this deployment.", {
    status: 503,
  });

// Secrets present → the real dashboard proxy. Absent (local dev, CI) → a
// plain 503 instead of createZenithRoute's intentional startup throw.
const handlers = zenithDashboardReady()
  ? createZenithRoute(ZENITH_CONFIG)
  : { GET: async () => notConfigured(), POST: async () => notConfigured() };

export const { GET, POST } = handlers;

The guard is why the config can hold no secrets. createZenithRoute validates when the module loads and throws if one is missing — right for production, but it also runs during next build, so a laptop or CI runner without the env vars would fail the build over a dashboard nobody is looking at. Guarded, it degrades to a 503 there and stays strict in production.

Deploy with the three secrets set, then open acme.com/zenith. A 503 instead of the password gate means one of them is missing from that environment.

On the Pages Router the file is pages/api/zenith/[[...zenith]].ts plus a rewrite — see Next.js.

See the data

Load a page of the site, then open your console. The pageview shows up immediately and the live counter updates every 15 seconds.

If nothing arrives, check the snippet is in the deployed build rather than only locally, and that you're not blocking your own requests with an ad blocker.

Where to go next