ZenZip logozenzip

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.

app.ts
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#

ModelStoreNotes
Single nodeembedded SQLite (WAL)Zero infra. Mount a volume at dataDir so state survives restarts. Vertical scale.
Multi nodePostgresN replicas, one config line. Cross-node claims, scheduler election, and recovery are handled by the engine.
EmbeddedeitherMount into an existing server via toNodeHandler() / toFetchHandler() (Next.js, Hono, Bun, edge).

Secrets & config#

  • Resolve secrets with resolveSecret("env:NAME") or resolveSecret("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 at start() and fails fast on misconfiguration.

Encryption & retention#

  • Encrypt at rest: set encryptionKey and 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 retention windows. 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:

shutdown.ts
// 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#

ConcernBuilt-in
AuthNzenzip.auth() — Bearer / API key, or a verify() JWT/OIDC seam
Validationzenzip.validate() — Standard Schema body/query → auto-400
Headerszenzip.secureHeaders() — nosniff, frame-options, HSTS, opt-in CSP
CSRFzenzip.csrf() — origin/referer guard for state-changing methods
Rate limitzenzip.rateLimit() — per-IP/key fixed window → 429
SSRFassertPublicUrl() on user-controlled fetches
Dashboard RBACoperator vs read-only tokens
AuditonAudit — privileged actions (trigger/cancel/requeue/approve/purge)

Logs, errors, alerts#

  • Logs: pipe engine logs into your stack with pinoLogger(pino()) or winstonLogger(w).
  • Errors: onError: sentryReporter(Sentry) captures background-loop + log errors; mount captureErrors(reporter) as error middleware for HTTP.
  • Alerts: alerts fires onAlert on dead-letter-queue growth and stuck runs — wire it to PagerDuty/Slack. Inspect stalls with app.orphanedRuns().

Multi-tenancy & PII#

  • Namespaces: const t = app.namespace(tenantId) scopes every queue, workflow, schedule, agent, and event with a tenantId: 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 subject and erase on request.
gdpr.ts
// 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 + steps

Scaling 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; encryptionKey set if you store PII.
  • retention windows set so the store is bounded.
  • ✅ Load balancer / k8s probes gated on /readyz; SIGTERM drains.
  • auth/validate/secureHeaders/csrf/rateLimit on public routes; dashboard behind RBAC tokens.
  • logger + onError + alerts wired to your stack.
  • ✅ Postgres for >1 replica; SQLite volume mounted otherwise.
  • ✅ A purgeSubject path for data-erasure requests.