› 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:
# 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 ./appYour 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:
// 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");./ezx bootstrap entrypoint.jsThat 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:
| Field | Type | What it does |
|---|---|---|
| name | string | Unique identifier. Used by scheduler.trigger / scheduler.status and in logs. |
| process | Process | The executable: binaryPath, arguments, env, filters, user/group, workingDir, log. |
| files | FileProvision[] | Declarative file provisioning, applied before this process starts. |
| dependsOn | string[] | Dependencies this node waits for before starting (the flat nodes/dependsOn form). |
| dependsOnEdges | Edge[] | Per-edge wait modes: { name, waitFor: "started"|"ready"|"exit" }. Each edge independently decides when a dependent may start. |
| needParentReady | bool | Legacy sugar: wait until the dependency's readiness passes before starting — exactly dependsOnEdges: [{ name, waitFor: "ready" }] (default false). |
| oneshot | bool | Run to completion; signals started + ready only on exit 0. Dependents start only after it finishes. |
| readiness | Probe | Readiness probe checked after Start. Dependents with needParentReady / waitFor: "ready" wait for it. |
| readinessFunc | JS function | JS callback alternative: readinessFunc: () => true. Overrides the declarative probe. Must be short and non-blocking. |
| restart | RestartPolicy | Restart on failure: { mode, maxRetries, backoff }. Default: never restart. |
| shutdown | ShutdownConfig | Graceful drain: { signal, timeout, forceKill }. Default: SIGTERM, 30s timeout, force-kill. |
| optional | bool | Non-fatal sidecar: if it fails (and is not restarted), the error is logged but the graph and container survive. Default false (fatal). |
| exec | bool | Mode A: syscall.Exec this process as PID 1. Leaf-only, mutually exclusive with health. |
| scheduler | SchedulerConfig | Cron-driven node: runs process on each tick instead of once. Mutually exclusive with exec and health. |
| health | HealthConfig | Drive the shared /readyz from this node: ready only while readyProbe passes. Mutually exclusive with exec. |
| forwardSignals | string[] | 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.
dependsOnEdgeslets each edge independently wait forstarted(default),ready, orexit. The legacydependsOn: ["a"]+needParentReady: trueis sugar fordependsOnEdges: [{ 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.
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+readyonly when it exits 0, so its dependents start only after it finishes successfully. A non-zero exit fails the node (fatal unlessoptional), skipping its dependents. - Retryable.
restarton a oneshot retries it on failure up toMaxRetries(the classic run-to-completion supervisor model). - Mutually exclusive with
exec,scheduler,health, andneedParentReady(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:
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:
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 beyondmaxBackupsare dropped.maxBackups < 0keeps 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
stdoutandstderrare"file"with the samefilePath, they share one writer so interleaving stays coherent and rotation happens once. Different paths get independent writers. - Validation — a
"file"destination requires a non-emptyfilePath(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.
| Field | Type | What it does |
|---|---|---|
| type | "exec" | "tcp" | "http" | Which check runs. Empty defaults to "exec". |
| exec | string[] | Command + args. Ready when it exits 0. |
| execExpect | string | Additionally require this substring on the probe's stdout (e.g. pg_is_in_recovery() = f). |
| execExpectExact | bool | Trimmed 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. |
| interval | duration (ns) | Wait between attempts, e.g. 1e9 = 1s. |
| timeout | duration (ns) | Per-attempt timeout, e.g. 5e9 = 5s. |
| maxAttempts | int | Give up after this many failed attempts. |
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.
// 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
| type | Behaviour |
|---|---|
| replace | Replace the entire file content with Value/FromEnv. Creates the file if missing. |
| append | Append Value/FromEnv as new line(s) at the end. |
| remove | Remove all lines matching Pattern (regex). |
| ensure | Ensure a line equal to Value exists; append if not found. |
| set-property | Remove lines matching Pattern, then append Value. Remove + Ensure — the most common config mutation. Idempotent. |
| insert-before | Insert Value before the first line matching Pattern. Not idempotent — for run-once startup. |
| insert-after | Insert Value after the first line matching Pattern. Not idempotent — for run-once startup. |
| replace-block | Replace the block from Pattern to BlockEnd with Value. Idempotent. |
| set-block | Remove the block if present, then insert before Marker (or append). Idempotent — the PHP-config injection op. |
| copy | Copy Source to the rule's path. No content transformation. |
Shared operation fields
| Field | What it does |
|---|---|
| pattern | Regex for remove / set-property / insert-* / block ops. Interpolates ${name}, ${1}, ${2} when fromEnvPattern is set. |
| blockEnd / marker | End regex and fallback insertion point for block ops. |
| value | Literal with ${ENV_VAR}, ${name}, ${value}, ${1} interpolation. |
| fromEnv | Use this env var's value as ${value}. |
| fromEnvPattern | Enumerate 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". |
| createOnly | Skip if the target file already exists — the if [ ! -f ] guard for default configs. |
| permission / owner | File 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.
// 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
| Pattern | Produces |
|---|---|
| { 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. |
formatdefaults to"flag-value"(--flag=value). Other formats:"flag-space"(two args),"bare-flag"(no value),"raw"(value only).${VAR:-default}interpolation works insideargumentsentries too, e.g."--web.listen-address=:${PROMETHEUS_PORT:-9090}".valuetemplates 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
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
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
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.
| Module | Methods (verified against v0.8.1) |
|---|---|
| env | get(name, def?), has(name), isTruthy(name, def?), bool(name, def?), int(name, def?), list(name, sep, def?), normalizeBool(v), all() |
| fs | exists, stat, mkdir, mkdirAll, ensureDir, chmod, chmodRecursive, chown, chownRecursive, readDir, glob, remove, removeAll, rename, symlink, realpath, tempFile, tempDir, umask, write, which, copy, copyTree |
| editor | open(path) → upsert, replace, append, ensure, remove, insertBefore, insertAfter, replaceBlock, setBlock, read, readLines, writeLines |
| process | spawn(node) → handle, run(opts), capture(opts), exec(node), shell(cmd, opts), sleep(ns) |
| shell | quote(value) — single-quote-escape for safe shell embedding |
| probe | tcp(host, port), http(url, expectStatus), exec(cmd...) — boolean one-shot checks |
| log | debug/info/warn/error(format, ...args), enabled(level) |
| chain | run({ nodes }) — the declarative supervisor (see Dependency graph). Flat nodes + dependsOn / dependsOnEdges edges. |
| config | build(spec), builder(opts) — see Builders |
| yaml | build(object) — deterministic YAML serialization |
| scheduler | build(cfg), parse(expr), every(expr, fn, opts?), trigger(name), status(name) |
| health | setReady(bool), ready() |
| api | get(path, handler), post(path, handler), put/delete — routes on the shared health server |
| archive | create(format, dest, sources), extract(src, dest, opts?) — native tar/tar.gz/tar.zst/zip, pure Go stdlib |
| crypto | sha256(input), sha256File(path), base64Encode(input), base64Decode(input), randomHex(bytes) |
| net | http(method, url, opts?), download(url, dest, opts?) — native HTTP client with timeout, redirects, optional SHA-256 verification |
One-shot processes & handles
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 onlyPrivilege drop (initdb pattern)
// 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.
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 / method | What it does |
|---|---|
| schedule.expression | 5-field cron expression, e.g. "0 2 * * *". |
| schedule.timezone | IANA name ("UTC", "Asia/Jakarta"). Empty = process local time. |
| initialDelay | Wait before the first tick evaluation (nanoseconds). Zero = start immediately. |
| minInterval | Dedupe window: a job never runs twice within it. Zero defaults to one minute. |
| gate | Probe checked before each tick; a failing probe skips that tick (the loop continues). |
| maxRetries | Caps 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.
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 byhealth.setReady()and/or a node'shealth.readyProbe(falling back to itsreadinessprobe).
› 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 — exec | Mode B — supervised | |
|---|---|---|
| PID 1 | Your app (via syscall.Exec) | ezx |
| Signals | Native, handled by your app | Forwarded by ezx (forwardSignals) + reaping |
| Restart / health / scheduler | None — ezx vanishes after exec | Full: restart, health, scheduler, tree |
| Constraints | Leaf node only; no active supervised siblings; mutually exclusive with health | None |
| Set with | exec: 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.
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:
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:
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:
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 handling5. Deterministic Patroni YAML
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