Self-hosting

Zenith self-hosts with Docker Compose. Everything stateful lives in one volume.

Run it

cp .env.example .env

Fill in the two required values:

ZENITH_JWT_SECRET=      # openssl rand -base64 32 — must be 32+ chars
ZENITH_ADMIN_EMAIL=you@example.com
ZENITH_ADMIN_PASSWORD=  # 6+ characters

Every other setting has a working default — see Configuration for the full list, including country lookup and the SEO worker.

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

The console is at http://localhost:8080/dashboard/.

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

Signing in to your console

Once core is up, open the root of wherever you deployed it — it redirects to the console:

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

Sign in with the ZENITH_ADMIN_EMAIL and ZENITH_ADMIN_PASSWORD you put in .env. That account is created on first boot, and only then: an account that already exists is never overwritten, so leaving those variables set can't silently reset your password on the next restart.

This is your console — the one with every client's site in it. It is not the page you give a client; that's the domain-native dashboard, which has its own separate password.

There is no "change password" yet, and no password reset. Nothing in the console or the API can change an existing account's password.

If you lose it, create a second developer account instead: change ZENITH_ADMIN_EMAIL to a different address, set a new ZENITH_ADMIN_PASSWORD, and restart. Because the email is new, it provisions a fresh account you can sign in with. The old one stays, harmlessly, unless you remove it from the database.

The architecture

Two services, not one — an out-of-memory SEO audit must never take analytics ingestion down with it:

  • core — event ingestion, the stats API, auth, settings, the scheduler, and the console. ~200 MB.
  • audit-worker — headless-Chromium SEO audits, in its own ~1 GB image. Optional, behind the seo compose profile.

Two stores, each for what it's best at:

  • DuckDB — columnar, for analytics events and GROUP BY aggregations.
  • SQLite — transactional, for users, sites, settings, and jobs.

Both sit behind EventStore / AppStore interfaces, so a hosted tier can later swap in ClickHouse and Postgres without business logic changing.

Behind HTTPS

Zenith doesn't terminate TLS — that's your reverse proxy's job. Two lines of Caddy:

zenith.example.com {
    reverse_proxy localhost:8080
}

It reads X-Forwarded-Proto and X-Forwarded-For, so the dashboard cookie is marked Secure and visitor IPs resolve to the real client.

Don't expose Zenith directly to the internet — it trusts X-Forwarded-For, which only makes sense behind a proxy. You need one for TLS anyway.

Back up the volume

zenith-data holds both databases and is the only stateful thing in a deploy. docker compose down keeps it; down -v destroys it.