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

VariableDefaultWhat it does
ZENITH_JWT_SECRETRequired. Signs session tokens. Must be 32+ characters; there is deliberately no default. Generate with openssl rand -base64 32.
ZENITH_ADMIN_EMAILDeveloper account provisioned on first boot. An existing account is never overwritten.
ZENITH_ADMIN_PASSWORDIts password, 6+ characters.
ZENITH_PORT8080HTTP listen port.
ZENITH_DATA_DIR./dataDirectory holding both databases. The only stateful thing in a deployment — this is what you back up.
ZENITH_EVENTS_DB$ZENITH_DATA_DIR/events.duckdbDuckDB event store. Override only to move it off the data dir.
ZENITH_APP_DB$ZENITH_DATA_DIR/zenith.sqliteSQLite store for sites, users, settings, and the audit queue.
ZENITH_TOKEN_TTL24hHow long a console session lasts. Any Go duration, e.g. 12h, 7d is not valid — use 168h.
ZENITH_GEOIP_DBPath to a country .mmdb. Without it, country is Unknown and everything else works. See Country data.
ZENITH_DASHBOARD_DIRbaked into the imageThe 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_ENDPOINTResend's APIPoint email at a mock to verify a deployment without sending real mail.
ZENITH_ENVproductiondevelopment 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).

VariableDefaultWhat it does
ZENITH_DATA_DIR./dataMust be the same volume as core — the audit queue is a table in the shared SQLite database.
ZENITH_APP_DB$ZENITH_DATA_DIR/zenith.sqliteWhere it claims jobs from.
ZENITH_AUDIT_CONCURRENCY1Audits 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/chromiumThe 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:

SourceAccount neededLink
DB-IP Lite Country (easiest)Nodb-ip.com/db/download/ip-to-country-lite
MaxMind GeoLite2 CountryYes — free signup, then a licence keydev.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.gz

The 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.mmdb

On 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.mmdb

Restart 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.

FieldRequiredSourceWhat it does
backendUrlpublicURL of your Zenith service.
siteKeypublicPublic key — ships in the tracker snippet.
apiKeyZENITH_API_KEYSecret key — reads analytics, server-side only.
siteDomainpublicThe client's domain, e.g. example.com.
dashboardPathpublicWhere the dashboard mounts. Default /analytics-dashboard.
protectedpublicPassword-gate the dashboard. Default true.
passwordHashif protectedZENITH_PW_HASHbcrypt hash from npx zenith hash. Never plaintext.
jwtSecretif protectedZENITH_JWT_SECRETSigns the dashboard session cookie.
sessionTtlpublicDashboard 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 password

Both read from a prompt, never a flag — a password in argv lands in shell history and the process list.