Configuration
The server (environment)
Set these in .env (see Self-hosting). Only the first one is
required — every other variable has a working default.
core
| Variable | Default | What it does |
|---|---|---|
ZENITH_JWT_SECRET | — | Required. Signs session tokens. Must be 32+ characters; there is deliberately no default. Generate with openssl rand -base64 32. |
ZENITH_ADMIN_EMAIL | — | Developer account provisioned on first boot. An existing account is never overwritten. |
ZENITH_ADMIN_PASSWORD | — | Its password, 6+ characters. |
ZENITH_PORT | 8080 | HTTP listen port. |
ZENITH_DATA_DIR | ./data | Directory holding both databases. The only stateful thing in a deployment — this is what you back up. |
ZENITH_EVENTS_DB | $ZENITH_DATA_DIR/events.duckdb | DuckDB event store. Override only to move it off the data dir. |
ZENITH_APP_DB | $ZENITH_DATA_DIR/zenith.sqlite | SQLite store for sites, users, settings, and the audit queue. |
ZENITH_TOKEN_TTL | 24h | How long a console session lasts. Any Go duration, e.g. 12h, 7d is not valid — use 168h. |
ZENITH_GEOIP_DB | — | Path to a country .mmdb. Without it, country is Unknown and everything else works. See Country data. |
ZENITH_DASHBOARD_DIR | baked into the image | The built console. The Docker image sets this for you; override only if you serve your own build. Unset and missing means API-only, and /dashboard/ returns 404. |
ZENITH_RESEND_ENDPOINT | Resend's API | Point email at a mock to verify a deployment without sending real mail. |
ZENITH_ENV | production | development lets core invent a throwaway signing secret so go run works with no setup. It says so loudly on boot and every restart signs everyone out. Never set this on a deployment. |
audit-worker
Only used when you run the optional SEO worker (the seo compose profile).
| Variable | Default | What it does |
|---|---|---|
ZENITH_DATA_DIR | ./data | Must be the same volume as core — the audit queue is a table in the shared SQLite database. |
ZENITH_APP_DB | $ZENITH_DATA_DIR/zenith.sqlite | Where it claims jobs from. |
ZENITH_AUDIT_CONCURRENCY | 1 | Audits at once. Each is another set of live Chromium tabs, so this is the knob that bounds the worker's memory. |
ZENITH_CHROME_PATH | /usr/bin/chromium | The Chromium binary. The image sets this; override only for a custom build. |
The Resend API key and MAIL FROM are not environment variables — they're set in the console under Settings and stored in the database, because they're configuration you change, not deployment plumbing. ZENITH_RESEND_ENDPOINT above only changes where mail is sent, for testing.
Country data (GeoIP)
Zenith resolves a visitor's country from their IP using a local lookup database. It can't ship inside Zenith — the licences don't allow redistribution — so the compose files fetch one at deploy time instead.
This is automatic. docker compose up runs a small geoip service that downloads
a current DB-IP Lite country database into the data volume and
exits. Core waits for it and opens it on boot, so countries work with nothing to
configure.
It re-downloads only once the file is over 30 days old, and it never fails a
deployment — if the download doesn't work, country reads Unknown and everything else
carries on. To turn country lookup off entirely, set ZENITH_GEOIP_DB to an empty
value.
DB-IP Lite is CC BY 4.0: if you publish country figures, attribute DB-IP.
Supplying the database yourself
Only needed if you want to pin a particular database, prefer MaxMind's, or run without
the geoip service.
1. Download a free database
Zenith reads any MaxMind-format country .mmdb. Two free options:
| Source | Account needed | Link |
|---|---|---|
| DB-IP Lite Country (easiest) | No | db-ip.com/db/download/ip-to-country-lite |
| MaxMind GeoLite2 Country | Yes — free signup, then a licence key | dev.maxmind.com |
DB-IP Lite is the path of least resistance: a direct download, refreshed monthly, no signup. Both arrive gzipped, so decompress first:
curl -L -o country.mmdb.gz https://download.db-ip.com/free/dbip-country-lite-2026-07.mmdb.gz
gunzip country.mmdb.gzThe DB-IP URL carries the year and month — check the download page for the current one.
2. Put it where core can read it
It has to live inside the container, on the data volume that survives restarts:
docker cp country.mmdb <core-container>:/data/country.mmdbOn Dokploy, add a mount in the Compose service pointing a host file at
/data/country.mmdb, so the file survives redeploys — a docker cp into a container
is lost the next time the image is rebuilt.
3. Point Zenith at it
ZENITH_GEOIP_DB=/data/country.mmdbRestart core. New pageviews resolve to countries from that moment on.
Country is resolved when the event is recorded, not when you look at it. The raw IP
is read from the request, turned into a country code, and then dropped — Zenith never
stores an IP address. So adding the database later cannot backfill: events already
collected keep Unknown forever. Only traffic after the restart gets a country.
Keep the file reasonably current — IP ranges get reassigned, so a database left alone for a year slowly gets less accurate. Both sources publish monthly.
The client (config/zenith.ts)
Every field of ZenithConfig, and where each value comes from. The file itself holds no
secret values — it reads them from the environment — which is what makes it safe to commit.
See The two keys for the split.
| Field | Required | Source | What it does |
|---|---|---|---|
backendUrl | ✓ | public | URL of your Zenith service. |
siteKey | ✓ | public | Public key — ships in the tracker snippet. |
apiKey | ✓ | ZENITH_API_KEY | Secret key — reads analytics, server-side only. |
siteDomain | ✓ | public | The client's domain, e.g. example.com. |
dashboardPath | — | public | Where the dashboard mounts. Default /analytics-dashboard. |
protected | — | public | Password-gate the dashboard. Default true. |
passwordHash | if protected | ZENITH_PW_HASH | bcrypt hash from npx zenith hash. Never plaintext. |
jwtSecret | if protected | ZENITH_JWT_SECRET | Signs the dashboard session cookie. |
sessionTtl | — | public | Dashboard session length in seconds. Default 43200 (12h). |
The three environment values are validated when the dashboard route is created, and a
missing one throws at module load rather than on the first request. That is right for a
production deploy and wrong for a local build, which is why the scaffolded route guards on
zenithDashboardReady() and answers 503 instead — see
the dashboard route.
CLI
npx zenith init # scaffold config/zenith.ts + the dashboard route
npx zenith hash # generate a bcrypt hash for the dashboard passwordBoth read from a prompt, never a flag — a password in argv lands in shell history and the process list.