# Ecosystem file cheatsheet

One file, many apps, one `3pm apply`. TOML is the native format;
`.json`, `.yaml`/`.yml` and pm2's `*.config.js|cjs|mjs` carry the same
fields (see [Other formats](#other-formats)). Every field below comes
from one table, `StartSpec` in `crates/threepm-core/src/spec.rs` — the
same names, the same units, whether it comes from the file,
`[defaults]`, a profile or a CLI flag; the
[field reference](#field-reference) below is generated from it. Unknown
keys are **errors** (with a did-you-mean), never ignored. Long-form
rationale: `FEATURES.md` → *Config workflow*.

## The whole file, annotated

Every value shown is the **default** unless the comment says otherwise;
a line you delete keeps behaving exactly as written here.

```toml
[defaults]                          # applied to every app first; may not
restart_policy = "always"           # carry name / script / profiles / env_*
log_rotate_size = "0"

[[apps]]
# ── identity ──────────────────────────────────────────────────────────
name = "api"                        # required, unique in the file
script = "./server.js"              # required; relative to the file's dir; aliases exec, cmd, command;
                                    # "npm run dev" (a blank, no such file) runs under bash -c
args = ["--port", "3000"]           # list, replaced (not merged) by profiles
interpreter = "node"                # default by extension: js/ts→node, py→python3, rb→ruby, sh→bash, pl→perl;
                                    # "none" = run the script itself; alias exec_interpreter
interpreter_args = []               # the interpreter's flags, before the script (pm2 node_args,
                                    # interpreterArgs); a string is split on blanks
cwd = "~/srv/api"                   # default: the file's directory
namespace = "backend"               # groups apps for `stop|restart|delete <ns>`
instances = 1                       # 0 = one per CPU (cluster-style)
user = "srt"                        # default unset: the daemon's own account. A ROOT daemon runs
                                    # the app as this Unix account (uid, gid, groups, clean env),
                                    # and only for a user it hosts ([users] allow); "" = the daemon's
persist = true                      # false = ephemeral: kept across restarts and `3pm update`,
                                    # never written to dump.json, so a reboot does not bring it back

# ── environment ───────────────────────────────────────────────────────
[apps.env]                          # merged by key across layers
NODE_ENV = "development"
PORT = 3000                         # bare numbers / booleans are stringified
DATA = "${HOME}/data"               # $VAR, ${VAR}, leading ~ expand; $$ = literal $
                                    # an unset variable is an error, not ""

[apps.env_production]               # pm2 sugar: env-only profile (--env production)
NODE_ENV = "production"

[apps.profiles.staging]             # full profile: ANY field (--env staging)
instances = 2
[apps.profiles.staging.env]
NODE_ENV = "staging"

[[apps]]
name = "worker"
script = "./worker.py"

# ── restart policy ────────────────────────────────────────────────────
restart_policy = "always"           # always | on_failure | never
autorestart = true                  # pm2 bool; false == "never" (contradiction = error)
max_restarts = 16                   # unstable restarts before `errored`
min_uptime = 1000                   # ms; an exit before this counts as unstable
restart_delay = 0                   # ms, fixed pause before every crash restart (0 = at once, pm2)
exp_backoff_restart_delay = 0       # ms; > 0 enables exponential backoff between crash
                                    # restarts starting at this delay (pm2 name; off by default)
backoff_factor = 1.5                #     ┐ backoff tuning (only with exp_backoff_restart_delay):
backoff_max = 15000                 # ms  │ growth per quick crash, cap, and the uptime after
backoff_reset_after = 30000         # ms  ┘ which the delay resets to the first one
stop_exit_codes = []                # exit codes that mean "stopped on purpose"
cron_restart = ""                   # "0 3 * * *" (5 or 6 fields); "" = off

# ── stop / readiness ──────────────────────────────────────────────────
kill_signal = "SIGINT"              # portable POSIX names (SIGTERM, SIGHUP, …)
kill_timeout = 1600                 # ms before SIGKILL escalation
shutdown_with_message = false       # send `shutdown` on ctl.sock before the signal
wait_ready = false                  # wait for `ready` on ctl.sock …
listen_timeout = 3000               # … at most this many ms

# ── start order ───────────────────────────────────────────────────────
depends_on = ["api"]                # default []: wait until these are online
depends_timeout = 60000             # ms; 0 = wait forever
start_order = 0                     # lower starts first among independents
start_delay = 0                     # ms before the first spawn

# ── resources ─────────────────────────────────────────────────────────
max_memory_restart = "200M"         # default unset; "512", "100K", "1G"
max_cpu_percent = 150.0             # default unset; % of one core, may exceed 100
limit_scope = "tree"                # what both judge: "tree" (the app's whole process tree) or "leader" (the spawned pid)
cgroup_enforce = false              # Linux only: hard cpu.max / memory.max
deny_gpu = false                    # hide CUDA/ROCm devices via env
ulimit_rtprio = 95                  # default unset; Linux only: RLIMIT_RTPRIO, so the app may run SCHED_FIFO up to 95
ulimit_memlock = "unlimited"        # default unset; RLIMIT_MEMLOCK: a size ("64M", "4G") or "unlimited"
ulimit_nice = 31                    # default unset; Linux only: RLIMIT_NICE, so the app's threads may go down to nice -11
ulimit_rttime = 200000              # default unset; Linux only: RLIMIT_RTTIME, µs a real-time thread may run without blocking
nice = -5                           # default unset (the daemon's); -20 (most CPU) to 19
ioprio = "idle"                     # default unset; Linux only: "idle", "best-effort[:0-7]", "realtime[:0-7]" (BFQ only)
oom_score_adj = -900                # default unset; Linux only: -1000 (never OOM-killed) to 1000 (first)
cpu_affinity = "0-3"                # default unset; Linux only: the CPUs it may run on

# ── watch ─────────────────────────────────────────────────────────────
watch = true                        # default off; true = cwd, or a path (a dir, or one file), or a list
ignore_watch = ["dist"]             # on top of the built-ins: ".*", "node_modules"
watch_delay = 0                     # ms debounce (a floor is always applied)
watch_poll = 1000                   # ms; default unset = native notifications

# ── logs ──────────────────────────────────────────────────────────────
out_file = "~/.3pm/logs/{name}-out.log"    # aliases: out, output, out_log; NULL = no file
error_file = "~/.3pm/logs/{name}-error.log"# aliases: error, err, err_file, err_log
log_file = ""                       # alias: log; one merged file for both streams
merge_logs = false                  # alias: combine_logs — instances share files
log_date_format = ""                # strftime persisted in front of each line
disable_logs = false                # no file, no stream, no crash tail
log_rotate_size = "0"               # "0" off, "10M", "1G" — per-app override of the daemon policy
log_rotate_files = 5                # generations kept (.1 .. .N) once rotation is on
log_rotate_days = 14                # 0 = keep by count only
log_rotate_compress = false         # gzip rotated generations
```

`{name}`, `{instance}`, `{namespace}` expand in log paths. A multi-instance
app whose custom path lacks `{instance}` gets `-<instance>` infixed.

## Field reference

Generated from the daemon's own table (`just gen`, source
`crates/threepm-core/src/spec.rs`): one line per field a file or a flag
may set, with the spellings it answers to, the wire name when it differs,
and the default the daemon uses when the field is absent.

<!-- spec:begin -->
| Field | Type | Default | What it sets |
|---|---|---|---|
| `name` | string | *unset* | Unique app name; the CLI defaults it to the script's file stem. |
| `script`, `exec`, `cmd`, `command` | string | *unset* | Absolute path to the script or binary (resolved client-side). |
| `args` | list | `[]` | Arguments passed to the app, after the script (`3pm start x -- a b`). |
| `interpreter`, `exec_interpreter` | string | *unset* | Absolute path to the interpreter (resolved client-side against the invoking shell's PATH — the daemon's env freezes at first spawn). `None` means direct exec of `script`; `INTERPRETER_NONE` clears it. |
| `interpreter_args`, `node_args`, `interpreterArgs` | list | `[]` | The interpreter's own flags, between it and the script (pm2's `node_args`: `node --inspect app.js`). Never without an interpreter. A string is split on blanks, like pm2. |
| `cwd` | string | *unset* | Absolute working directory. |
| `env` | map | `{}` | Values are strings on the wire; numbers and booleans in a config file (`PORT = 3000`, pm2 habit) are stringified on decode. Merged by key across layers, never replaced. |
| `namespace` | string | *unset* | Grouping label: `stop\|restart\|delete <namespace>` acts on all of them. |
| `instances` | u32 | `1` | Desired instance count (cluster mode). `0` = one per CPU, resolved by the daemon at instance-creation time. |
| `autorestart` → `restart_policy` | bool | `true` | pm2's bool: `false` is shorthand for `restart_policy = "never"`. |
| `restart_policy` | enum(always\|on_failure\|never) | `always` | Wins over `autorestart` when both are given and agree; a contradiction is a `SpecError::ConflictingRestartPolicy`. |
| `kill_signal` | string | `SIGINT` | Signal name sent on stop before escalation (pm2 default: SIGINT). |
| `wait_ready` | bool | `false` | Hold `launching` until the app signals readiness on the control socket (pm2's wait_ready); `listen_timeout_ms` bounds the wait. |
| `listen_timeout` → `listen_timeout_ms` | u64 (ms) | `3000` | Max wait for the ready signal before forcing online (pm2's GRACEFUL_LISTEN_TIMEOUT). |
| `shutdown_with_message` | bool | `false` | Deliver the graceful stop as a control-socket `shutdown` message instead of `kill_signal` (SIGKILL escalation unchanged). |
| `max_memory_restart` | size | *unset* | Restart when RSS exceeds this many bytes. Written as a human size string: "512", "100K", "200M", "1G". |
| `max_cpu_percent` | f64 (percent) | *unset* | Restart when a sampled CPU% exceeds this (soft ceiling, same observe-and-restart shape as `max_memory_restart`). Positive, finite; may exceed 100 on multi-core hosts. |
| `cgroup_enforce` | bool | `false` | Linux only: put each incarnation in its own cgroup v2 with `cpu.max` / `memory.max` derived from `max_cpu_percent` / `max_memory_restart` — a HARD kernel limit, not a restart trigger. |
| `limit_scope` | enum(tree\|leader) | `tree` | What `max_memory_restart` / `max_cpu_percent` judge: the whole process tree (the default) or the spawned pid alone. |
| `deny_gpu` | bool | `false` | Hide GPUs from the child (best effort: the CUDA / ROCm visibility env vars are set to "none"). |
| `ulimit_rtprio` | u8 | *unset* | Linux only: the app's `RLIMIT_RTPRIO` (soft = hard, 0–99), so its threads may ask for `SCHED_FIFO` / `SCHED_RR` up to that priority. Unset = inherited from the daemon (0 under a systemd unit). Above the daemon's own limit only a root daemon can grant it. |
| `ulimit_memlock` | size | *unset* | The app's `RLIMIT_MEMLOCK` (soft = hard): how much it may lock in RAM (`mlock`, real-time audio buffers). A size ("64M", "4G") or "unlimited". Unset = inherited from the daemon. |
| `ulimit_nice` | u8 | *unset* | Linux only: the app's `RLIMIT_NICE` (soft = hard, 0–40), so its threads may lower their nice value down to `20 - v` (Chromium's audio threads ask for -8: 28). Unset = inherited from the daemon (0 under a systemd unit: nothing below the nice it starts with). |
| `ulimit_rttime` → `ulimit_rttime_us` | u64 (us) | *unset* | Linux only: the app's `RLIMIT_RTTIME` (soft = hard), in microseconds: the CPU time a real-time thread may take without blocking before the kernel kills it (SIGKILL, no SIGXCPU warning: soft = hard) — the guard against a `SCHED_FIFO` loop that freezes the machine. Unset = inherited (unlimited under a systemd unit). |
| `nice` | i8 | *unset* | The app's nice value (-20–19, lower = more CPU), set before `exec` and inherited by its children. Unset = the daemon's own. Below the daemon's takes privilege or `ulimit_nice`. |
| `ioprio` | ioprio | *unset* | Linux only: the app's I/O scheduling class: "idle", "best-effort[:0-7]" or "realtime[:0-7]" (0 first). Honoured by the BFQ and mq-deadline disk schedulers, not by `none` (the NVMe default). Unset = the daemon's own. |
| `oom_score_adj` | i16 | *unset* | Linux only: the app's `oom_score_adj` (-1000–1000): lower is killed later when memory runs out, -1000 never. Unset = the daemon's own. Below the daemon's takes privilege. |
| `cpu_affinity` | cpulist | *unset* | Linux only: the CPUs the app may run on, as the kernel lists them ("0-3,6"). Each must be one this daemon may use. Unset = all of the daemon's. |
| `max_restarts` | u32 | `16` | Unstable restarts before the app is parked in `errored`. |
| `min_uptime` → `min_uptime_ms` | u64 (ms) | `1000` | An exit before this counts as unstable. |
| `kill_timeout` → `kill_timeout_ms` | u64 (ms) | `1600` | Grace before SIGKILL escalation. |
| `restart_delay` → `restart_delay_ms` | u64 (ms) | `0` | Fixed delay before every crash respawn (pm2 `restart_delay`); `0` = respawn at once (pm2 default). |
| `exp_backoff_restart_delay`, `backoff_initial` → `backoff_initial_ms` | u64 (ms) | `0` | pm2 `exp_backoff_restart_delay`: `0` = exponential backoff OFF (the default — crash respawns use `restart_delay_ms` only); otherwise the first backoff delay, grown by `backoff_factor` per quick crash up to `backoff_max_ms`. Wire name kept for compatibility; see `StartSpec::exp_backoff_enabled`. |
| `backoff_factor` | f64 | `1.5` | Growth of the backoff delay per quick crash. |
| `backoff_max` → `backoff_max_ms` | u64 (ms) | `15000` | Cap on the backoff delay. |
| `backoff_reset_after` → `backoff_reset_after_ms` | u64 (ms) | `30000` | Uptime after which the backoff delay resets to the first one. |
| `watch` | bool\|path\|list | `false` | Absolute directories/files to watch; empty = watch off. Relative entries are resolved against `cwd` at the CLI edge (pm2 rule). Written as `true` (the cwd), a path, or a list of paths. |
| `ignore_watch` | list | `[]` | Glob patterns ADDED to `watch::DEFAULT_IGNORE_WATCH`. |
| `watch_delay` → `watch_delay_ms` | u64 (ms) | `0` | Trailing debounce before a watch-triggered restart (floored at `watch::WATCH_MIN_DEBOUNCE_MS`). |
| `watch_poll` → `watch_poll_ms` | u64 (ms) | *unset* | When set, poll at this interval instead of using OS events (NFS, container bind mounts). `None` = native backend, polling only as a fallback when the native one cannot be set up. |
| `log_date_format` | string | *unset* | strftime format prefixed (plus `": "`) to every persisted log line. `None` = raw bytes, exactly the child's output (pm2's `log_date_format`). Validated by `validate_date_format`. |
| `merge_logs`, `combine_logs` | bool | `false` | stdout and stderr share one file (pm2's `merge_logs`). |
| `out_file`, `out`, `output`, `out_log` | string | *unset* | Custom log destinations. `None` = derived from `Paths`. Absolute (the CLI edge resolves relative paths against `cwd`); may contain `{name}`, `{instance}`, `{namespace}` placeholders — see `paths::Paths::log_targets`. `"NULL"` / `/dev/null` disables that stream's file (the bus still carries it). |
| `error_file`, `error`, `err`, `err_file`, `err_log` | string | *unset* | stderr destination, same rules as `out_file`. |
| `log_file`, `log` | string | *unset* | pm2's `log_file`: one file for both streams; implies `merge_logs`. |
| `disable_logs` | bool | `false` | pm2's `disable_logs`: no file, no bus, no crash tail. The pumps still drain the pipes so the child never blocks. |
| `tty` | bool | `false` | Run the app on a pseudo-terminal instead of pipes: `isatty` is true, stdin is the terminal, and `3pm attach` connects to it. One output stream (no stderr): the log is the merged file, stripped of escape sequences; `error_file` is refused. `TERM` and `COLORTERM` are set unless `env` names them. |
| `log_rotate_size` → `log_rotate` | size | *unset* | Rotate once the live file reaches this size; "0" = never. Unset = the daemon-wide policy. |
| `log_rotate_files` → `log_rotate` | u32 | *unset* | Rotated generations kept (`.1`..`.N`); `0` = keep none. |
| `log_rotate_days` → `log_rotate` | u32 (days) | *unset* | Delete generations older than this; `0` = no age pruning. |
| `log_rotate_compress` → `log_rotate` | bool | *unset* | gzip generations `.2` and older (`.1` stays plain). |
| `stop_exit_codes` | list | `[]` | Exit codes that park the app in `stopped` instead of respawning, whatever `restart_policy` says (pm2's `stop_exit_codes`). |
| `start_delay` → `start_delay_ms` | u64 (ms) | `0` | Delay before the first spawn of a start/restart series, ms. Crash respawns use `restart_delay_ms`/backoff, never this. |
| `depends_on` | list | `[]` | Apps that must be online before this one spawns (see `start_waves` for bulk starts; the daemon gates single spawns). |
| `start_order` | i32 | `0` | Tie-break among apps of one wave (lower starts first). |
| `depends_timeout` → `depends_timeout_ms` | u64 (ms) | `60000` | Max wait for `depends_on` before spawning anyway; `0` = forever. |
| `cron_restart` | cron | *unset* | Restart on a schedule (pm2's `cron_restart`), stored in the 6-field spelling of `cron` (`"0 0 2 * * *"`). `None` = off; a 5-field expression is accepted and `""` switches it off. |
| `user` | string | *unset* | Unix account the app runs as (uid, gid and groups set at spawn, clean environment). `None` = the daemon's own account; `""` hands the app back to it. Only a root daemon honors it, and only for a user declared in `[users] allow`; a hosted caller's apps are forced to its own name. |
| `persist` | bool | `true` | Whether `dump.json` keeps this app (`3pm save`, the implicit save of `3pm update`). `false` = ephemeral: it survives restarts and handoffs, never a reboot. A spec predating the field is `true`. |
<!-- spec:end -->

## Other formats

The format comes from the file name. JSON, YAML and JavaScript take the
same fields and, like pm2, three shapes: `{ apps: [...] }`, a bare list of
apps, or one app alone (an object with a `script` and no `apps`).
`[defaults]` becomes a `defaults` object. pm2's `deploy` section is
ignored.

```yaml
# ecosystem.yaml
apps:
  - name: api
    script: ./server.js
    node_args: --max-old-space-size=256
    env:
      PORT: 3000
  - name: dev
    script: npm run dev        # a command line: bash -c "npm run dev"
```

```js
// ecosystem.config.js — pm2's file, as is
module.exports = {
  apps: [{
    name: 'api',
    script: './server.js',
    instances: 2,
    env: { PORT: process.env.PORT || 3000 },   // your shell's environment
  }],
};
```

A JavaScript config is **code the CLI runs**, as under pm2: `node` from
your PATH evaluates it in your cwd and environment, stdin closed, 10 s and
16 MiB of output at most; `module.exports`, `export default` and `export
const apps` work, and functions vanish (the export crosses as JSON).
`restart --update-env` evaluates it again.

`3pm start -` reads the config on stdin: JSON when it opens with `{` (or
`[` then `{`/`]`), TOML otherwise; relative paths resolve against the cwd.
With no target, `start`, `validate` and `apply` use the current
directory's `ecosystem.toml`, `.json`, `.yaml`, `.yml`, `.config.js`,
`.config.cjs`, `.config.mjs` — the first found.

## Layering

```
[defaults]  →  [[apps]]  →  [apps.profiles.<name>]  →  [apps.env_<name>]
```

| Kind | Rule |
|---|---|
| scalars | later layer wins |
| `env` | merged by key |
| lists (`args`, `watch`, `ignore_watch`, `depends_on`, `stop_exit_codes`) | **replaced** |
| `--env <name>` not defined in the file | error (pm2 would silently ship dev settings) |

## Workflow

```console
$ 3pm validate eco.toml                      # no daemon needed; exit 1 on any error
$ 3pm validate eco.toml --env staging --json # findings as JSON
$ 3pm validate eco.toml --check-managed      # ask the daemon about external depends_on

$ 3pm apply eco.toml                         # idempotent reconcile
$ 3pm apply eco.toml --env production        # with a profile (recorded on the app)
$ 3pm apply eco.toml --only api,worker       # subset, decided on declared names
$ 3pm apply eco.toml --prune                 # + delete apps from this file no longer in it
$ 3pm apply eco.toml --prune-all             # + delete every managed app not in the file
$ 3pm apply eco.toml --dry-run               # print the plan, change nothing

$ 3pm runtime eco.toml --env production      # container entrypoint (PID 1), see FEATURES.md
$ 3pm start eco.toml                         # pm2 habit: always (re)starts
$ 3pm start                                  # the cwd's ecosystem.* (first found)
$ cat apps.json | 3pm start -                # the config on stdin (JSON or TOML)
$ 3pm start ecosystem.config.js              # pm2's file, evaluated by your node
$ 3pm stop eco.toml                          # stop / restart / delete accept a file:
$ 3pm restart eco.toml                       # every managed app it declares
$ 3pm stop eco.toml api 3                    # …and several targets at once
$ 3pm restart api --update-env               # re-read the app's own file, same profile
$ 3pm show api                               # `config file` / `profile` rows
$ 3pm ls --json | jq '.[].spec.source'       # {file, profile} per app
```

What `apply` does per app, in plan order:

| Verdict | When | Effect |
|---|---|---|
| **create** | not managed | started |
| **update** | any field differs | restarted on the new spec (the reason names the fields) |
| **scale** | only `instances` differs | rows added/removed, survivors keep their pid |
| **restart** | same spec, currently stopped | started again |
| **unchanged** | same spec, running | nothing, pid kept |
| **prune** | `--prune`: from this file, no longer declared | deleted (hand-started apps never are) |

## Gotchas, in one place

- Times are **milliseconds** with pm2's bare names (`min_uptime = 1000`),
  sizes are human strings (`"200M"`), CPU is a float percent.
- `restart_policy` beats `autorestart` when both are given and agree; a
  contradiction (`"always"` + `false`) is an error at validate time.
- `[defaults]` cannot set `name`, `script`, `profiles` or `env_*` — each
  is a notice, not silently dropped.
- A name declared twice is an error; a broken app outside `--only` never
  blocks the selected ones.
- `env` values must be scalars: tables, arrays and nulls have no shell
  meaning and are rejected.
- `watch = true` on the file's directory *includes* the ecosystem file
  itself — `ignore_watch` it, or point `watch` at `src/`.
- Re-applying **without** `--env` an app that was applied with a profile
  drops the profile and says so.
- `user` needs a **root** daemon that already hosts that account
  (`[users] allow` in the daemon's `config.toml`, then `3pm update`);
  anywhere else it is an error at start, never "runs as the daemon". An
  app a hosted user declares itself always runs as that user, whatever it
  writes here.
- `owner` is not a field you set: the daemon stamps it from the
  connection that started the app (`FEATURES.md` → *Hosted users*). In a
  file it is an unknown key, like any typo.
- `persist = false` keeps the app out of `3pm save` **and** out of the
  implicit save of `3pm update`; `apply` and `restart` still manage it as
  usual. It is not part of the configuration `apply` compares either:
  changing it alone updates the running rows in place instead of
  restarting them.

## CLI flag ↔ field

Every field is settable from `3pm start` too; the spellings follow pm2
(`--max-memory-restart 200M`, `--kill-timeout 1600`, `-i 4`, `-o/-e/-l`,
`--time` = `log_date_format = "%Y-%m-%dT%H:%M:%S"`, `--no-autorestart`
= `restart_policy = "never"`). `3pm start --help` is the authoritative
list; the units are identical. The ones whose spelling differs:

| CLI | Field |
|---|---|
| `3pm start <script> -- a b` | `script`, `args = ["a", "b"]` |
| `3pm start "npm run dev"` | `script = "npm run dev"` |
| `--interpreter none` | `interpreter = "none"` |
| `--interpreter-args "--inspect"`, `--node-args` | `interpreter_args = ["--inspect"]` |
| `--env KEY=VALUE` | `[apps.env]` (with a file, `--env <name>` picks a profile) |
| `-i N` | `instances` |
| `-o` / `-e` / `-l` | `out_file` / `error_file` / `log_file` |
| `--time` | `log_date_format = "%Y-%m-%dT%H:%M:%S"` |
| `--no-autorestart` | `restart_policy = "never"` |
| `--watch` / `--watch=PATH` | `watch = true` / `watch = ["PATH"]` |
| `--user NAME` | `user` (`--user ""` hands the app back to the daemon's account) |
| `--persist false` | `persist = false` |
