Production & Deployment
Everything to take a ZenZip app to production: secrets, encryption, health probes, security, observability, multi-tenancy, scaling to Postgres, and containers.
ZenZip is a single Node process that embeds the engine — there is no broker, scheduler, or worker fleet to run alongside it. A production deploy is mostly about configuration: secrets, retention, health, and observability. Here's the whole picture.
import { zenzip, sentryReporter, pinoLogger, resolveSecret } from "zenzipjs";
import pino from "pino";
import * as Sentry from "@sentry/node";
const app = zenzip({
// Storage: embedded SQLite (single node) → Postgres for multi-node.
store: process.env.DATABASE_URL
? { driver: "postgres", url: process.env.DATABASE_URL }
: { driver: "sqlite" },
dataDir: "/data",
// Secrets from the environment / mounted files — never hard-coded.
encryptionKey: resolveSecret("env:ZENZIP_ENCRYPTION_KEY"),
// Retention: bound how long durable data lives.
retention: { runs: "30d", events: "7d", sweep: "1h" },
// Observability.
logLevel: "info",
logger: pinoLogger(pino()),
onError: sentryReporter(Sentry),
alerts: { onAlert: (a) => Sentry.captureMessage(a.message), dlqThreshold: 25 },
});Deployment models#
| Model | Store | Notes |
|---|---|---|
| Single node | embedded SQLite (WAL) | Zero infra. Mount a volume at dataDir so state survives restarts. Vertical scale. |
| Multi node | Postgres | N replicas, one config line. Cross-node claims, scheduler election, and recovery are handled by the engine. |
| Embedded | either | Mount into an existing server via toNodeHandler() / toFetchHandler() (Next.js, Hono, Bun, edge). |
Secrets & config#
- Resolve secrets with
resolveSecret("env:NAME")orresolveSecret("file:/run/secrets/key")(Docker/k8s secret mounts) — never hard-code keys. redactSecrets(obj)deep-masks tokens, passwords, and connection-URL credentials before you log a config/state dump.validateConfig(options)runs atstart()and fails fast on misconfiguration.
Encryption & retention#
- Encrypt at rest: set
encryptionKeyand job payloads, run inputs/outputs, step results, and event payloads are AES-256-GCM encrypted in the store. Transparent to enable on an existing DB. - Retention: a background sweep deletes aged terminal runs and old events so the store never grows unbounded — set
retentionwindows. On Postgres the event outbox is range-partitioned, so GC reclaims space by dropping partitions.
Health & graceful shutdown#
/healthz (liveness, zero I/O) and /readyz (readiness — store reachable, 503 until ready) are auto-registered. Gate load balancers and rolling deploys on /readyz. On SIGTERM, app.stop() drains in-flight HTTP and jobs:
// SIGTERM (k8s rollout) → app.stop() drains HTTP + in-flight jobs.
// handleSignals: true (default) wires this for you. To drain manually:
process.on("SIGTERM", async () => {
const clean = await app.stop({ timeout: "30s", httpDrain: "10s" });
process.exit(clean ? 0 : 1);
});App security#
| Concern | Built-in |
|---|---|
| AuthN | zenzip.auth() — Bearer / API key, or a verify() JWT/OIDC seam |
| Validation | zenzip.validate() — Standard Schema body/query → auto-400 |
| Headers | zenzip.secureHeaders() — nosniff, frame-options, HSTS, opt-in CSP |
| CSRF | zenzip.csrf() — origin/referer guard for state-changing methods |
| Rate limit | zenzip.rateLimit() — per-IP/key fixed window → 429 |
| SSRF | assertPublicUrl() on user-controlled fetches |
| Dashboard RBAC | operator vs read-only tokens |
| Audit | onAudit — privileged actions (trigger/cancel/requeue/approve/purge) |
Logs, errors, alerts#
- Logs: pipe engine logs into your stack with
pinoLogger(pino())orwinstonLogger(w). - Errors:
onError: sentryReporter(Sentry)captures background-loop + log errors; mountcaptureErrors(reporter)as error middleware for HTTP. - Alerts:
alertsfiresonAlerton dead-letter-queue growth and stuck runs — wire it to PagerDuty/Slack. Inspect stalls withapp.orphanedRuns().
Multi-tenancy & PII#
- Namespaces:
const t = app.namespace(tenantId)scopes every queue, workflow, schedule, agent, and event with atenantId:prefix — one tenant's events never wake another's triggers. (Logical isolation within one store; for hard isolation give each tenant its own database.) - PII erasure: tag runs with a
subjectand erase on request.
// Tag runs with a data subject at trigger time…
await onboarding.trigger({ userId }, { subject: userId });
// …then honor a GDPR erasure request:
const removed = await app.purgeSubject(userId); // deletes that user's runs + stepsScaling to Postgres#
Point the same API at Postgres and run N replicas — claims use SKIP LOCKED, cross-node wakeups ride LISTEN/NOTIFY, and the scheduler elects a leader via an advisory lock. Bound the pool and statement timeouts are pre-set for fail-fast recovery. Use a separate read replica for the dashboard if needed. Validated by a 3-node kill-a-node chaos test.
Containers & Kubernetes#
Reference artifacts live in deploy/: a multi-stage Dockerfile (Alpine via musl prebuilds, non-root, HEALTHCHECK), a Kubernetes StatefulSet + Service with /healthz·/readyz probes and a drain preStop, and a parameterized Helm chart. Single-node keeps replicas: 1 with a PVC; multi-node sets Postgres and scales out. See Architecture.
Production checklist#
- ✅ Secrets via env/file, not literals;
encryptionKeyset if you store PII. - ✅
retentionwindows set so the store is bounded. - ✅ Load balancer / k8s probes gated on
/readyz;SIGTERMdrains. - ✅
auth/validate/secureHeaders/csrf/rateLimiton public routes; dashboard behind RBAC tokens. - ✅
logger+onError+alertswired to your stack. - ✅ Postgres for >1 replica; SQLite volume mounted otherwise.
- ✅ A
purgeSubjectpath for data-erasure requests.