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 zenithEverything you deploy is in here: the core service, the optional SEO worker, and
the compose files under deploy/.
Create your .env
cp .env.example .envFill 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+ charactersEverything 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:
| Setting | Value |
|---|---|
| Compose Path | ./deploy/docker-compose.dokploy.yml |
| Environment | the three variables from step 2 |
| Domain | your 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 --buildAdd -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:
| Value | What it is |
|---|---|
ZENITH_URL | Your console's own address |
ZENITH_SITE_KEY | Public — ships in the page, writes events only |
ZENITH_API_KEY | Secret — 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 dashboardThe 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
Next.js in depth
Both routers, custom events, and the entry points.
Deploying
Build-time env vars, Docker, and the CSP the dashboard needs.
Hand it to a client
Serve their analytics from their own domain, password-gated.
SEO audits
Run an audit and download the findings.
Configuration
Every environment variable, and country data.