› DOCUMENTATION · v0.8.1

Documentation

v0.8.1 — every field, every edge case.

› 01 / Getting started

Getting started

ezx ships as a single static Go binary. Prebuilt binaries are on the GitHub releases page, or build from source:

sh
# Linux x86_64 — download v0.8.1 from GitHub releases
curl -LO https://github.com/supanadit/ezx/releases/download/0.8.1/ezx-0.8.1-linux-amd64
mv ezx-0.8.1-linux-amd64 ezx
chmod +x ezx

# or build from the repo root
go build -o ezx ./app

Your first entrypoint

A script is plain JavaScript run by ezx's Go-hosted JS engine (goja). It reads env, provisions files, and declares a dependency graph:

js
// entrypoint.js
const { env, chain, log } = require("ezx");

chain.run({
  nodes: [
    {
      name: "app",
      process: {
        binaryPath: "/bin/sh",
        arguments: ["-c", "echo 'GREETING='$GREETING"],
        environment: ["GREETING=Hello from ezx"],
        filterEnv: ["PHP_MEMORY_LIMIT"],
      },
    },
    {
      name: "sidecar",
      dependsOn: ["app"],
      needParentReady: true,
      process: { binaryPath: "/bin/sh", arguments: ["-c", "echo sidecar ready"] },
    },
  ],
});

log.info("entrypoint complete");
sh
./ezx bootstrap entrypoint.js

That is the whole model: one binary, one declarative script, zero shell. The rest of this page is a reference for everything the script can declare.

› 02 / Dependency graph

The dependency graph

chain.run({ nodes: [...] }) hands a flat list of ProcessNodes to the orchestrator, connected by explicit dependsOn edges, which spawns and supervises the whole graph. Each node:

FieldTypeWhat it does
namestringUnique identifier. Used by scheduler.trigger / scheduler.status and in logs.
processProcessThe executable: binaryPath, arguments, env, filters, user/group, workingDir, log.
filesFileProvision[]Declarative file provisioning, applied before this process starts.
dependsOnstring[]Dependencies this node waits for before starting (the flat nodes/dependsOn form).
dependsOnEdgesEdge[]Per-edge wait modes: { name, waitFor: "started"|"ready"|"exit" }. Each edge independently decides when a dependent may start.
needParentReadyboolLegacy sugar: wait until the dependency's readiness passes before starting — exactly dependsOnEdges: [{ name, waitFor: "ready" }] (default false).
oneshotboolRun to completion; signals started + ready only on exit 0. Dependents start only after it finishes.
readinessProbeReadiness probe checked after Start. Dependents with needParentReady / waitFor: "ready" wait for it.
readinessFuncJS functionJS callback alternative: readinessFunc: () => true. Overrides the declarative probe. Must be short and non-blocking.
restartRestartPolicyRestart on failure: { mode, maxRetries, backoff }. Default: never restart.
shutdownShutdownConfigGraceful drain: { signal, timeout, forceKill }. Default: SIGTERM, 30s timeout, force-kill.
optionalboolNon-fatal sidecar: if it fails (and is not restarted), the error is logged but the graph and container survive. Default false (fatal).
execboolMode A: syscall.Exec this process as PID 1. Leaf-only, mutually exclusive with health.
schedulerSchedulerConfigCron-driven node: runs process on each tick instead of once. Mutually exclusive with exec and health.
healthHealthConfigDrive the shared /readyz from this node: ready only while readyProbe passes. Mutually exclusive with exec.
forwardSignalsstring[]Signals relayed from ezx (PID 1) to this process's group, e.g. ["SIGTERM", "SIGINT", "SIGHUP"].

Ordering semantics

  • A node starts after all of its dependencies have started.
  • dependsOnEdges lets each edge independently wait for started (default), ready, or exit. The legacy dependsOn: ["a"] + needParentReady: true is sugar for dependsOnEdges: [{ name: "a", waitFor: "ready" }] and still works.
  • Sibling / independent nodes start in parallel; multi-root chains start concurrently.
  • A oneshot node (oneshot: true) runs to completion and signals success only on exit 0, so its dependents start after it finishes.
  • When a dependency exits permanently, its running dependents drain and its not-yet-started dependents are skipped.
  • When the app shuts down (SIGTERM/SIGINT), the whole graph is drained: nodes get their shutdown signal, then force-kill per config.
js
const { chain } = require("ezx");

chain.run({
  nodes: [
    { name: "postgres", process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"] } },
    {
      name: "pgbouncer",
      dependsOn: ["postgres"],
      needParentReady: true,   // waits until postgres readiness passes
      process: { binaryPath: "pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"] },
      restart: { mode: "always", backoff: 2e9 },
    },
  ],
});

› 03 / Oneshot services

Oneshot services & init DAG → exec main

A oneshot node (oneshot: true) runs its process to completion instead of supervising it as a long-running service. It is the declarative form of the imperative process.run one-shot, composed into the dependency graph:

  • Exit 0 = success gate. A oneshot signals started + ready only when it exits 0, so its dependents start only after it finishes successfully. A non-zero exit fails the node (fatal unless optional), skipping its dependents.
  • Retryable. restart on a oneshot retries it on failure up to MaxRetries (the classic run-to-completion supervisor model).
  • Mutually exclusive with exec, scheduler, health, and needParentReady (a oneshot is "ready" only when it exits 0) — validated fail-fast at chain validation.

The headline pattern is "init DAG → exec main": a chain of init steps (initdb, stanza-create, migrations) run to completion, then the main long-running app becomes PID 1. The exec node fires only after all its oneshot deps exit 0 and no other long-running node is still supervised:

js
const { chain } = require("ezx");

const PGDATA = "/var/lib/postgresql/data";

chain.run({
  nodes: [
    {
      name: "initdb",
      oneshot: true,
      process: { binaryPath: "/usr/local/bin/initdb", arguments: ["--username=postgres", "--pwfile=/tmp/ezx-pwfile", PGDATA], user: "postgres", group: "postgres" },
    },
    {
      name: "stanza-create",
      oneshot: true,
      dependsOn: ["initdb"],
      process: { binaryPath: "/usr/bin/pgbackrest", arguments: ["stanza-create", "--stanza=main", "--pg1-path=" + PGDATA] },
    },
    {
      name: "postgres",
      exec: true,
      dependsOn: ["initdb", "stanza-create"],
      process: { binaryPath: "/usr/local/bin/postgres", arguments: ["-D", PGDATA], user: "postgres", group: "postgres" },
    },
  ],
});

Try it: ./ezx bootstrap examples/bootstrap/oneshot-init.js.

› 04 / Logging & rotation

Per-service logging with rotation

Each supervised process can route its stdout/stderr to its own file-backed log destination with size-based rotation. Set log.stdout / log.stderr to "file" and point filePath at the target:

js
process: {
  binaryPath: "/usr/bin/pgbouncer",
  arguments: ["/etc/pgbouncer/pgbouncer.ini"],
  log: {
    stdout: "file",                 // route stdout to a rotating file
    filePath: "/var/log/ezx/pgbouncer.log",
    maxBytes: 5 * 1024 * 1024,      // rotate at 5 MiB (default 10 MiB)
    maxBackups: 2,                  // keep .log + .1 + .2 (default 3)
    stderr: "stderr",               // stderr still goes to container stderr
  },
},
  • Rotation is size-based: when a write would push the active file past maxBytes, it is closed, shifted to .1 (newest), older backups shift down, and backups beyond maxBackups are dropped. maxBackups < 0 keeps backups indefinitely. There is no time-based rotation or compression.
  • Append on open — a restarted node appends to the current file; rotation is purely size-driven.
  • Shared path — when both stdout and stderr are "file" with the same filePath, they share one writer so interleaving stays coherent and rotation happens once. Different paths get independent writers.
  • Validation — a "file" destination requires a non-empty filePath (fail-fast at chain validation, naming the node).

Try it: ./ezx bootstrap examples/bootstrap/log-rotation.js.

› 05 / Probes

Probes & readiness

A Probe is the readiness/health primitive used for node readiness, scheduler gate, and per-node health.readyProbe. Real probes replace sleep 5.

FieldTypeWhat it does
type"exec" | "tcp" | "http"Which check runs. Empty defaults to "exec".
execstring[]Command + args. Ready when it exits 0.
execExpectstringAdditionally require this substring on the probe's stdout (e.g. pg_is_in_recovery() = f).
execExpectExactboolTrimmed stdout must equal execExpect exactly, not just contain it.
tcp{ host, port }Dial target for TCP probes.
http{ url, expectStatus }HTTP GET target; expectStatus 0 accepts any 2xx.
intervalduration (ns)Wait between attempts, e.g. 1e9 = 1s.
timeoutduration (ns)Per-attempt timeout, e.g. 5e9 = 5s.
maxAttemptsintGive up after this many failed attempts.
js
readiness: { type: "exec",  exec: ["pg_isready", "-h", "/run/postgresql"], interval: 1e9, timeout: 5e9, maxAttempts: 30 }
readiness: { type: "tcp",   tcp: { host: "127.0.0.1", port: 5432 } }
readiness: { type: "http",  http: { url: "http://127.0.0.1:8008/master", expectStatus: 200 } }

// execExpect: exit code 0 is not enough — the output must match too.
readiness: {
  type: "exec",
  exec: ["psql", "-c", "select pg_is_in_recovery()"],
  execExpect: "f",   // only the primary is "ready"
}

› 06 / Env → config files

Env → config files (declarative provisioning)

Nodes take a files array of FileProvision rules. Each rule targets one file and runs an ordered list of operations — replacing the sed -i pipeline in classic entrypoints. Provisioning runs before the node's process starts.

js
// POSTGRESQL_CONFIG_LOG_DESTINATION=stderr
// becomes log_destination = 'stderr' in postgresql.conf
const { chain } = require("ezx");

chain.run({
  nodes: [{
    name: "postgres",
    files: [{
      path: "/etc/postgresql/postgresql.conf",
      operations: [{
        type: "set-property",
        fromEnvPattern: "^POSTGRESQL_CONFIG_(.+)$",
        nameTransform: "lower",
        pattern: "^\\s*#?\\s*${name}\\s*=.*",
        value: "${name} = ${value}",
        valueFormat: "auto",
      }],
    }],
    // files are provisioned before the process starts
    process: {
      binaryPath: "postgres",
      arguments: ["-D", "/var/lib/postgresql/data"],
    },
  }],
});

Operation types

typeBehaviour
replaceReplace the entire file content with Value/FromEnv. Creates the file if missing.
appendAppend Value/FromEnv as new line(s) at the end.
removeRemove all lines matching Pattern (regex).
ensureEnsure a line equal to Value exists; append if not found.
set-propertyRemove lines matching Pattern, then append Value. Remove + Ensure — the most common config mutation. Idempotent.
insert-beforeInsert Value before the first line matching Pattern. Not idempotent — for run-once startup.
insert-afterInsert Value after the first line matching Pattern. Not idempotent — for run-once startup.
replace-blockReplace the block from Pattern to BlockEnd with Value. Idempotent.
set-blockRemove the block if present, then insert before Marker (or append). Idempotent — the PHP-config injection op.
copyCopy Source to the rule's path. No content transformation.

Shared operation fields

FieldWhat it does
patternRegex for remove / set-property / insert-* / block ops. Interpolates ${name}, ${1}, ${2} when fromEnvPattern is set.
blockEnd / markerEnd regex and fallback insertion point for block ops.
valueLiteral with ${ENV_VAR}, ${name}, ${value}, ${1} interpolation.
fromEnvUse this env var's value as ${value}.
fromEnvPatternEnumerate env vars matching a regex; the op repeats once per match.
nameTransform"lower" | "upper" | "snake-to-dot" | "snake-to-camel" | "snake-to-kebab" — transforms the captured name.
valueFormat"auto" smart-quotes (numbers, on|off|true|false, unit-suffixed stay bare; strings get single quotes). "quote" always quotes. Default raw.
when{ name, value } env condition gating the op. Empty value means "any non-empty".
createOnlySkip if the target file already exists — the if [ ! -f ] guard for default configs.
permission / ownerFile mode (e.g. 0640) and user:group applied after writing.

› 07 / Env → CLI args

Env → CLI arguments

argOperations build CLI flags from environment variables — the argument-side mirror of file operations. They run in slice order and are appended after arguments.

js
// PROMETHEUS_ENABLE_WEB_LIFECYCLE=true
// THANOS_QUERY_STORE_ADDRESSES=store1:10901,store2:10901
// becomes: --web.enable-lifecycle --endpoint=store1:10901 --endpoint=store2:10901
const { chain } = require("ezx");

chain.run({
  nodes: [{
    name: "thanos",
    process: {
      binaryPath: "thanos",
      argOperations: [
        { when: { name: "PROMETHEUS_ENABLE_WEB_LIFECYCLE", value: "true" }, flag: "--web.enable-lifecycle", format: "bare-flag" },
        { flag: "--endpoint", fromEnv: "THANOS_QUERY_STORE_ADDRESSES", split: "," },
      ],
    },
  }],
});

Operation forms

PatternProduces
{ flag, fromEnv }if-set value: --flag=value once, only when the var is non-empty.
{ when: {name, value}, flag, format: "bare-flag" }if-truthy toggle: --flag only when the var equals value.
{ when: {...}, flag, value }conditional flag+literal: --enable-feature=native-histograms.
{ flag, fromEnv, split: "," }list split: one --endpoint=<el> per comma-separated element.
{ flag, fromEnvPattern: "^X_(.+)$", value: '${name}="${value}"', nameTransform: "lower" }pattern-enum: one arg per matching env var, capture group 1 as ${name}.
{ fromEnv, split: " ", format: "raw" }raw pass-through: whitespace-split extra args, no flag prefix.
  • format defaults to "flag-value" (--flag=value). Other formats: "flag-space" (two args), "bare-flag" (no value), "raw" (value only).
  • ${VAR:-default} interpolation works inside arguments entries too, e.g. "--web.listen-address=:${PROMETHEUS_PORT:-9090}".
  • value templates interpolate ${VAR}, ${VAR:-default}, ${value}, ${name}, ${1}.

› 08 / Builders

Config & YAML builders

Structured objects in, deterministic text out — no template-string concatenation, byte by byte.

config.build — key=value, tables, INI

js
const { config } = require("ezx");

// 1. Flat key=value (postgresql.conf) — auto value formatting
const postgresql = config.build({
  comment: "PostgreSQL configuration generated by container",
  separator: " = ",
  valueFormat: "auto",
  properties: [["listen_addresses", "*"], ["port", 5432], ["shared_buffers", "256MB"]],
});

// 2. Table/matrix (pg_hba.conf) — column-aligned rows
const pgHba = config.build({
  rows: [["local", "all", "postgres", "trust"], ["host", "all", "all", "0.0.0.0/0", "md5"]],
});

// 3. INI sections (pgbackrest.conf)
const pgbackrest = config.build({
  separator: "=",
  sections: { global: { "log-level-console": "info" }, default: { "pg1-port": 5432 } },
});

config.builder — fluent & conditional

js
const b = config.builder({ separator: "=", valueFormat: "none" });
const srv = b.section("server");
srv.set("port", 8080);
srv.setIf("host", env.get("HOST", "")); // skipped when empty
srv.setFlag("tls", true);               // tls=y (skipped when false)

yaml.build — deterministic YAML

js
const { yaml } = require("ezx");
const doc = yaml.build({
  scope: "postgres-cluster",
  postgresql: { listen: "0.0.0.0:5432", parameters: { hot_standby: "on" } },
});
// scope: postgres-cluster
// postgresql:
//   listen: 0.0.0.0:5432
//   parameters:
//     hot_standby: "on"

› 09 / Script API

Imperative modules

Everything under require("ezx"). Use the declarative chain where you can; drop to these modules for logic that cannot be expressed declaratively.

ModuleMethods (verified against v0.8.1)
envget(name, def?), has(name), isTruthy(name, def?), bool(name, def?), int(name, def?), list(name, sep, def?), normalizeBool(v), all()
fsexists, stat, mkdir, mkdirAll, ensureDir, chmod, chmodRecursive, chown, chownRecursive, readDir, glob, remove, removeAll, rename, symlink, realpath, tempFile, tempDir, umask, write, which, copy, copyTree
editoropen(path) → upsert, replace, append, ensure, remove, insertBefore, insertAfter, replaceBlock, setBlock, read, readLines, writeLines
processspawn(node) → handle, run(opts), capture(opts), exec(node), shell(cmd, opts), sleep(ns)
shellquote(value) — single-quote-escape for safe shell embedding
probetcp(host, port), http(url, expectStatus), exec(cmd...) — boolean one-shot checks
logdebug/info/warn/error(format, ...args), enabled(level)
chainrun({ nodes }) — the declarative supervisor (see Dependency graph). Flat nodes + dependsOn / dependsOnEdges edges.
configbuild(spec), builder(opts) — see Builders
yamlbuild(object) — deterministic YAML serialization
schedulerbuild(cfg), parse(expr), every(expr, fn, opts?), trigger(name), status(name)
healthsetReady(bool), ready()
apiget(path, handler), post(path, handler), put/delete — routes on the shared health server
archivecreate(format, dest, sources), extract(src, dest, opts?) — native tar/tar.gz/tar.zst/zip, pure Go stdlib
cryptosha256(input), sha256File(path), base64Encode(input), base64Decode(input), randomHex(bytes)
nethttp(method, url, opts?), download(url, dest, opts?) — native HTTP client with timeout, redirects, optional SHA-256 verification

One-shot processes & handles

js
const { process, log } = require("ezx");

// One-shot with inherited output; blocks until exit, returns the code.
const code = process.run({ name: "init", process: { binaryPath: "/bin/sh", arguments: ["-c", "echo hi"] } });

// Capture output; check: true throws on non-zero exit.
const { stdout } = process.capture({
  process: { binaryPath: "pg_ctl", arguments: ["status"] },
  check: true,
});

// Long-running spawn returns a handle.
const h = process.spawn({ name: "app", process: { binaryPath: "/bin/app" } });
h.start([]);                // start (idempotent); env array appended after inherited
log.info("pid=%d", h.pid());
h.wait();                   // blocks → exit code
h.signal("SIGTERM");        // TERM | INT | KILL | QUIT | HUP only

Privilege drop (initdb pattern)

js
// Run initdb as the postgres user, like the official entrypoint.
process.run({
  name: "initdb",
  process: {
    binaryPath: "initdb",
    arguments: ["-D", PGDATA],
    user: "postgres",           // name or numeric UID
    group: "postgres",          // optional; default = user's primary group
  },
  check: true,
});

› 10 / Scheduler

Cron scheduler with gates

A node with a scheduler runs its process on each cron tick instead of once at startup — the cron sidecar container, gone. Ticks can be gated on a probe (e.g. only back up while the primary role is healthy) and fired manually from the runtime API.

js
const { chain, scheduler } = require("ezx");

chain.run({
  nodes: [
    { name: "postgres", process: { binaryPath: "postgres", arguments: ["-D", "/var/lib/postgresql/data"] } },
    {
      name: "hourly-backup",
      dependsOn: ["postgres"],
      needParentReady: true,
      process: { binaryPath: "pgbackrest", arguments: ["--config=/etc/pgbackrest.conf", "backup"] },
      scheduler: scheduler.build({
        schedule: { expression: "0 * * * *", timezone: "UTC" },
        minInterval: 60e9,   // dedupe: never run twice within 60s
        gate: { type: "exec", exec: ["pg_isready", "-h", "/run/postgresql"] },
      }),
    },
  ],
});
Option / methodWhat it does
schedule.expression5-field cron expression, e.g. "0 2 * * *".
schedule.timezoneIANA name ("UTC", "Asia/Jakarta"). Empty = process local time.
initialDelayWait before the first tick evaluation (nanoseconds). Zero = start immediately.
minIntervalDedupe window: a job never runs twice within it. Zero defaults to one minute.
gateProbe checked before each tick; a failing probe skips that tick (the loop continues).
maxRetriesCaps consecutive per-tick restarts of the tick process; <= 0 means unlimited.
scheduler.every(expr, fn, opts?)JS callback on each tick instead of spawning a process. Must be short and non-blocking.
scheduler.trigger(name)Manual fire-and-forget tick (e.g. from an api route).
scheduler.status(name)Returns { exists, inflight }.

› 11 / Health & API

Health endpoints & runtime API

ezx serves /readyz, /livez and /healthz on EZX_HEALTH_ADDR (default :8080). The same server hosts user-defined routes via api — no sidecar needed.

js
const { api, scheduler, health, chain } = require("ezx");

// Flip readiness from script logic (drives /readyz and /healthz).
health.setReady(true);

// Register api routes BEFORE chain.run — the script blocks inside chain.run,
// so anything after it never executes. Handlers return plain values (JSON).
api.post("/backup/full", () => { scheduler.trigger("hourly-backup"); return { ok: true }; });
api.get("/backup/status", () => scheduler.status("hourly-backup"));

// Per-node readiness: /readyz reports ready only while the probe passes.
chain.run({
  nodes: [{
    name: "app",
    process: { binaryPath: "/bin/app" },
    health: { readyProbe: { type: "http", http: { url: "http://127.0.0.1:3000/healthz" } } },
  }],
});
  • /livez — is the process alive.
  • /readyz, /healthz — readiness state, driven by health.setReady() and/or a node's health.readyProbe (falling back to its readiness probe).

› 12 / Launch modes

Launch modes

Every node chooses how its main process launches — as an alternative to tini/dumb-init, or with your app as PID 1.

Mode A — execMode B — supervised
PID 1Your app (via syscall.Exec)ezx
SignalsNative, handled by your appForwarded by ezx (forwardSignals) + reaping
Restart / health / schedulerNone — ezx vanishes after execFull: restart, health, scheduler, tree
ConstraintsLeaf node only; no active supervised siblings; mutually exclusive with healthNone
Set withexec: true — or a lone node, which is auto-exec'd (see below)restart / health / scheduler / forwardSignals, or execDefault: false

› 13 / Recipes

Use cases & recipes

Each recipe is runnable — the full versions live in the repo under examples/bootstrap/.

1. Gated dependency graph (postgres → pgbouncer → pgpool)

Flat nodes + dependsOn edges; needParentReady replaces the $PARENT_PID polling hack. Siblings (etcd, pgbouncer) start in parallel.

js
chain.run({
  nodes: [
    { name: "postgresql", process: { binaryPath: "/bin/sh", arguments: ["-c", "start-postgres"] } },
    { name: "etcd",       dependsOn: ["postgresql"], process: { binaryPath: "etcd" } },
    { name: "pgbouncer",  dependsOn: ["postgresql"], needParentReady: true,
      process: { binaryPath: "pgbouncer", arguments: ["/etc/pgbouncer/pgbouncer.ini"] } },
    { name: "pgpool",     dependsOn: ["pgbouncer"], needParentReady: true, process: { binaryPath: "pgpool" } },
  ],
});

2. Secrets handling for pgBackRest

pgBackRest breaks if PGBACKREST_* vars leak into its child. Consume them into config, then strip them from the spawned env:

js
chain.run({
  nodes: [{
    name: "pgbackrest",
    process: {
      binaryPath: "pgbackrest",
      arguments: ["--config=/etc/pgbackrest.conf", "backup"],
      filterEnvPattern: ["^PGBACKREST_"],   // strip consumed secrets before spawn
      environment: ["KEEP=kept"],           // additive entries survive filtering
    },
  }],
});

3. Scheduled backup, role-gated, manually triggerable

The cron + gate + API combo from the production containers project — a 1:1 port of a bash entrypoint into a few readable lines:

js
const { api, scheduler } = require("ezx");

// Only tick while this node is the primary (http gate).
node.scheduler = scheduler.build({
  schedule: { expression: "0 2 * * *", timezone: "UTC" },
  initialDelay: 120e9,
  gate: { type: "http", http: { url: "http://localhost:8008/master" } },
});

// in a real script, register api routes BEFORE chain.run
api.post("/backup/full", () => { scheduler.trigger("backup-full"); return { ok: true }; });
api.get("/backup/status", () => scheduler.status("backup-full"));

4. First-run init + exec (init DAG → exec main)

The official postgres entrypoint flow, declaratively: init steps run to completion as oneshot nodes, then the main app becomes PID 1 via exec only after they all exit 0:

js
const { chain } = require("ezx");

const PGDATA = "/var/lib/postgresql/data";

chain.run({
  nodes: [
    {
      name: "initdb",
      oneshot: true,
      process: { binaryPath: "/usr/local/bin/initdb", arguments: ["--username=postgres", "--pwfile=/tmp/ezx-pwfile", PGDATA], user: "postgres", group: "postgres" },
    },
    {
      name: "pg-hba",
      oneshot: true,
      dependsOn: ["initdb"],
      process: { binaryPath: "/bin/sh", arguments: ["-c", "echo 'local all all trust' >> " + PGDATA + "/pg_hba.conf"] },
    },
    {
      name: "postgres",
      exec: true,
      dependsOn: ["initdb", "pg-hba"],
      process: { binaryPath: "/usr/local/bin/postgres", arguments: ["-D", PGDATA], user: "postgres", group: "postgres" },
    },
  ],
});
// the exec node never returns — postgres is now PID 1 with native signal handling

5. Deterministic Patroni YAML

js
const { yaml, editor } = require("ezx");
const patroni = yaml.build({
  scope: "postgres-cluster",
  postgresql: { listen: "0.0.0.0:5432", parameters: { hot_standby: "on" } },
});
editor.open("/etc/patroni/patroni.yml").replace(patroni);

› 14 / Edge cases

Edge cases & gotchas

Everything here is verified behaviour of v0.8.1 — the stuff you would otherwise discover at 2 a.m. inside a container.

› 15 / Status

Status

ezx is pre-1.0 and in active use. The require("ezx") surface is additive-only: existing modules and method names are frozen, additions are allowed, and any unavoidable breaking change ships as require("ezx/v2") with the prior surface preserved.

The supervision model draws on the init-supervisor tradition (tini, dumb-init, s6-overlay): dependency graph, per-edge wait modes, oneshot services, init DAG → exec main, per-service logging with rotation, exit-code propagation, restart, health, and scheduling — declared in JavaScript, no shell.

Apache License 2.0 — © 2026 Supan Adit Pratama.

Ready to try it?

One binary, one declarative script, zero shell.

Get started → Browse examples