Deploying your client's site

A correct integration can still ship broken, because two of the things Zenith depends on live outside your application code: when your environment variables are read, and what your Content-Security-Policy allows. Get the first wrong and the tracker is baked out of your HTML at build time. Get the second wrong and the dashboard loads its data but not its stylesheet.

Both fail silently. This page is how to avoid them.

When your environment variables are read

config/zenith.ts is a normal module, so process.env is read wherever the page that imports it renders. For a statically prerendered page — the Next.js default — that is during next build, on the build machine, not when a visitor arrives.

That matters because <Analytics config={ZENITH_PUBLIC} /> sits in your root layout. If ZENITH_SITE_KEY is undefined at build time, the component renders null, and the tracker is simply absent from every prerendered page. Supplying the variable at run time afterwards changes nothing: the HTML was already generated. The page never gets rendered again to pick it up.

The three secrets behave differently. They are read only by the dashboard route, and that route is force-dynamic — it renders per request, on the server, at run time.

VariableRead byNeeded at
ZENITH_SITE_KEY<Analytics /> in your layoutBuild time
ZENITH_URL<Analytics /> in your layoutBuild time
ZENITH_API_KEYThe dashboard route (force-dynamic)Run time
ZENITH_PW_HASHThe dashboard route (force-dynamic)Run time
ZENITH_JWT_SECRETThe dashboard route (force-dynamic)Run time

Build time also implies run time in practice — set all five everywhere and you can stop thinking about it. The distinction only bites when a platform separates the two, which is exactly what Docker does.

No pageviews, a dashboard of zeroes. That symptom — with no tracking snippet in view-source — is almost always one thing: ZENITH_SITE_KEY was undefined during next build, so the tracker was baked out of every prerendered page. The two public values must be present during the build, not merely at run time.

Plain Next.js (Vercel, Netlify, a Node server)

These platforms build in the same environment they run in, so once the variables are set they are set for both phases and this usually just works.

The one catch is which environment. On Vercel and Netlify, variables are scoped per environment (Production, Preview, Development) — the build uses the scope for the branch being deployed, so a variable added only to Preview will be missing from a production build. And because the site key is compiled into the prerendered HTML, changing it requires a redeploy, not a restart. Editing the value in the dashboard does nothing to pages that were already generated.

Docker

A Dockerfile runs the build inside the image, where your host's environment does not exist. Variables passed at docker run time arrive long after the build has finished, so the two public values have to come in as build arguments and be promoted to environment variables before the build step.

The catch that trips almost everyone is which stage they go in. A multi-stage build starts a fresh stage at every FROM, and an ARG declared in one stage does not carry into the next — so the values must be declared in the same stage that runs next build, not in a deps stage or globally at the top. Here is a complete, realistic standalone build with them in the right place:

FROM node:22-alpine AS base
RUN corepack enable

# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# --- Builder: this is the stage that runs the build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# These belong HERE, in the builder, before `pnpm build`. A FROM starts a
# fresh stage: an ARG declared in `deps` or at the top of the file does not
# exist here. And ARG alone is not enough — Next reads process.env, so each
# needs a matching ENV. Both are public (the site key ships in every page),
# so baking them into this layer leaks nothing.
ARG ZENITH_URL
ARG ZENITH_SITE_KEY
ENV ZENITH_URL=$ZENITH_URL
ENV ZENITH_SITE_KEY=$ZENITH_SITE_KEY

RUN pnpm build

# --- Runner: lean production image ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# The three secrets are read per request by the force-dynamic dashboard
# route, so they arrive at run time — never as build args, never baked into
# the image. Your platform's environment, or `docker run --env-file .env`.
EXPOSE 3000
CMD ["node", "server.js"]

<Analytics /> has no siteKey at build time, or a dashboard of zeroes, almost always means the ARG/ENV pair is in the wrong stage — declared in deps, or at the top before the first FROM, where the builder never sees it. They have to sit in the stage that runs the build, immediately before it. Never put ZENITH_API_KEY, ZENITH_PW_HASH or ZENITH_JWT_SECRET in any stage — those are run-time secrets, and an ARG would persist them in the image history.

Then build with the values:

docker build \
  --build-arg ZENITH_URL=https://zenith.example.com \
  --build-arg ZENITH_SITE_KEY=zk_your_public_site_key \
  -t acme-site .

When the site key changes, you need a rebuild, not a restart. The old key is inside the prerendered HTML in the image, and restarting the container serves that same HTML again.

Docker Compose

Each Zenith variable lands in one of two places, and a few land in both. The two public values go in build.args so the build can see them, and at run time so a dynamically-rendered page and the dashboard route see them too. The three secrets go at run time only, through env_file.

services:
  web:
    build:
      context: .
      args:
        # Public values, compiled into the prerendered HTML during the build.
        ZENITH_URL: ${ZENITH_URL}
        ZENITH_SITE_KEY: ${ZENITH_SITE_KEY}
    # Every ZENITH_* variable at run time: the two public ones again (dynamic
    # pages and the dashboard's backendUrl read them per request) and the three
    # secrets (the force-dynamic dashboard route reads them per request).
    env_file: .env
    ports:
      - "3000:3000"

An env_file on its own is the trap: it supplies variables to the container at run time, which is right for the three secrets and useless for the site key. If you put ZENITH_SITE_KEY only there, the build never sees it and the tracker is never emitted.

Never put ZENITH_API_KEY, ZENITH_PW_HASH or ZENITH_JWT_SECRET in build.args. A build argument is baked into the image's layers, where anyone who can pull the image can read it — and these are the keys that read your client's analytics and gate their dashboard. The build never needs them anyway: the dashboard route is force-dynamic, so it reads them per request. Secrets belong at run time, in env_file, and nowhere else.

WARNING: The "C" variable is not set during the build means Compose is interpolating a $ inside one of your values — almost always the bcrypt ZENITH_PW_HASH, whose $2b$10$C… reads as the variable $C. It happens when a secret is referenced through ${...} in the YAML (an environment: entry or a build.args line). Pass secrets through env_file: instead — Compose reads that file literally and never interpolates it, so the hash survives intact. If a secret truly must go through ${...}, escape every $ in the value as $$.

After changing either build arg:

docker compose build web
docker compose up -d web

docker compose restart will not regenerate the HTML — it restarts the same image, with the same prerendered pages, and the same missing snippet.

Using required on <Analytics>? Then this is exactly the failure it is meant to catch: a build with ZENITH_SITE_KEY missing throws instead of shipping blind, so next build (and your docker compose build) stops here until the value reaches the build. The fix is to wire the build arg above and the matching ARG/ENV in your Dockerfile — not to drop required, which would only return you to a silently empty tracker.

Dokploy, Coolify, and dashboard-deployed platforms

These build your image from git and inject the environment variables you enter in their UI. That injection is reliable at run time — but a variable entered in the dashboard is not guaranteed to be in scope when docker compose build resolves ${ZENITH_SITE_KEY} in build.args. Very often it is not: the dashboard has the value, the build sees an empty string, and every prerendered page ships with no tracker. With required set, the build fails on the missing key instead; without it, it ships silently blind.

The two build values are public, so write them as literals — not ${...} — in build.args. The site key is in your page source on every request already; putting it in a committed compose file leaks nothing. This removes the guesswork entirely: the build gets the value no matter how the platform scopes its variables.

    build:
      context: .
      args:
        ZENITH_URL: https://zenith.example.com
        ZENITH_SITE_KEY: zk_your_public_site_key

The three secrets stay in the platform's environment UI, run-time only, and never appear in build.args.

Step by step:

Set every variable in the platform's environment UI

All five: ZENITH_URL, ZENITH_SITE_KEY, and the three secrets. This is what the running container reads — the tracker on dynamic pages, and the dashboard route.

Put the two public values as literals in build.args

Not ${...}. This is the step that fixes a build which cannot see the dashboard variables, and it is safe because both values are public.

Declare ARG and ENV in the Dockerfile's build stage

Before RUN … build, so next build can read them (see Docker). The build arg delivers the value to the stage; the ENV is what puts it in process.env where Next looks.

Redeploy — a full rebuild

Not a restart. The site key is compiled into the HTML, so the image has to be rebuilt for a new value to take effect.

Check your environment file has one variable per line. A pasted secret that lands on the same line as the next one — ZENITH_PW_HASH="…" ZENITH_JWT_SECRET="…" — means only the first is read and the second is swallowed into its value, and depending on the parser it can derail the variables after it too, including your site key. Give each its own line, quote the bcrypt hash whole (ZENITH_PW_HASH="$2b$10$…") with no stray spaces inside the quotes, and never split it across two lines.

Content-Security-Policy

The embedded dashboard is a small SPA served from your Zenith host and rendered inside a page on your domain. If your app sends a CSP, that origin has to be allowed for four directives, because the SPA loads four kinds of thing from it:

script-src 'self' 'unsafe-inline' https://zenith.example.com;
style-src  'self' 'unsafe-inline' https://zenith.example.com;
font-src   'self' data: https://zenith.example.com;
connect-src 'self' https://zenith.example.com;
  • script-src — the dashboard bundle, and the tracking snippet.
  • style-src — the dashboard's stylesheet.
  • font-src — its webfonts.
  • connect-src — the fetch calls that load the analytics, and the tracker's POST /api/collect.

The dashboard loads unstyled and looks broken. That is what allowing only script-src does: the SPA boots, authenticates, fetches real data and renders correct numbers — with no styling at all, because the cross-origin stylesheet was blocked. It reads like a Zenith bug; it is a CSP with three directives missing. The browser console will name the blocked resource and the directive that blocked it; that is the fastest confirmation.

In Next.js the CSP is usually sent from headers() in next.config.js, or from middleware. Whichever you use, update the directives there — a CSP set at the reverse proxy or CDN counts too, and if both send one, the browser enforces the intersection.

"Zenith dashboard is not configured on this deployment"

That exact 503 is the guarded route saying zenithDashboardReady() returned false — which means at least one of the three dashboard secrets is missing or empty at run time: ZENITH_API_KEY, ZENITH_PW_HASH, ZENITH_JWT_SECRET. It is a different failure from the empty tracker above, and a different set of variables.

The catch is that these are the run-time half of the table. The dashboard route is force-dynamic, so it reads them per request — which means they have to reach the running container, not the build. Two ways to get that wrong:

  • They were passed only as build.args. A build argument is gone by the time the container runs. The secrets must arrive through env_file or an environment: entry, at run time. (And they should never be in build.args — see the warning above.)
  • The value is malformed. A ZENITH_JWT_SECRET that landed on the same line as another variable, or a ZENITH_PW_HASH whose $ characters were eaten by interpolation, is present-but-empty or present-but-wrong.

Since 0.4.5, npx zenith hash prints an interpolation-safe hash — the bcrypt hash base64url-encoded, so it is plain letters, digits, - and _ with no $ for anything to eat. Paste that into ZENITH_PW_HASH and every problem below disappears: no env_file requirement, no $$ escaping, it travels like your api key. The config decodes it back, and a raw $2b$… hash still works if you already have one. If you generated your hash before 0.4.5, regenerate it — that is the whole fix.

Using a raw $2b$… hash? Then check each secret is its own line, and the hash is intact. One variable per line in your environment file: ZENITH_JWT_SECRET sharing a line with ZENITH_PW_HASH means one of them is never read, and zenithDashboardReady() sees an empty value and answers 503. Quote the hash whole — ZENITH_PW_HASH="$2b$10$…" — with no stray spaces inside the quotes, and deliver it through env_file (read literally) rather than ${...} interpolation, which eats the $.

A hash missing only some of its $ is still truthy, so the dashboard loads and then rejects every password. A hash missing all of them — 2b10… instead of $2b$10$… — no longer looks like bcrypt at all, and the config refuses to start: ConfigError: passwordHash is not a bcrypt hash at module load, which surfaces as a 500 (or a 503 if your route uses the guarded pattern) on every request. Both are the same cause: the $ was interpolated away. A real hash from npx zenith hash is $2b$10$ followed by 53 characters, in three $-delimited sections — confirm that is what the container actually receives with docker exec <container> printenv ZENITH_PW_HASH.

Once all three are present and intact, the route mounts the real dashboard on the next request — no rebuild needed, because unlike the site key these are read live.

When nothing is being recorded

Work down this list; the first check usually settles it.

  1. View-source on a deployed page and look for the inlined Zenith snippet. If it is not there, the config was empty when that page rendered — go back to build-time variables. Use view-source, not the element inspector: you want the HTML as it was generated.
  2. Open the Network tab and load a page. You want a POST to /api/collect returning 204. A blocked or failed request points at connect-src; a 4xx points at the key.
  3. Compare the site key against the one on the site's Setup tab in your console. A key from a different site writes events you will never see on this dashboard.
  4. Disable your ad blocker. Most of them block analytics endpoints by hostname, and they block yours as readily as anyone else's. Check in a clean profile before concluding the deploy is wrong.

Since v0.4.1, <Analytics /> logs a warning naming the variable it is missing rather than rendering null in silence. For a prerendered page that warning is printed when the page renders — which is during next build, so it appears in your build log, not in the browser console. If you deploy through a platform that hides build output by default, that log is the first place to look.

To make this failure loud instead of quiet next time, pass the required prop — <Analytics config={ZENITH_PUBLIC} required />. Since v0.4.3 that turns a missing key from a build-log warning into a thrown error, so next build fails outright rather than shipping a page with no tracker. See the tracker section.

Where to go next