Documentation
Configuration — ZigBase
The full environment-variable reference, CLI commands, and configuration precedence rules for the ZigBase server.
ZigBase is configured by environment variables and a small set of serve command-line flags.
Precedence
Configuration resolves in order of increasing precedence:
- Built-in defaults
- Environment variables
servecommand-line flags (where a flag exists)
So a flag overrides the matching environment variable, which overrides the default.
CLI commands
Offline storage maintenance
Build with -Dfile-inventory=true to include zigbase files reconcile. It emits JSON and defaults to a read-only preview; built-in local storage plus SQLite is required in both modes. S3/PostgreSQL/custom backends are refused.
| Flag | Default | Meaning |
|---|---|---|
--data-dir PATH | configured data directory | Existing database and storage root |
--limit N | 100 | Page size, 1–1,000 |
--cursor KEY | none | Previous nextCursor, not a deletion approval/snapshot |
--min-age-seconds N | 86400 | Grace period, 1–31,536,000 seconds |
--apply | off | Irreversible bounded deletion after exclusive root lease and database writer lock |
--json | JSON is always emitted | Explicit output-format spelling |
Apply refuses cooperating running apps, including serve --ignore-lock. Stop older/external writers yourself and keep the root dedicated to one database. Every built-in local-storage app holds one boot-lifetime maintenance descriptor, even without this CLI enabled; never remove its permanent lock file. Age alone does not prove an upload has finished. See offline reconciliation for retained unknown metadata, pagination bounds and partial-failure semantics.
Server and common commands
zigbase serve [--http-host H] [--http-port N] [--data-dir PATH] [--serve-static DIR]
[--insecure-cookies] [--trust-proxy] [--realtime-origins CSV]
[--background] [--ephemeral] [--ignore-lock] [--force]
zigbase serve stop | status [--json] | logs [--follow] [--data-dir PATH]
zigbase doctor [--production] [--json] [--data-dir PATH]
zigbase migrate [--data-dir PATH]
zigbase superuser create --email E --password P [--data-dir PATH]
zigbase version
zigbase help
Running zigbase with no recognised command prints usage.
serve— start the HTTP server. The--serve-static DIRflag enables static file serving fromDIRat the root path (anything not matching/api/,/_/, or custom routes). Only available when the app uses the default static-files mode (field absent inApp(.{...})); rejected as an unknown flag when the mode is comptime-hardcoded (.disabled,.dir, or.embedded). A directory in the static tree containing an empty file named.spabecomes an SPA root: any GET/HEAD miss at or below it serves that directory’sindex.html(200) so client-routed apps survive deep links and hard refreshes — resolved live against the filesystem on every miss (add/remove a marker with no restart needed); startup only fails fast if a marker has noindex.html. Real files and/api/admin paths always win. See Framework → Static files.--backgrounddetaches into its own process group and exits0only once the server answers its own/api/health;--ephemeralstarts a throwaway server on a fresh temp data dir and a free port, printing one JSON line when ready;--ignore-lockstarts an untracked instance (no session lock, invisible toserve status/stop/logs). A secondserveon the same data dir is refused unless--ignore-lockis passed. See Running the server for the full session/control-verb contract.serve stop | status | logs— manage a backgroundservesession via itsserve.lock/serve.jsonsession files.status --json/logs --followfor scripting. See Running the server → Control verbs.doctor— run the nine frozen preflight checks over this deployment’s config, data dir, and schema;--productionescalates the checks that matter more in a real deployment;--jsonemits NDJSON findings plus a summary object. Exits0fully clean,1on any error,2on warnings only. See Running the server →zigbase doctor.migrate— run schema migrations against the data directory and exit.superuser create— create a superuser (required before managing collections).help— print usage.
zigbase init
Scaffold a starting-point project. Never overwrites: existing files are reported as skipped and left alone, and there is no --force.
| Flag | Meaning |
|---|---|
--box | (default) No Zig toolchain — docker-compose.yml, a schema starting point (apply it with zigbase schema apply), AGENTS.md + CLAUDE.md, .gitignore, README.md. |
--framework | A Zig package embedding ZigBase — build.zig (wired with zigbase.addTo + zigbase.addTest), build.zig.zon, src/main.zig with a comptime schema and in-process tests, plus the same agent files. |
--dir PATH | Target directory, created if missing. Default . |
--name NAME | Package/executable name (framework mode). Default: the directory name, sanitized into a Zig identifier. |
In framework mode, run zig fetch --save git+https://github.com/valthon/zigbase afterwards — that is what writes the dependency URL and its content hash into build.zig.zon.
zigbase agents-md
Write AGENTS.md + CLAUDE.md for a project that already exists.
| Flag | Meaning |
|---|---|
--dir PATH | Target directory. Default . |
--box / --framework | Force a content set. Omit to infer: a build.zig.zon in the directory means framework. |
--stdout | Print AGENTS.md instead of writing it (diff-friendly). |
Build flag: -Ddev-tools
init, agents-md, typegen, capabilities, routes, migrate preview, tune and diagnostics are development-time CLI tools: scaffolding, code generation, discovery, measurement comparison and structured doctor checks. They are gated behind -Ddev-tools, on by default. Every artifact we publish — the GitHub release tarballs, the Docker image, and the @zigbase/server npm packages — builds at this default and ships these tools. -Ddev-tools=false is an opt-out offered to a consumer compiling their own binary for their own deployment: it drops these tools and their implementation code from the build. Ordinary doctor and database migration actions remain available. A stripped binary recognizes the gated command names, but exits non-zero with a message pointing back at -Ddev-tools=true instead of running them.
Environment variables
| Env var | Flag | Default | Purpose |
|---|---|---|---|
ZIGBASE_HTTP_HOST | --http-host | 127.0.0.1 | bind address (loopback by default; set 0.0.0.0 for all interfaces) |
ZIGBASE_HTTP_PORT | --http-port | 8090 | listen port |
ZIGBASE_DATA_DIR | --data-dir | ./zb_data | data directory (holds data.db and storage/) |
ZIGBASE_DB_URL | — | "" (SQLite) | Opt-in. A postgres://… URL selects the PostgreSQL backend instead of SQLite. TLS defaults to sslmode=verify-full (certificate chain + hostname verified; sslrootcert=<pem-path> for private CAs) — append ?sslmode=require or ?sslmode=disable to opt down (logged at startup). Only honored in a binary built with -Dpostgres=true (see below); ignored otherwise |
| — | --serve-static | "" (off) | serve static files from DIR at the root path (default mode only) |
ZIGBASE_STATIC_CACHE_CONTROL | --static-cache-control | "max-age=3600" (facil.io stock) | Cache-Control value for static responses (embedded + dir); flag wins over env, both win over the comptime .static_cache_control default. See Framework → Static files |
ZIGBASE_JWT_SECRET | — | auto-generated | token signing secret (≥32 bytes). Unset → a random secret is generated + persisted at <data-dir>/.jwt_secret (0600); a shorter provided value is refused |
ZIGBASE_FIELD_KEY | — | "" (unset) | key for at-rest field encryption (.encrypted fields). Never auto-generated/persisted/logged; the server refuses to start if any collection declares an encrypted field while this is empty |
ZIGBASE_FIELD_CRYPTO | — | real | DEV-ONLY (-Ddev-mode, on by default in Debug): set fake to store .encrypted fields as readable fake:<key>:<value> instead of AES-GCM — for eyeballing values while debugging. Compiled out of release binaries; never read there. See Fields → Encryption at rest |
ZIGBASE_FIELD_KEY_GENERATION | — | 1 | generation of the primary (write) field-encryption key — the envelope version stamped on writes (v<N>:). Bump to rotate, then run zigbase rewrap |
ZIGBASE_FIELD_KEY_V<n> | — | unset | older read-only key for generation <n>, needed to decrypt existing v<n>: data after a key rotation |
ZIGBASE_COOKIE_SECURE | --insecure-cookies (sets false) | true | mark auth cookies Secure. On by default; opt out for plain-HTTP local dev |
ZIGBASE_TRUST_PROXY | --trust-proxy (sets true) | false | trust X-Forwarded-For/X-Real-IP for client-IP / rate-limit keying (set only behind a trusted reverse proxy) |
ZIGBASE_AUTH_TOKEN_TTL | — | 1209600 (14 days) | auth token lifetime, seconds |
ZIGBASE_VERIFICATION_TTL | — | 604800 (7 days) | email-verification token lifetime, seconds |
ZIGBASE_PASSWORD_RESET_TTL | — | 3600 (1 hour) | password-reset token lifetime, seconds |
ZIGBASE_REALTIME_ORIGINS | --realtime-origins | "" (deny cross-origin) | CSV of allowed WebSocket Origins. Empty denies cross-origin browser upgrades; same-origin upgrades are always allowed |
ZIGBASE_REALTIME_OUTBOUND_HWM | --realtime-outbound-hwm | 1024 (frames) | slow-consumer outbound high-water-mark: max queued outbound frames per realtime (WS/SSE) connection before the server disconnects the peer (bounds memory under a stalled/slow reader). 0 disables the bound |
ZIGBASE_SERVE_BACKGROUND | --background | auto-detect | 1 forces serve into the background; any other value (including empty) disables the automatic backgrounding that a detected AI-agent environment (CLAUDECODE, CODEX_THREAD_ID, GEMINI_CLI, …) would otherwise trigger. See Running the server → Agent auto-detection |
ZIGBASE_LOG_FORMAT | --log-format | text | log stream encoding: text or json (one JSON object per line on stderr). The env var applies to every subcommand; the flag is serve-only. See Observability |
ZIGBASE_LOG_LEVEL | --log-level | info | minimum log severity: debug, info, warn, error |
ZIGBASE_LOG_REQUESTS | --no-request-log (sets false) | true | per-request access lines (method, path, status, duration); turn off when a reverse proxy already ships access logs |
ZIGBASE_MAX_UPLOAD_SIZE | — | 52428800 (50 MiB) | max request body size, bytes |
ZIGBASE_FILE_TOKEN_TTL | — | 120 (2 min) | file-access token lifetime, seconds |
ZIGBASE_SENTRY_DSN | — | "" (log to stderr) | set to enable Sentry error reporting |
ZIGBASE_RATE_LIMIT_MAX | — | 10 | max sensitive-auth attempts per window per client; 0 disables rate limiting |
ZIGBASE_RATE_LIMIT_WINDOW | — | 60 | rate-limit window length, seconds |
ZIGBASE_OAUTH_STATE_SERVER | — | true | server-side OAuth state (CSRF) store is on by default; set false to opt out (client-driven state only — PKCE still required) |
ZIGBASE_OAUTH_STATE_TTL | — | 600 (10 min) | server-side OAuth state lifetime, seconds |
ZIGBASE_PUBLIC_URL | — | "" | public base URL used to build user-facing links (magic-link sign-in emails). Unset → magic-link emails contain the raw token instead of a clickable URL |
ZIGBASE_SMTP_HOST | — | "" (use LogMailer) | SMTP server host; set to deliver verify/reset email instead of logging |
ZIGBASE_SMTP_PORT | — | 25 | SMTP server port |
ZIGBASE_SMTP_USERNAME | — | "" | SMTP username; non-empty enables AUTH LOGIN |
ZIGBASE_SMTP_PASSWORD | — | "" | SMTP password |
ZIGBASE_SMTP_FROM | — | noreply@zigbase.dev | envelope + From: address |
ZIGBASE_SMTP_TLS | — | auto | transport security: none / starttls / implicit / auto (auto: 465→implicit, 587→starttls, else→none) |
ZIGBASE_SMTP_INSECURE | — | false | skip TLS cert verification (self-signed relays only) |
ZIGBASE_SENDMAIL_COMMAND | — | "" (off) | local-MTA command to pipe mail to (e.g. sendmail -t -i or msmtp -t); when set, overrides SMTP. App holds no SMTP creds |
ZIGBASE_FAKE_NOW | — | unset | DEV-ONLY test clock. Freeze “now” to an ISO-8601 UTC instant (e.g. 2029-03-07T16:00:00Z) for deterministic time-boundary e2e tests. Freezes both the framework’s own timestamps and a consumer’s raw SQL datetime('now') / unixepoch('now') / strftime(…, 'now') (and date/time/julianday), plus the CURRENT_TIMESTAMP / CURRENT_TIME / CURRENT_DATE keywords and column DEFAULT CURRENT_TIMESTAMP (via a wrapping VFS). Ignored entirely on a production build (compiled out unless built with -Ddev-mode=true; off in any release build). See Known limitations → Testing for scope |
ZIGBASE_FAKE_SEED | — | unset | DEV-ONLY seeded entropy. Set to a decimal u64 (e.g. 12345) to make record/field ID and token generation deterministic: two runs with the same seed produce identical IDs and tokens. Useful for snapshot tests. Ignored entirely on a production build (compiled out unless built with -Ddev-mode=true; off in any release build). See Framework → Test/dev-mode seams |
ZIGBASE_S3_BUCKET | — | "" (off) | Opt-in. Non-empty selects the S3-compatible storage backend instead of local disk. Only honored in a binary built with -Ds3=true (see below); ignored otherwise |
ZIGBASE_S3_REGION | — | "us-east-1" | AWS region (SigV4 signing + default endpoint) |
ZIGBASE_S3_ENDPOINT | — | "" | "" → https://s3.<region>.amazonaws.com; set for MinIO/R2/other S3-compatible endpoints |
ZIGBASE_S3_ACCESS_KEY_ID | — | "" | SigV4 access key id (required with ZIGBASE_S3_BUCKET) |
ZIGBASE_S3_SECRET_ACCESS_KEY | — | "" | SigV4 secret access key (required with ZIGBASE_S3_BUCKET) |
ZIGBASE_S3_FORCE_PATH_STYLE | — | auto | true/1 forces path-style addressing; unset auto-selects path-style when ZIGBASE_S3_ENDPOINT is set, virtual-hosted otherwise |
ZIGBASE_S3_KEY_PREFIX | — | "" | prefix prepended to every object key — namespace multiple apps in one bucket |
ZIGBASE_S3_CACHE_DIR | — | "" | "" → <data-dir>/storage_cache; the local spool cache directory downloads materialize through |
ZIGBASE_S3_CACHE_MAX_BYTES | — | 1073741824 (1 GiB) | spool cache size cap; eviction reclaims down to a 3/4 low-water mark |
ZIGBASE_S3_MULTIPART_THRESHOLD_BYTES | — | 67108864 (64 MiB) | use multipart at this object size; 5 MiB–5 GiB |
ZIGBASE_S3_MULTIPART_PART_BYTES | — | 8388608 (8 MiB) | preferred part size; 5 MiB–5 GiB, increased to stay within 10,000 parts |
Database backend (experimental)
ZigBase defaults to its embedded SQLite database (<data-dir>/data.db) — the single-binary story, and the only backend in a stock build. A pure-Zig PostgreSQL backend is available behind the opt-in -Dpostgres build flag; it is off by default and adds no libpq, C, or OpenSSL (TLS and SCRAM-SHA-256 use Zig std only), so the default binary stays fully static and OpenSSL-free, with its SQLite data path unchanged.
When compiled in (zig build -Dpostgres=true), setting ZIGBASE_DB_URL to a postgres://user:pass@host:port/dbname?sslmode=require URL selects PostgreSQL at startup; any other value (or an unset var) keeps SQLite. The backend is chosen once, by connection string — “switch via configuration alone”. A stock (-Dpostgres=false) binary handed a postgres:// ZIGBASE_DB_URL does not silently write to local SQLite — it logs a prominent warning and falls back to SQLite, so a misconfigured deployment is visible rather than misdirecting data.
This is a fully supported deployment target with full feature parity: record CRUD, the typed filter/sort/expand/search query engine, the access-rule + abilities + tenancy authorization stack, analytics rollups, the KV/TTL/rate-limit/feature-flag stores, field encryption + key rotation, the deterministic test-clock, and typed-client codegen all work identically on Postgres — verified against a live server in CI. Transport is verified by default since 0.10.0 — an unqualified URL gets sslmode=verify-full (chain + hostname verification, sslrootcert= for private CAs), a server that refuses TLS fails at startup with the exact opt-down instruction, and any explicit mode below verify-full logs a startup warning. See PostgreSQL backend for the full guide.
S3 storage backend (experimental)
ZigBase defaults to local-disk file storage (<data-dir>/storage). An opt-in S3-compatible object storage backend (AWS S3, MinIO, Cloudflare R2, and similar) is available behind the -Ds3 build flag — off by default, alongside -Dpostgres and -Dvector in the same “compiled in only when asked for” family of opt-in build flags.
When compiled in (zig build -Ds3=true), setting ZIGBASE_S3_BUCKET (plus credentials — see the env table above) selects S3 storage at startup instead of local disk; leaving it unset keeps local disk. The backend is chosen once, by configuration — the same “switch via configuration alone” contract as ZIGBASE_DB_URL. A stock (-Ds3=false) binary handed ZIGBASE_S3_BUCKET does not silently keep writing to local disk unnoticed — it logs a loud warning and falls back to local storage.
Downloads are never proxied straight from S3: the server spools an object to a local cache file on first read (ZIGBASE_S3_CACHE_DIR) and serves every subsequent read from that file, so Range requests, conditional requests, ETag, and per-collection cacheability behave identically to local storage. Startup runs a fail-fast HeadObject probe so a bad config is caught at boot, not on first upload. See Known limitations for the write-lock, best-effort-delete, proxy-only-serving, and buffered-multipart caveats.
Email delivery
Three backends, selected by config with a fixed precedence (no code change to switch):
ZIGBASE_SENDMAIL_COMMANDset → the message (the same RFC822 bytes the SMTP backend sends) is piped to a local command’s stdin and exit 0 means delivered. This is the standard “delegate delivery to a local MTA/relay, hold no SMTP credentials in the app” setup — point it atsendmail -t -iormsmtp -t. The string is whitespace-split into argv;From:still comes fromZIGBASE_SMTP_FROM. Takes precedence over SMTP.ZIGBASE_SMTP_HOSTset → verification and password-reset tokens are emailed over the configured SMTP transport (STARTTLS / implicit TLS / plaintext). TLS verifies certificates by default;ZIGBASE_SMTP_INSECUREdisables verification for self-signed relays.- Neither set (the default) → tokens are logged to the server (a dev/CI convenience).
Configure a real backend (sendmail command or SMTP) for production.
Rate limiting
Sensitive auth endpoints (login, verification, password-reset) are rate limited — see API → Rate limiting. X-Forwarded-For / X-Real-IP are ignored by default (they are spoofable on direct exposure); the limiter keys on the submitted identity/email. Set --trust-proxy / ZIGBASE_TRUST_PROXY=true only behind a trusted reverse proxy to key on the proxy-supplied client IP; see Known limitations.
Security
ZigBase is secure by default. The bind is loopback (127.0.0.1); expose all interfaces deliberately with --http-host 0.0.0.0 (front it with a firewall / reverse proxy). The JWT secret is per-deployment: leaving ZIGBASE_JWT_SECRET unset generates a strong random secret and persists it at <data-dir>/.jwt_secret (mode 0600), reused on later runs; a provided secret must be ≥32 bytes or the server refuses to start. Auth cookies are Secure by default, so plain-HTTP local dev needs --insecure-cookies (or ZIGBASE_COOKIE_SECURE=false) or the admin-UI login cookie is not stored. An empty ZIGBASE_REALTIME_ORIGINS denies cross-origin browser WebSocket upgrades; same-origin upgrades (the embedded admin UI, or a frontend served from the same binary) are always allowed, so only a separate-origin frontend needs --realtime-origins.
See also
For bounded query diagnostics, build with -Dquery-workbench=true and optionally set .query_workbench = .{ .max_entries = 64, .slow_ms = 100 }. The operator-only SQLite/PostgreSQL workbench reports route-template/structural-shape metrics without SQL or parameter capture, and plans schema-validated SELECT shapes without executing them on SQLite. PostgreSQL reports client-side protocol-exchange durations including network/server wait and result buffering; PostgreSQL plans still return 501. Buffered rows add no per-row clocks or telemetry copies. Telemetry bounds do not limit the backend’s existing result buffering. It is fully absent by default. Completed-statement lifecycle timing separates measured prepare/step/cleanup calls from time held between them, including application pauses and row handling. In-scope prepares fingerprint compiled SQL once and reuse the key on reset; statements prepared outside scope retain execution-time fingerprinting. Each in-scope finalization adds one mutex-guarded store update with a bounded entry scan. This helps distinguish slow SQLite calls from long-lived statements without claiming CPU or full-request latency; enabled counters remain explicitly bounded. HTTP scopes also classify returned status codes and escaping handler errors. Fixed reader/writer buckets measure pool mutex acquisition, excluding connection creation and time holding a connection. Declared durable/scheduled jobs have separate attempt aggregates; queue residence, retry backoff, memory jobs, and app.submit are excluded. See the framework workbench scope.
For request backpressure, compile .admission = .{ .max_requests = 3 } into your app. Excess synchronous HTTP callbacks receive 503 with Retry-After: 1, not an additional waiting queue. Superusers can inspect /api/admission/stats for active, high-water and rejected counts; this endpoint obeys the same limit. Omission compiles out the checks and counters. This does not bound transport-buffered bodies, long-lived realtime sessions, background jobs, or total RSS.
To coordinate HTTP with outstanding memory jobs and app.submit, additionally build with -Dcoordinated-admission=true and set .admission.max_work = 16 alongside .max_requests. Embedded builds forward .@"coordinated-admission" = true to the ZigBase dependency. Queued/running jobs retain one permit through retries; saturation immediately returns error.QueueFull for jobs or the HTTP overload response. Jobs can consume all capacity, so allow room for handlers that enqueue work and handle rejection. Shared diagnostics expose work_limit, jobs, work_high_water, and jobs_rejected; resources reports the compiled ceiling. This optional integration compiles out without its build flag. It counts work, not bytes, and excludes durable jobs, transport buffers and long-lived realtime.
The same build gate also offers an independent retained-copy byte ceiling: .admission = .{ .max_job_bytes = 64 * 1024 }. No max_requests is required: HTTP counting and admission checks compile out, but diagnostics remain available with limit: null and zero HTTP counters. It charges memory-job payload and app.submit name copies before allocation, across queued/running tasks and retries; full capacity returns error.QueueFull. It does not count inline borrowed payloads, pre-enqueue serialization, handler allocations, task headers or allocator overhead, so it is not an RSS cap. Diagnostics expose job_bytes_limit, job_bytes, job_bytes_high_water and job_bytes_rejected; the offline envelope exposes coordinated_admission_max_job_bytes. Omission leaves byte counters untouched. For Zig embedders, App.admission_config.?.max_requests and the admission snapshot’s limit are now ?u32: handle null as no HTTP cap, or unwrap only when your configuration guarantees one. Omit .max_requests rather than setting it to zero to retain a byte-only budget.
Bound long-lived realtime sessions separately with comptime .realtime = .{ .max_connections = 256 }. WS and SSE share the positive cap; the default remains 10,000 and excess upgrades receive 503. Inspect the effective cap and reserved slots through superuser /api/realtime/stats. The Golfsim example demonstrates a smaller cap; larger deployments can explicitly raise it.
zigbase tune --input measurements.json compares observed throughput, p95 latency, and peak RSS against explicit budgets. Pair it with comptime resource profiles; the offline advisor never changes production settings. The framework guide includes a real local measurement workflow and explains its limits. This command requires -Ddev-tools=true (the default); custom builds with development tools disabled omit it.
- Quick start — install and serve.
- API — the REST + WebSocket reference.
- Known limitations — caveats around email, rate limiting, and more.