Files
zpan/docs/deploy/docker.md
T
Jasper Van 04f9d93ddf feat: v2.6 Z11 — production public key, Docker cron docs, release notes (#350)
* feat: v2.6 Z11 — prod public key, Docker cron docs, release notes

- Replace DEV placeholder in public-keys.ts with cloud.zpan.space
  production Ed25519 key (k4.public.sphdaogcyIh2_6_yZnO4_xQsi2m52HH9j2CPHcKlGGw)
  from cloud C5 cross-repo PR
- Add external cron section to docs/deploy/docker.md for the
  POST /api/licensing/refresh-cron endpoint (Z6)
- Create docs/v2.6-release-notes.md with what's new, retroactive gate
  notice (open_registration, teams_unlimited, team_quotas), upgrade guide

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test(licensing): decouple verify/entitlement tests from DEV secret key

Tests were hardcoded to the old DEV placeholder key. Now they generate
a fresh throwaway keypair per suite (beforeAll/afterAll), inject the
public key into PUBLIC_KEYS, and restore the original on teardown.

This keeps the tests independent of whichever production key is in
PUBLIC_KEYS, so rotating the key never breaks the test suite.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
2026-04-24 09:20:58 -04:00

3.2 KiB

Docker Deployment

ZPan ships as a single Docker image. By default it uses an embedded SQLite database (better-sqlite3). For production multi-replica deployments you can opt into Turso (libSQL) as a shared remote database.

Default: local SQLite

No extra configuration needed. Mount a volume so the database survives container restarts:

services:
  zpan:
    image: ghcr.io/saltbo/zpan:latest
    ports:
      - "8222:8222"
    environment:
      PORT: 8222
      BETTER_AUTH_SECRET: <generate with: openssl rand -base64 32>
      BETTER_AUTH_URL: https://your-domain.example
      DATABASE_URL: /data/zpan.db
    volumes:
      - zpan-data:/data
    restart: unless-stopped

volumes:
  zpan-data:

Migrations run automatically at startup.

Turso (libSQL) opt-in

Set TURSO_DATABASE_URL to switch from local SQLite to a Turso (or self-hosted libSQL) database. TURSO_AUTH_TOKEN is required for remote URLs; it can be omitted for local file:// URLs.

services:
  zpan:
    image: ghcr.io/saltbo/zpan:latest
    ports:
      - "8222:8222"
    environment:
      PORT: 8222
      BETTER_AUTH_SECRET: <generate with: openssl rand -base64 32>
      BETTER_AUTH_URL: https://your-domain.example
      TURSO_DATABASE_URL: libsql://your-db-name-orgname.turso.io
      TURSO_AUTH_TOKEN: <your-turso-auth-token>
    restart: unless-stopped

When TURSO_DATABASE_URL is present:

  • DATABASE_URL is ignored.
  • Migrations are applied automatically at startup via drizzle-orm/libsql/migrator.
  • TURSO_AUTH_TOKEN may be omitted only for file:// URLs (local libSQL files).

Running migrations manually against Turso

TURSO_DATABASE_URL=libsql://your-db.turso.io \
TURSO_AUTH_TOKEN=your-token \
npm run db:migrate

drizzle.config.ts automatically switches to the turso dialect when TURSO_DATABASE_URL is set, so npm run db:generate and npm run db:migrate work against Turso without any extra flags.

Obtaining a Turso auth token

turso db tokens create your-db-name

Or create one in the Turso dashboard.

Pro licensing: external cron for 6h refresh

ZPan refreshes its entitlement certificate every 6 hours via a built-in background timer. The Docker container runs a persistent Node.js process, so the background timer fires automatically — no external cron is required.

If you prefer an explicit external trigger (e.g. to integrate with your monitoring or to refresh immediately after a plan change), you can call the refresh endpoint manually:

Setup

  1. Generate a secret:

    openssl rand -hex 32
    
  2. Add the env var to your container environment:

    environment:
      REFRESH_CRON_SECRET: <the-secret-from-step-1>
    
  3. Trigger a refresh with an HTTP POST:

    POST https://your-domain.example/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>
    

    To run on a schedule via host cron, add to your crontab:

    0 */6 * * * curl -s -X POST "https://your-domain.example/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>"
    

If REFRESH_CRON_SECRET is not set, the endpoint returns 401 for all requests.