Skip to content
License Server v1.29.0 · released 30 Oct 2026release notes →
Skip to content

Installation & Setup ​

Get started with the License Server API in minutes. This guide covers deployment options, configuration, and initial setup.

Don't want to run your own server?

This page covers self-hosting. If you'd rather sign up for a managed account instead, see Hosted (SaaS) Tier — same API, no deployment required.

Quick Start ​

Recommended: License Server + Console, via Docker Compose

Download the self-hosted bundle — License Server and its admin console, already wired together in one docker-compose.yml. This is the recommended way to self-host: you get a real UI for issuing and managing licenses instead of raw curl calls against the admin API. Unzip it and follow its own README top to bottom; come back to this page afterward for anything its README doesn't cover in depth (environment variables, Kubernetes, and the rest of this guide).

Don't want the console, or want the lightest possible footprint? Two other ways to self-host, both covered below:

Using the bundle: console sign-in and the admin API key ​

Signing in to the console. The console has one shared login, and you choose it: set ADMIN_UI_USERNAME and ADMIN_UI_PASSWORD in the bundle's .env before the first docker compose up -d. There are no default credentials, and the console won't start without both. Then open http://127.0.0.1:3000/login (or your reverse proxy's console URL) and sign in with them.

Using the console. Using the Console walks through each page: issuing a license, finding one, changing its terms, revoking it, freeing a seat, products and releases. The bundle's console has Dashboard, Licenses, Releases and Products, but not the hosted Settings and Billing pages, so storefront setup goes through the API: see Storefront Auto-Fulfillment.

Changing the password, or recovering a forgotten one: edit ADMIN_UI_USERNAME/ADMIN_UI_PASSWORD in .env and run docker compose up -d again. The console restarts with the new values and no data is lost. Sessions last 8 hours and a password change doesn't end them; to sign everyone out immediately, change SESSION_SECRET in the same edit.

The admin API key. The bundle generates the key the console uses to talk to License Server on first boot, unique to your installation, so there's nothing to set. You need it only to call the admin API yourself (with curl, or from your own scripts or storefront). To read it, run this from the bundle's folder:

bash
docker compose exec license cat /shared/admin-api-key

To use a specific key instead (for example when restoring a previous deployment), set ADMIN_API_KEY in .env and run docker compose up -d; an explicit value takes precedence for both services.

Using Docker (License Server only) ​

The bundle above already covers Docker Compose with the console included. This section is the plain, no-Compose, License-Server-only path — pull the image and run it directly with docker run, for anyone who specifically doesn't want Compose managing the container.

Pull the published image — no source access needed. The four required secrets listed under Required Environment Variables don't need to be generated by hand: set AUTO_GENERATE_SECRETS=true and the container generates and persists them itself on first boot.

bash
mkdir -p data
docker pull ghcr.io/casazium/license:latest
docker run -d \
  --name casazium-license \
  -p 127.0.0.1:3001:3001 \
  -v "$(pwd)/data:/app/data" \
  -e DB_FILE=/app/data/license.db \
  -e AUTO_GENERATE_SECRETS=true \
  -e CASAZIUM_UNLICENSED_EVAL=1 \
  --restart unless-stopped \
  ghcr.io/casazium/license:latest

The server will be available at http://localhost:3001. The 127.0.0.1: prefix on the port mapping binds it to localhost only, so nothing outside this machine can reach it — that's deliberate for a first run; put your own reverse proxy with TLS in front of it for a real deployment, or use -p 3001:3001 to listen on all interfaces.

The container runs as uid 1000, not root - on Linux, ./data created by a plain mkdir -p data is usually owned by your own user or root, and the container crash-loops with a permission error until you fix its ownership. See Database permission errors under Troubleshooting below if that happens (it's common enough to expect, not just an edge case). macOS with Docker Desktop generally doesn't hit this.

CASAZIUM_UNLICENSED_EVAL=1 starts it under a time-limited evaluation window, not for production use — without some activation-license variable set, the server refuses to start at all. See License Activation below to configure a real purchased license instead.

Already have a purchased license instead of evaluating? Drop CASAZIUM_UNLICENSED_EVAL=1, name the file you received license.json and put it in the current directory, and mount it into the container alongside setting TIER_A_LICENSE_FILE:

bash
docker run -d \
  --name casazium-license \
  -p 127.0.0.1:3001:3001 \
  -v "$(pwd)/data:/app/data" \
  -v "$(pwd)/license.json:/app/license.json:ro" \
  -e DB_FILE=/app/data/license.db \
  -e AUTO_GENERATE_SECRETS=true \
  -e TIER_A_LICENSE_FILE=/app/license.json \
  --restart unless-stopped \
  ghcr.io/casazium/license:latest

Using Docker Compose (License Server only) ​

The same image, via Compose, still without the console — convenient once you're managing more than one service, or want restart: unless-stopped and the volume mount declared in a file rather than a long docker run line. Want the console too? Use the bundle from Quick Start above instead of the compose file below — same idea, one more service.

yaml
# docker-compose.yml
services:
  license:
    image: ghcr.io/casazium/license:latest
    ports:
      - '127.0.0.1:3001:3001'
    env_file:
      - .env
    volumes:
      - ./data:/app/data
      # Your activation license, delivered separately after purchase.
      # Uncomment once you have it (place the file as license.json next
      # to this docker-compose.yml) and set
      # TIER_A_LICENSE_FILE=/app/license.json in .env - see License
      # Activation below. Leave both commented out while evaluating:
      # uncommenting this before the file exists doesn't error, it
      # silently creates a root-owned directory named license.json in
      # its place, which then blocks the real file from ever landing
      # there correctly.
      # - ./license.json:/app/license.json:ro
    environment:
      - DB_FILE=/app/data/license.db
      - AUTO_GENERATE_SECRETS=true
    # Lets a license email that's still sending finish on shutdown
    # (Docker's default of 10 seconds can cut it off).
    stop_grace_period: 20s
    restart: unless-stopped

Compose reads env_file at startup and errors out if the file it names is missing, so create one before the first docker compose up:

bash
mkdir -p data
touch .env
echo "CASAZIUM_UNLICENSED_EVAL=1" >> .env
docker compose up -d

As with the docker run command above, CASAZIUM_UNLICENSED_EVAL=1 starts the server under a time-limited evaluation window, not for production use. Once you have a purchased license, replace that line in .env with TIER_A_LICENSE_FILE=/app/license.json, uncomment the volume line above, and restart (docker compose up -d) — see License Activation below.

Environment Configuration ​

Required Environment Variables ​

The server refuses to start unless these four are set (src/lib/config.js validates them at boot). This check runs before the activation-license check covered in License Activation directly below — the four required secrets are validated as soon as the server's code loads, before the activation gate ever gets a chance to run. So an install with none of these five things configured fails here first, with a "Missing required environment variables" error and no tier-a-license log line at all; set these four, then also set an activation-license variable, or the server exits at that second gate instead:

bash
ADMIN_API_KEY=your-secure-admin-api-key-here
ENCRYPTION_KEY=your-32-byte-hex-encryption-key-here  
LICENSE_SIGNING_SECRET=your-license-signing-secret-here
LICENSE_RSA_PRIVATE_KEY=your-pem-encoded-rsa-private-key-here

Don't want to generate these yourself?

Set AUTO_GENERATE_SECRETS=true (shown above) instead of setting these four directly, and leave them blank. The container generates all four on first boot and saves them to <your data directory>/.secrets.json — the same directory your SQLite database already lives in — so every later restart reuses the same values instead of minting new ones. The file is a plain JSON object keyed by the same four variable names (ADMIN_API_KEY, ENCRYPTION_KEY, LICENSE_SIGNING_SECRET, LICENSE_RSA_PRIVATE_KEY) — see Admin Authentication Test below for reading a value back out of it. Back that file up along with the rest of your data directory: losing it invalidates every license file you've exported and your admin API key. If you set any of the four explicitly, that value always wins and is never overwritten, even with AUTO_GENERATE_SECRETS=true set — useful if you're restoring a previous deployment's values rather than starting fresh. The "Generating Secure Keys" section below still applies if you'd rather generate and manage these yourself.

Keeping secrets in files

Each of the four can also be read from a file: set ADMIN_API_KEY_FILE (or ENCRYPTION_KEY_FILE, LICENSE_SIGNING_SECRET_FILE, LICENSE_RSA_PRIVATE_KEY_FILE) to the file's path instead of setting the value itself — the convention Docker secrets and orchestrators use. A value set directly still wins. If the file named is missing or empty, the server refuses to start rather than generating a different secret. Available from version 1.6.2.

License Activation ​

The public ghcr.io/casazium/license image is gated by an activation license — without one configured, the server refuses to start immediately, before it ever opens its database or starts listening. This is separate from the four required environment variables above, checked at a different stage of boot, so it's easy to miss if you're only working from the four-variable list.

This is what the variable names and log lines below call a Tier-A build (TIER_A_LICENSE_FILE, tier-a-license: ...) — the internal name for this self-hosted product line, gated by the activation-license check described here. Every publicly published ghcr.io/casazium/license image is a Tier-A build, so you don't need to choose anything or look anything up: this section is simply what "Tier-A" refers to everywhere else on this page.

Set one of:

bash
# A license file (the normal case for Docker/Compose) - mount it into
# the container and point this at the mounted path:
TIER_A_LICENSE_FILE=/app/license.json

# ...or the same file's raw JSON contents inline, if mounting an extra
# file isn't convenient for your deployment style:
TIER_A_LICENSE='{"payload": {...}, "signature": "..."}'

You'll receive a license file after purchase — name it license.json (the filename this page's examples assume; rename it if it arrives under a different name) — see Pricing for self-hosted licensing, or Contact to ask about it. If you don't have one yet and want to evaluate first:

bash
# 30-day evaluation, logged loudly on every boot. Not for production
# use. The 30 days start at this installation's first boot in
# evaluation mode.
CASAZIUM_UNLICENSED_EVAL=1

A valid TIER_A_LICENSE_FILE/TIER_A_LICENSE always takes precedence over CASAZIUM_UNLICENSED_EVAL: the server checks for a real license first and only falls back to evaluation mode when none is found or the one it found doesn't verify. So if you forget to remove CASAZIUM_UNLICENSED_EVAL=1 when switching to a real license, nothing breaks — it's simply never consulted once a valid license is present — though the "real license" examples above remove it anyway, since a config that no longer does anything is worth cleaning up.

How the 30 days are counted

The evaluation starts the first time an installation boots in evaluation mode, and that date is recorded in its database. Restarts, upgrades and new image pulls don't change it; the only way to a fresh evaluation is a new, empty database. It never starts later than the oldest license or activation already in the database. When the 30 days are up, the server refuses to start until you configure a purchased license; nothing in the database is deleted, so everything you set up during the evaluation carries over. (Before version 1.6.1 the window was measured from each image's build date instead.)

If Casazium stops offering the License Server

A purchased license doesn't leave you dependent on Casazium staying in business. The License Server end-user license agreement commits to:

  • 90 days' notice. At least 90 days' written notice by email before Casazium stops offering or supporting the License Server, or stops operating.
  • A final build that needs nothing from Casazium. Before that date, everyone holding a valid, unexpired license when the notice is sent gets a final build with the activation-license check and all communication with Casazium's servers removed, which they can keep running indefinitely under the same license terms.
  • No degraded installs. If Casazium's license-validation servers go away, that is never grounds for your installation to stop working or lose functionality.

An evaluation-mode install (CASAZIUM_UNLICENSED_EVAL) has no license, so it isn't covered.

Verifying activation ​

Check the container logs after starting:

bash
docker logs casazium-license | grep tier-a-license

# or, if you used Docker Compose:
docker compose logs license | grep tier-a-license

A successful activation logs tier-a-license: valid activation license found with the name it was issued to and its expiry date. Evaluation mode logs tier-a-license: no valid activation license - proceeding under a time-limited evaluation allowance, not for production use (expires <timestamp>) - that timestamp is when this installation's evaluation runs out, per the note above.

A refused boot logs two lines, not one — a specific reason first, immediately followed by a second, generic line (confirmed live: both always print together, one right after the other, on every refusal). The first line is one of two, depending on why: tier-a-license: refusing to start - no valid activation license found. Contact your vendor to obtain one., or, if CASAZIUM_UNLICENSED_EVAL was set but this installation's 30 days have already run out (per the note above), tier-a-license: refusing to start - the evaluation allowance window for this installation has expired. The second line is always tier-a-license: refusing to start (<reason>). Contact your vendor for a valid activation license., where <reason> is a short internal code (e.g. not-configured) rather than a full sentence — the first line is the one worth reading; the second is there mainly as a grep-friendly marker. Either way, the container exits before ever accepting a connection.

Every one of these lines is followed on the same line by a small JSON object with structured metadata (e.g. {"reason":"not-configured"}) - harmless, and grep tier-a-license still matches the line either way, but don't be thrown if what you see isn't a byte-for-byte match for the quoted text above.

Optional Environment Variables ​

These all have working defaults and don't need to be set:

bash
PORT=3001                       # actual default: 3001 - see note below
NODE_ENV=production             # default: development
LOG_LEVEL=info                  # default: info — see Logging Configuration
DB_FILE=./data/license.db       # default: ./dev.db
ADMIN_RATE_LIMIT_MAX=300        # default: 300 — see Rate Limits
MULTI_TENANT=false              # default: false — see Hosted (SaaS) Tier
PORTAL_ENABLED=false             # default: false — see below
REPORT_EXTRACT_KEY=              # default: unset — see below
NOTIFICATIONS_EXTRACT_KEY=       # default: unset — see below
CRASH_REPORT_DSN=                # default: unset (off) — see Crash reporting
CRASH_REPORT_ENVIRONMENT=        # required with a DSN

PORT actually defaults to 3001 wherever the server runs unset - Docker, Compose, the SEA executable, all of it - confirmed directly against the code that does the actual listening (src/app.js/src/sea-entry.js both use process.env.PORT || 3001). There is a separate, lower 3000 fallback inside src/lib/config.js, used only to validate that some port number is well-formed when PORT is unset - it's never what the server actually binds to, so you can ignore it.

MULTI_TENANT=true switches this deployment into multi-tenant mode (many accounts sharing one running instance, each isolated to its own data) — see Hosted (SaaS) Tier. Leave it unset/false for an ordinary single-tenant self-hosted deployment, which is what the rest of this page assumes.

PORTAL_ENABLED=true turns on the end-user license portal — a page your own customers can reach directly (no Casazium account, no password) to view their license, deactivate a machine, and download their .lic file without you building any of that yourself. Off by default; leaving it unset means those routes don't exist at all on this deployment.

Selling through Stripe, Lemon Squeezy or Paddle? Storefront auto-fulfillment emails each buyer their key once you set EMAIL_PROVIDER, RESEND_API_KEY and EMAIL_FROM (and requires the portal to be on, and PUBLIC_BASE_URL set — from 1.10.0 the server won't start without it). Until then each buyer's email is only logged, and the server says so at every start (Buyer license emails are off, 1.15.1+). From 1.17.0, EMAIL_FROM must be a bare address or Name <address>, or the server won't start; that page also covers the optional RESEND_WEBHOOK_SECRET and RECIPIENT_HASH_KEY (required only with MULTI_TENANT=true).

REPORT_EXTRACT_KEY gates GET /v1/admin/report-extract, a separate credential from ADMIN_API_KEY by design — leaving it unset simply means that one route rejects every request; nothing else about the deployment is affected. Comma-separated to support rotation (add the new key, then remove the old one, with no window where neither works).

NOTIFICATIONS_EXTRACT_KEY gates GET /v1/admin/expiring-licenses the same way, as its own separate credential — same comma-separated rotation support, same fail-closed-when-unset behavior, just a different route.

Security Warning

Never commit real secrets to version control! Use secure methods to manage production secrets:

  • Environment variables
  • Docker secrets
  • Cloud secret managers (AWS Secrets Manager, Azure Key Vault, etc.)
  • HashiCorp Vault

This applies to AUTO_GENERATE_SECRETS=true too: your generated secrets end up in ./data/.secrets.json, right next to your docker-compose.yml. Add data/ to .gitignore before you commit anything in this directory.

Crash reporting ​

From 1.11.0, License Server can report its own errors (5xx replies, crashes and background-job errors) to an error service that speaks Sentry's envelope protocol, such as your own Sentry. It is off unless you set both:

bash
CRASH_REPORT_DSN=https://<key>@<host>/<project id>   # your project's DSN
CRASH_REPORT_ENVIRONMENT=production                  # a name you choose

There is no built-in destination: reports go only to the DSN you set, never to Casazium. Before a report leaves the process its message is masked (keys, tokens, email and IP addresses, and quoted text are removed), and request and response bodies, headers, URLs, query strings, tenant IDs and IP addresses are never sent. Each report carries the error's type, the masked message, the stack, the route, and the request ID that matches the server's own log line.

The DSN must use https (or http to localhost). CRASH_REPORT_ENVIRONMENT is lowercase letters, digits, - and _, up to 32 characters; the name is yours to choose. A malformed value never stops the server: it starts with reporting off and logs crash reporting off: … with the reason. When it's on, the startup log says crash reporting on (environment=…, release=…). To turn it off, clear CRASH_REPORT_DSN and recreate the container (docker compose up -d); a restart keeps the old value.

To check it works (1.15.0 and later), send the crash test with your ADMIN_API_KEY (how to read it if the server generated it):

bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_KEY" \
     http://localhost:3001/v1/admin/crash-test

It answers 500 with {"error":"Internal Server Error"}, because it throws a real error on purpose; it changes nothing. Within a few seconds your error service should have an issue for the route /v1/admin/crash-test, with the message "Crash test: a deliberate error, requested with the admin key" and a stack trace. Sending it again adds to that issue rather than opening a new one, so resolve it when you're done. A 403 means the key isn't your ADMIN_API_KEY: a tenant API key is refused too. If no report arrives, check the startup log line above.

Expect an unreadable stack. Every build Casazium distributes (the Docker image and the SEA binaries) is obfuscated, so each frame from the server's own code is on line 1 of one obfuscated file, rather than a readable file and line: index.js:1:<column> in the Docker image, the bundled file's name in a SEA binary. The report still shows that reporting works end to end, and which route failed. A report keeps up to 50 frames (from 1.15.0; 10 before), with paths relative to the server's working directory. In the Docker image, frames from its dependencies are named too, such as node_modules/fastify/…; a SEA binary bundles everything into its one file.

Generating Secure Keys ​

Only needed if you're setting the four required secrets explicitly instead of using AUTO_GENERATE_SECRETS=true (above) — for example, restoring a previous deployment's values, or a Kubernetes deployment, where secrets are conventionally managed as Kubernetes Secrets (see the Kubernetes section below) rather than generated in-container. Generate cryptographically secure keys for production:

bash
# ADMIN_API_KEY (must be 32+ characters - the server refuses to start otherwise)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# ENCRYPTION_KEY (must be exactly 32 bytes / 64 hex chars)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# LICENSE_SIGNING_SECRET (must be 32+ characters - the server refuses to start otherwise)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# LICENSE_RSA_PRIVATE_KEY - RSA keypair for offline license verification
# (private key only — the public key is derived at runtime and served
# from GET /v1/public-key)
node -e "console.log(require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } }).privateKey)"

The three randomBytes(32).toString('hex') commands above produce identical-looking 64-character hex output with nothing to distinguish them afterward — copy each one's output into its matching variable name as you go, rather than generating all four first and trying to match them up from a scrollback later.

Each command only prints the generated value to your terminal — it doesn't set anything itself. Copy what it prints into wherever you're actually setting these variables: your .env file, docker run -e VAR=<value>, the environment: block in Docker Compose, or a Kubernetes Secret's stringData (see Kubernetes below).

Setting the RSA private key as a shell variable

Its output is a multi-line PEM block (-----BEGIN PRIVATE KEY----- … -----END PRIVATE KEY----- across several lines), which breaks a raw shell export LICENSE_RSA_PRIVATE_KEY=<paste> unless the pasted value is wrapped in double quotes. A .env file handles a multi-line value without that problem, so prefer that over a bare shell export for this key specifically. In a Kubernetes Secret's stringData, use a literal block scalar (|), not a double-quoted string, for the same reason - a quoted multi-line value there loses its newlines when YAML parses it, which the server then rejects as invalid. See the rsa-private-key entry in the Kubernetes example below for the exact syntax.

This same RSA private key (LICENSE_RSA_PRIVATE_KEY, generated above) also signs update manifests if you use Software Distribution — not the signing secret from the block above, despite the similar wording — see that page's note on why the RSA key must persist across redeploys.

Deployment Options ​

The Docker and Docker Compose paths are covered in Quick Start above - this section covers the remaining options.

Single Executable (SEA) Binary ​

The lightest-weight option: one self-contained executable for Linux and macOS (Apple Silicon) with Node itself, this application, and its dependencies already bundled in — no container runtime, no image to pull. Gated by the same activation license as the Docker image (see License Activation above) and reads the same required/optional environment variables - just export them in your shell or process manager instead of docker run -e/an env file. It doesn't include the console — pair it with the admin API directly, or raw curl, the same way the Docker-only paths above did before the bundle existed.

AUTO_GENERATE_SECRETS is not available on this path — it's implemented in the Docker image's own entrypoint script, not in the executable itself, so all four required secrets below must be set explicitly (see Generating Secure Keys above).

bash
# Linux (x64)
curl -L -o casazium-license https://github.com/casazium/license-releases/releases/latest/download/casazium-license-linux-x64
chmod +x casazium-license

# macOS (Apple Silicon)
curl -L -o casazium-license https://github.com/casazium/license-releases/releases/latest/download/casazium-license-darwin-arm64
chmod +x casazium-license

macOS: don't run this from Downloads, Desktop, Documents, or iCloud Drive

macOS restricts write access to these specific folders for any app that hasn't been explicitly granted permission — regardless of your own file ownership/permissions on them. Since DB_FILE below defaults to a path relative to wherever you run the binary from, launching it straight out of ~/Downloads (where a browser download naturally lands) fails on first boot with EPERM: operation not permitted, mkdir ... the moment it tries to create its data directory — confirmed live, not theoretical. Move the binary somewhere ordinary first (e.g. ~/casazium-license/), or grant your terminal app Files and Folders access to that location in System Settings → Privacy & Security. This is unrelated to the Gatekeeper/code-signing question covered by casazium/license's NODE_SEA_DESIGN.md — the binary itself launches fine either way; this is purely about where it's allowed to write.

bash
export ADMIN_API_KEY=... ENCRYPTION_KEY=... LICENSE_SIGNING_SECRET=... LICENSE_RSA_PRIVATE_KEY=...
export TIER_A_LICENSE_FILE=/path/to/license.json
export DB_FILE=./data/license.db
./casazium-license

Using a .env file instead of exporting each variable

This binary loads a .env file from its own working directory too, the same as the Docker paths above — including TIER_A_LICENSE_FILE/ TIER_A_LICENSE/CASAZIUM_UNLICENSED_EVAL, even though License Activation above notes that check runs at its own separate stage of boot. But ~ is never expanded inside .env. The library that reads it (dotenv) just takes the literal string after =; tilde expansion is a shell behavior that never runs on a value coming from a file. So DB_FILE=~/data/license.db in .env ends up creating a directory literally named ~ next to the binary, not resolving to your home directory — confirmed live, not theoretical. Use a full absolute path instead (DB_FILE=/Users/you/casazium-license-data/license.db), or set the variable with export in your shell instead of .env, where ~ does expand correctly before the app ever sees it.

Unlike the Docker examples above, there's no -p 127.0.0.1:... mapping to restrict this to localhost - the binary listens directly on all interfaces (0.0.0.0) on whatever port you configure. Put it behind your own firewall or reverse proxy before exposing it to a real network.

These URLs always resolve to the newest published build - there's no version number to update here as new releases ship. Only linux-x64 and darwin-arm64 builds are published today; other platforms aren't available yet.

Kubernetes ​

Deploy to Kubernetes with secrets management:

yaml
# license-server-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: license-server-secrets
type: Opaque
stringData:
  admin-api-key: "your-admin-api-key"
  encryption-key: "your-encryption-key"
  signing-secret: "your-signing-secret"
  # Use a literal block scalar (|), not a quoted string, for the RSA
  # key specifically - a real multi-line PEM inside double quotes loses
  # its newlines when YAML parses it, and the server then rejects it as
  # invalid. The block scalar below preserves them correctly.
  rsa-private-key: |
    -----BEGIN PRIVATE KEY-----
    your-pem-encoded-rsa-private-key
    -----END PRIVATE KEY-----
  # Your activation license file's raw JSON contents (see License
  # Activation above) - TIER_A_LICENSE accepts the same JSON either
  # way. To evaluate without one yet, remove this key, remove the
  # TIER_A_LICENSE entry it's wired to below, and add this env entry to
  # the Deployment's own `env:` list instead:
  #   - name: CASAZIUM_UNLICENSED_EVAL
  #     value: "1"
  license-json: |
    {"payload": {...}, "signature": "..."}

---
# license-server-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: license-server-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

---
# license-server-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: license-server
  labels:
    app: license-server
spec:
  # SQLite is single-writer, so this deployment runs exactly one
  # replica - the PersistentVolumeClaim above is also ReadWriteOnce for
  # the same reason, which would keep a second replica from scheduling
  # anyway. For continuous off-box data protection instead of multiple
  # instances, see Litestream under Database Configuration below; for a
  # managed multi-instance deployment, see the Hosted (SaaS) Tier.
  replicas: 1
  selector:
    matchLabels:
      app: license-server
  template:
    metadata:
      labels:
        app: license-server
    spec:
      # The container runs as uid 1000, not root, but a freshly
      # provisioned PersistentVolume is commonly root-owned - without
      # this, the container can fail to open its database/secrets file
      # with a permission error on first boot, depending on your
      # storage provisioner.
      securityContext:
        fsGroup: 1000
      containers:
      - name: license-server
        image: ghcr.io/casazium/license:latest
        ports:
        - containerPort: 3001
        env:
        - name: NODE_ENV
          value: "production"
        - name: PORT
          value: "3001"
        - name: ADMIN_API_KEY
          valueFrom:
            secretKeyRef:
              name: license-server-secrets
              key: admin-api-key
        - name: ENCRYPTION_KEY
          valueFrom:
            secretKeyRef:
              name: license-server-secrets
              key: encryption-key
        - name: LICENSE_SIGNING_SECRET
          valueFrom:
            secretKeyRef:
              name: license-server-secrets
              key: signing-secret
        - name: LICENSE_RSA_PRIVATE_KEY
          valueFrom:
            secretKeyRef:
              name: license-server-secrets
              key: rsa-private-key
        - name: TIER_A_LICENSE
          valueFrom:
            secretKeyRef:
              name: license-server-secrets
              key: license-json
        - name: DB_FILE
          value: "/app/data/license.db"
        volumeMounts:
        - name: data-volume
          mountPath: /app/data
        livenessProbe:
          httpGet:
            path: /
            port: 3001
          initialDelaySeconds: 30
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /
            port: 3001
          initialDelaySeconds: 5
          periodSeconds: 10
      volumes:
      - name: data-volume
        persistentVolumeClaim:
          claimName: license-server-pvc

---
# license-server-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: license-server-service
spec:
  selector:
    app: license-server
  ports:
  - protocol: TCP
    port: 80
    targetPort: 3001
  type: ClusterIP

Deploy to Kubernetes:

bash
kubectl apply -f license-server-secret.yaml
kubectl apply -f license-server-pvc.yaml
kubectl apply -f license-server-deployment.yaml
kubectl apply -f license-server-service.yaml

Verification ​

Health Check ​

Verify the server is running (the health check is at the server root, not under /v1):

bash
curl http://localhost:3001/

Expected response (version reflects whatever build you actually pulled, so don't expect this exact string — it's illustrative):

json
{
  "message": "License API is running",
  "version": "1.4.0",
  "buildFingerprint": "a1b2c3d4e5f6"
}

Admin Authentication Test ​

Test admin endpoints. If you used AUTO_GENERATE_SECRETS=true, your generated admin key is the ADMIN_API_KEY field in <your data directory>/.secrets.json. Read it with the Node.js that's already inside the container, so nothing extra needs installing on the host:

bash
# The bundle (License Server + Console) keeps its key elsewhere:
ADMIN_API_KEY=$(docker compose exec -T license cat /shared/admin-api-key)
# License Server only, via Docker Compose:
ADMIN_API_KEY=$(docker compose exec -T license node -p "require('/app/data/.secrets.json').ADMIN_API_KEY")
# ...or, if you used docker run:
ADMIN_API_KEY=$(docker exec casazium-license node -p "require('/app/data/.secrets.json').ADMIN_API_KEY")

curl -H "Authorization: Bearer $ADMIN_API_KEY" \
     http://localhost:3001/v1/admin/stats

If you set ADMIN_API_KEY explicitly instead, use that value directly in place of your-admin-api-key:

bash
curl -H "Authorization: Bearer your-admin-api-key" \
     http://localhost:3001/v1/admin/stats

Expected response:

json
{
  "totalLicenses": 0,
  "activeLicenses": 0,
  "revokedLicenses": 0,
  "totalActivations": 0,
  "recentActivations": []
}

Configuration Options ​

Database Configuration ​

The License Server uses SQLite by default. Configure the database location:

bash
# Relative path
DB_FILE=./data/license.db

# Absolute path  
DB_FILE=/var/lib/license-server/license.db

# In-memory (development only - the database is gone on every restart,
# nothing persists across process restarts, and it's incompatible with
# Litestream and the backup scripts below, which both assume a real
# on-disk file)
DB_FILE=:memory:

A relative path resolves against the process's working directory — inside the published Docker image and the Kubernetes manifest above that's always /app (so ./data/license.db there is the same file as /app/data/license.db, which is why both forms appear interchangeably on this page); running the SEA binary directly, it's wherever you launched it from.

Database Backups

For production deployments, take regular backups of the SQLite database file on whatever schedule fits your risk tolerance (e.g. a daily cron job), and monitor database size and performance.

Continuous replication for high availability

The server supports optional, off-by-default continuous replication via Litestream, streaming every database change to your own S3-compatible storage (AWS S3, Backblaze B2, Cloudflare R2, MinIO, etc.) within seconds — cutting realistic data-loss exposure from whatever your own backup interval is (the tip above) down to a few seconds. It's designed specifically so self-hosted operators can protect their own data with no dependency on Casazium's own infrastructure: you provide the storage destination and credentials, nothing is sent to Casazium. Off unless you configure a replica destination. Set:

bash
LITESTREAM_REPLICA_BUCKET=your-bucket-name
LITESTREAM_ACCESS_KEY_ID=...
LITESTREAM_SECRET_ACCESS_KEY=...

# Required for any S3-compatible provider that isn't real AWS S3
# (Backblaze B2, Cloudflare R2, MinIO, etc.) - leave both blank only
# for real AWS S3.
LITESTREAM_REPLICA_ENDPOINT=...
LITESTREAM_REPLICA_REGION=...

The replica's storage path is derived automatically — there's no separate path variable to set. After starting, check the container logs for litestream: replica destination reachable (or a WARNING line if it's misconfigured or unreachable via DNS — that case boots normally). A destination that actively refuses the connection is slower to fail, not silent - expect boot to pause for several minutes (an internal retry budget) before it gives up on that connection attempt and continues anyway. If your deployment platform's own health/liveness check has a shorter timeout than that (Kubernetes' defaults do — see the manifest below), it can kill the container for taking too long to become healthy before Litestream ever finishes retrying. Point LITESTREAM_REPLICA_ENDPOINT at a reachable host, even a wrong one that at least accepts and closes the connection, rather than one that refuses it outright, if you're not ready to fully configure this yet.

Rate Limits ​

Most rate-limit tiers are fixed values set in the server implementation. The one exception is the Admin-Info tier (list-licenses, admin/stats, recent-activations, and related read endpoints), whose limit is configurable via ADMIN_RATE_LIMIT_MAX (default 300 requests per 15 minutes). See Rate Limits for the full breakdown.

Logging Configuration ​

The server writes structured JSON logs to stdout with Fastify's built-in Pino logger. NODE_ENV=test disables logging entirely (used by the test suite to keep output quiet); any other value leaves it on.

LOG_LEVEL sets the level: one of fatal, error, warn, info, debug, trace or silent, default info. Any other value stops the server at startup with Invalid LOG_LEVEL value. debug is for an investigation, not everyday running: it logs considerably more. (Available from version 1.4.0.)

There's no LOG_FORMAT or LOG_FILE variable. For a different format or a log file, capture stdout with your process manager or container runtime (e.g. Docker's own --log-driver/--log-opt flags, a logging: block in your own Compose file, or your platform's log aggregation).

Next Steps ​

Now that your License Server is running:

  1. How authentication works - Which endpoints require the admin key, and how the hosted tier differs
  2. Understand rate limits - Learn about request quotas
  3. Issue your first license - Create a test license
  4. Integrate with your app - Add license verification
  5. Gate your own updates - License-gated releases and signed auto-update checks for your end users

Troubleshooting ​

Common Issues ​

Port already in use:

bash
# Check what's using port 3001
lsof -i :3001

# Use a different port - change the published port on the left side of
# the -p/ports mapping (the container still listens on 3001 internally)
docker run -p 127.0.0.1:3002:3001 ... ghcr.io/casazium/license:latest

Database permission errors:

The container process runs as uid 1000, not root, so ./data must be writable by uid 1000 specifically - chmod 755 alone doesn't fix this if the directory is owned by root or by your own host user, which is what mkdir -p ./data normally gives you. This shows up as a crash-loop (docker ps shows Restarting (1)) with EACCES: permission denied, open '.../.secrets.json' or a database-open error in the logs. Fix the ownership directly:

bash
mkdir -p ./data
sudo chown -R 1000:1000 ./data

Invalid encryption key:

bash
# Encryption key must be exactly 64 hex characters (32 bytes)
node -e "
  const key = 'your-key-here';
  console.log('Key length:', key.length, '(should be 64)');
  console.log('Valid hex:', /^[0-9a-f]{64}$/i.test(key));
"

Support ​

If you encounter issues: