# Config reference

A Rollbridge config is a JavaScript module that `export default`s a config
object (or a sync/async function returning one). When `--config` is omitted,
the CLI loads `rollbridge.js` from the working directory. Run
`rollbridge validate` to check a config without starting the daemon.

```js
// rollbridge.js
export default {
  application: "ticket-server",
  control: {path: "/tmp/rollbridge-ticket-server.sock"},
  proxy: {host: "127.0.0.1", port: 8182},
  processes: [
    {id: "web", policy: "proxied", cwd: "{{releasePath}}", command: "npx velocious server --port {{port}}", port: {from: 18182, to: 18299}, health: {path: "/ping"}}
  ]
}
```

## Config reloads

The daemon reloads the config file it was started with before every deploy and
rollback. Compatible changes apply to the replacement release, daemon-wide
service restart definitions, and the retirement of the previous release. This
means changes to process commands, environment, health checks, lifecycle hooks,
`nonBlockingDrain`, stop signals, graceful-stop timeouts, restart policies, and
memory supervision do not require restarting the stable proxy daemon. Updated
retirement settings also apply to processes that were started by the previous
release.

The replacement config is adopted only after the new release starts and passes
its health check. Invalid config, an incompatible change, or a failed release
leaves the previous config and release active.

Settings that own daemon listeners or change the managed-process topology still
require a daemon restart: `application`, `control`, `statePath`, `proxy.host`,
`proxy.port`, `proxy.upstreamHost`, and changes to process ids, count, `policy`,
`deployStrategy`, `replicas`, or `port`. A deploy with one of these changes
fails before starting the replacement and names the settings that require a
restart.

## Top-level fields

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `application` | string | basename of the config file's directory | Names the app; used in the default control-socket path and the `ROLLBRIDGE_APPLICATION` env var. |
| `control` | object | — | Control-socket settings (see below). |
| `legacyTakeover` | object | unset | Optional matchers for `rollbridge predeploy-cleanup` to stop pre-Rollbridge supervisors during first handover (see below). |
| `ownerRecovery` | object | unset | Durable guardian recovery and atomic incompatible owner replacement; requires `statePath` (see below). |
| `proxy` | object | **required** | Proxy listener and shared defaults (see below). |
| `processes` | array | **required** | Managed processes (see below). Exactly one must be `proxied`. |
| `releaseRetention` | object | — | How many stopped releases the daemon retains (see below). |
| `statePath` | string | unset (no persistence) | File the daemon persists its state to, enabling orphaned-process detection on the next startup; relative paths resolve from the config file directory (see [`statePath`](#statepath)). |

## `control`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `control.path` | string | `/tmp/rollbridge-<application>.sock` | Unix domain socket the CLI uses to talk to the daemon; relative paths resolve from the config file directory. |
| `control.mode` | octal string (e.g. `"660"`) or octal number (`0o660`) | unset | `chmod` applied to the socket after it binds, to share it with a deploy group. When unset, the daemon umask applies. |
| `control.owner` | non-negative integer uid or user name | unset | `chown` owner applied to the socket after it binds. |
| `control.group` | non-negative integer gid or group name | unset | `chown` group applied to the socket after it binds, so a shared deploy group can use it. |

Names are resolved via `/etc/passwd`/`/etc/group` (local users and groups); use
numeric ids for NSS-only principals. The daemon must run as a user permitted to
`chown` the socket (root, or a member of the target group) — otherwise it fails
to start with a clear error. Combine `control.group` with `control.mode: "660"`
to let a deploy group talk to the daemon.

## `proxy`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `proxy.host` | string | `"127.0.0.1"` | Interface the stable proxy binds. |
| `proxy.port` | number | `8182` | Stable port Nginx (or another front end) points at. |
| `proxy.upstreamHost` | string | `proxy.host`, or `"127.0.0.1"` when `proxy.host` is `0.0.0.0`/`::` | Host Rollbridge uses for release health checks and proxy targets. |
| `proxy.healthPath` | string | `"/ping"` | Default health-check path for proxied processes. |
| `proxy.healthTimeoutMs` | number | `30000` | Default health-check timeout for proxied processes. |
| `proxy.drainTimeoutMs` | number | `60000` | How long to drain HTTP/WebSocket connections before stopping the retired proxied process. Expiry never stops an independently draining jobs generation. |
| `proxy.forceStopTimeoutMs` | number | `10000` | Default per-process graceful-stop timeout (`SIGTERM`, then `SIGKILL`). |

## `releaseRetention`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `releaseRetention.keep` | non-negative integer | `10` | Number of most-recent **stopped** releases the daemon keeps in memory and reports in `status`. |
| `releaseRetention.maxAgeMs` | non-negative number | `0` (disabled) | Also prune stopped releases older than this many milliseconds. |

Active and draining releases are never pruned. This governs Rollbridge's own
release records; the deploy tool still owns on-disk release directories.

## `statePath`

When set, the daemon persists a sanitized operational state snapshot — the active and
draining releases, each managed process's recovery metadata (including pid),
restart counters, and recent structured events — to this file (atomically, on
changes and every few seconds). Process commands, working directories,
environment mappings, child command lines, and retained stdout/stderr are never
persisted. They remain available from the live `status`, `logs`, and `events`
APIs while the daemon is running. On a clean `shutdown` the file is removed.

Without `ownerRecovery`, the **next startup** reads any leftover file and reports
managed processes whose pids are still alive — likely orphans from a daemon that
crashed without shutting down cleanly — in its log/event history and the
`orphans` array of [`rollbridge status`](cli.md#status). This is **advisory**:
that mode cannot re-adopt detached children, so it does not stop them
automatically. A recycled pid can be a false positive, so treat a report as a
prompt to investigate. Use
[`rollbridge recover`](cli.md#recover) to list and (with `--force`) stop those
orphans after a crash.

```js
statePath: "/var/lib/rollbridge/ticket-server.state.json"
```

Leave `statePath` unset to disable persistence (the default).

Set `ownerRecovery` to opt into same-authority process-exit recovery and atomic
incompatible owner replacement:

```js
statePath: "/var/lib/rollbridge/ticket-server.state.json",
ownerRecovery: {reconnectGraceMs: 30000}
```

The private guardian socket is derived from `statePath`; the atomic state file is
written mode `0600` and contains its authentication capability. The guardian
owns managed child processes, restart policy, lifecycle hooks, and exit events.
After an unexpected daemon exit, an exact config/runtime replacement may claim
the guardian during `reconnectGraceMs`. If none does, the guardian restarts the
exact accepted daemon command and environment itself, retains startup fencing
until that daemon publishes its ready listeners and PID file, and uses a nonzero
retry backoff after failed starts. The replacement restores active and draining
releases with their allocated ports and resumes proxy/control ownership.
An unfailed durable generation transition resumes under the guardian's mutation
fence after those listeners and the recovered PID are ready, so a valid long
lifecycle hook does not consume the daemon startup deadline. Status remains
available during replay while competing mutations and terminal owner operations
stay fenced. `SIGINT` and `SIGTERM` wait for that startup replay before beginning
clean shutdown. Transition snapshots carry a monotonic journal revision; the
public snapshot is considered only when it is strictly newer and contains every
retained transition and singleton-owning release, while private guardian state
remains authoritative on ties and for older released snapshots without a
journal revision.
Concurrent matching starts are fenced: one claims ownership and losers attest
that winner. The authenticated guardian's private committed state is
authoritative when the public snapshot is stale or partially written; missing or
corrupt guardian identity, authentication, or authority still fails closed
without rewriting the snapshot. Owner disconnection alone never reclaims
accepted work or transfers workers: guardian-owned processes and their
generation-local connections continue during the grace, so the replacement
reconnects to supervision rather than duplicating execution.

For a responsive incompatible owner, `ensure-daemon` prepares the requested
durable runtime, restores exact active and draining generation definitions from
the authenticated guardian, starts the candidate listeners, and then commits a
single fenced guardian/control-socket handoff. Config identity, process topology,
control path, and package/runtime identity may change; `statePath` remains the
unchanged transaction anchor. Failures before commit leave the old owner serving.
After commit, drains resume under their original release configs and never block
the command. A monotonic guardian revision rejects a stale candidate if owner
state changes after prepare. Listener-owned HTTP/WebSocket counts remain fenced
to their releases until the retired listener reports them drained, preventing a
later deploy from stopping the connected process early. Compatible per-deploy
config reloads remain unchanged.

The first upgrade from an authenticated pre-replacement Rollbridge guardian and
daemon uses an explicitly disruptive compatibility bridge because that legacy
owner cannot transfer listeners on Node 20. A retained guardian that supports
replacement prepare/stage but lacks the retired-owner commit command is explicitly
classified and uses the same bridge. Rollbridge attests the exact guardian and
daemon processes, sockets, runtime/config authority, and durable process
registrations before retiring the legacy listeners. Managed process PIDs and
release state remain supervised, but live proxy/control connections may close.
The resulting status includes `ownerTransition: {disruptive: true, mode:
"legacy-first-upgrade", ...}`. The bridge only accepts the incumbent config
identity; retry config or socket changes after the protocol upgrade, when the
normal atomic handoff applies. Other guardian/auth/transport/identity failures
remain fail-closed.

An intermediate guardian which already supports atomic owner replacement but
predates guardian-owned daemon recovery cannot be upgraded through that bridge.
The candidate aborts before listener handoff, the incumbent resumes any paused
drains and remains serving, and the command requests one explicit clean
`shutdown` followed by `ensure-daemon` so the new guardian can become the OS
supervisor.

Without `ownerRecovery`, `statePath` retains the advisory orphan behavior above.

## `legacyTakeover`

`legacyTakeover` lets deploy scripts run `rollbridge predeploy-cleanup` during
the first migration from an old supervisor. The command only uses these matchers
when no active Rollbridge release is running. If a Rollbridge daemon already has
an active release, it exits without stopping legacy processes.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `legacyTakeover.screens` | array of strings | `[]` | GNU Screen session names to stop with `screen -S <name> -X quit`. |
| `legacyTakeover.processes` | array | `[]` | Process command-line matchers. Each entry must define `includes`, and may define `name`. |
| `legacyTakeover.forceStopTimeoutMs` | number | `proxy.forceStopTimeoutMs` | Grace period after `SIGTERM` before `SIGKILL` is sent to matched legacy processes. |

Each `legacyTakeover.processes[]` entry:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `includes` | array of strings | **required** | Every string must appear in a process command line for it to be considered a legacy seed process. Descendants of seed processes are stopped too. |
| `name` | string | generated | Human-readable label for diagnostics. |

Example:

```js
legacyTakeover: {
  forceStopTimeoutMs: 10000,
  screens: ["ticket-server"],
  processes: [
    {name: "legacy web", includes: ["/home/dev/ticket-server/", "velocious server", "--port 8082"]}
  ]
}
```

## `processes[]`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | string | **required** | Unique identifier. Appears in `status`, logs, and `ROLLBRIDGE_*` env vars. |
| `policy` | `"proxied"` \| `"companion"` \| `"singleton"` \| `"service"` | `"companion"` | Lifecycle policy (see [README → Process Policies](../README.md#process-policies)). Exactly one process must be `proxied`. |
| `deployStrategy` | `"persistent"` \| `"handoff"` | `"persistent"` | Service deploy behavior. Only valid on `policy: "service"`; see below. |
| `command` | string | **required** | Shell command to run (templated). |
| `cwd` | string | the release path | Working directory (templated). |
| `env` | object of string → string | `{}` | Extra environment variables (values templated). Merged over the injected `ROLLBRIDGE_*` vars. |
| `port` | number or `{from, to}` | unset | Port (or range) allocated per release. **Required for the `proxied` process.** A plain number `n` means the fixed port `n` (`{from: n, to: n}`). |
| `health` | object or `false` | enabled with defaults | Health check for the `proxied` process; set `false` to disable (see below). |
| `stopSignal` | signal name (e.g. `"SIGTERM"`, `"SIGINT"`, `"SIGQUIT"`) | `"SIGTERM"` | Signal sent to gracefully stop the process; after `gracefulStopMs` it is `SIGKILL`ed. Use a worker's quit signal so it finishes in-flight work before exiting. |
| `nonBlockingDrain` | boolean | `false` | When a release is retired, drain this process **immediately** (in parallel with the proxied connection drain) instead of after it. Companion processes only — typically background workers (see below). |
| `lifecycle` | object | no hooks | Command hooks run when gracefully stopping the process (see below). |
| `gracefulStopMs` | number or `"indefinite"` | `proxy.forceStopTimeoutMs` | Graceful-stop window: time between `stopSignal`/`stopCommand` and `SIGKILL` for this process. Use `"indefinite"` to wait for process exit without sending `SIGKILL`. |
| `restartDelayMs` | number | `1000` | Base delay before restarting this process after a crash (the backoff base; see `restart`). |
| `restart` | object | unlimited restarts, constant delay | Automatic-restart policy: cap, rolling window, and backoff (see below). |
| `memory` | object | unset (no monitoring) | Memory supervision: restart the process when its RSS exceeds a limit (see below). |
| `replicas` | positive integer | `1` | Run this many instances of the process (see below). |
| `outputLines` | positive integer | `50` | Recent stdout/stderr lines retained per process and reported by `status`/`logs`. |

### `processes[].replicas`

Run a pool of identical instances of one process — for example several
background-job workers. `replicas` greater than `1` is supported only on a
**`companion`** process **without a `port`** (the worker-pool case);
`proxied`, `singleton`, and ported processes must keep `replicas: 1`.

```js
{id: "worker", policy: "companion", command: "npx velocious background-jobs-worker", replicas: 4}
```

Each replica runs as its own managed process with id `<id>#<index>` (`worker#0`,
`worker#1`, …) — that id is what appears in `status` and what
[`rollbridge restart`](cli.md#restart) targets (use the base id `worker` to
restart every replica, or `worker#0` for one). Replicas get `replicaIndex`/
`replicaCount` template variables and `ROLLBRIDGE_REPLICA_INDEX`/`_COUNT` in their
environment, so each instance can pick a distinct shard, queue, or lock. A single
process (`replicas: 1`) keeps its plain id and is replica `0` of `1`.

### `processes[].deployStrategy`

`deployStrategy` controls how `policy: "service"` processes behave across deploys:

| Value | Behavior |
| --- | --- |
| `"persistent"` | The default. Rollbridge runs one daemon-wide service instance on a stable port. Deploys update the stored template for future restarts but do not replace a healthy service. |
| `"handoff"` | Rollbridge starts a new release-scoped service instance before the new release's companions and proxied process start. The old release keeps its old service port while it drains, then that old service stops with the old release. |

Use `"handoff"` when release-scoped processes must talk to a same-release service
while old and new releases overlap. A handoff service must define a **multi-port
range** so old and new instances can run at the same time:

```js
{
  id: "background-jobs-main",
  policy: "service",
  deployStrategy: "handoff",
  command: "npx velocious background-jobs-main",
  lifecycle: {
    activateCommand: 'npx velocious background-jobs:activate --generation "$ROLLBRIDGE_RELEASE_ID" --socket "$VELOCIOUS_BACKGROUND_JOBS_LIFECYCLE_SOCKET"',
    quietCommand: 'npx velocious background-jobs:retire --generation "$ROLLBRIDGE_RELEASE_ID" --socket "$VELOCIOUS_BACKGROUND_JOBS_LIFECYCLE_SOCKET"'
  },
  port: {from: 7331, to: 7399}
}
```

The lifecycle socket path is illustrative; set it to the reviewed release-local
Velocious socket used by jobs-main.

Reference it from same-release processes with `{{ports.background-jobs-main}}`.
During a deploy, old workers keep the old port and new workers get the new port.
For background jobs, the required compliant architecture makes the handoff
service and its workers one release generation. Immediately after activation,
retirement must quiesce the old jobs-main's scheduling, dispatch, and new worker
handoffs while keeping it with its workers until their accepted work settles.
Workers are not adopted by the new service.

Configure `lifecycle.activateCommand` and `lifecycle.quietCommand` on one handoff
service when the service starts as a quiescent candidate and requires an explicit
generation transition. Rollbridge starts and health-checks the complete candidate,
waits for the old generation's strict retirement acknowledgement, waits for the
candidate's strict activation acknowledgement, then commits the active release and
proxy target synchronously. Activation is bounded by `lifecycle.activateTimeoutMs`,
which defaults to 30 seconds; retirement uses the process's `gracefulStopMs` bound (or 30 seconds when that
window is `"indefinite"`). Both run with the process environment plus
`ROLLBRIDGE_PID`.

This opt-in mode requires `statePath` and `ownerRecovery`. Rollbridge journals the
exact candidate, previous release, config authority, phase, and failure. An
unresolved transition blocks a different deploy; an explicit deploy with the same
release id, path, revision, and config may resume only its incomplete idempotent
phase. Once the health-ready candidate is journaled, its exact config becomes the
transition authority even if a later hook fails. A recorded failed hook is not
retried merely because daemon ownership changes. Omit `activateCommand` to retain
the existing activate-then-retire behavior. Adding, removing, or moving
`activateCommand` changes the daemon's generation coordinator and therefore
requires a daemon restart before the next deploy.

The synchronous traffic assignment is persisted as `committed_pending` before
Rollbridge awaits singleton replacement. Exact retry or unambiguous owner recovery
finishes that idempotent post-commit work before the transition becomes
`committed` and deploy reports success. Stop, restart, and rollback operations are
rejected while any transition remains unresolved. Exact per-release definitions
stay in the guardian's authenticated private recovery state; `statePath` remains a
secret-safe status and transaction anchor.

An activation-owning handoff service also carries a durable desired role. If its
active process automatically or manually restarts, Rollbridge runs the exact
generation-scoped `activateCommand` once before marking it running. A failed role
restoration is surfaced and the process remains failed rather than being treated
as active. Retired generations remain fenced and are not restarted.

### `processes[].lifecycle`

Command hooks run when Rollbridge **gracefully stops** the process — during a
deploy's drain, a `rollbridge restart`, a memory restart, or shutdown. They let a
job worker quiesce and finish in-flight work before it is terminated. Omit
`lifecycle` for the default behavior (just `stopSignal` then `SIGKILL`).

These hooks describe an individual process stop. They do not define when a
deploy completes and must not impose a short normal-drain deadline on a jobs
generation. Per-job timeouts remain the correct bound for genuinely hung jobs;
legitimate hours-long generation drains are valid.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `lifecycle.activateCommand` | string | unset | For one handoff service, acknowledge activation of its already-started candidate generation after the previous generation has acknowledged retirement. Requires `quietCommand`, `statePath`, and `ownerRecovery`; bounded by `activateTimeoutMs`. |
| `lifecycle.activateTimeoutMs` | positive number | `30000` | Bounds activation, reactivation, and active-role restoration commands. |
| `lifecycle.quietCommand` | string | unset | Run first to tell the process to stop accepting new work. Bounded by `gracefulStopMs`, or 30 seconds when that window is `"indefinite"`. |
| `lifecycle.drainCommand` | string | unset | Run after quieting to wait until the process has drained (it blocks until done). When unset, Rollbridge instead waits up to `drainTimeoutMs` for the process to exit on its own. Requires a positive `drainTimeoutMs` (which bounds it). |
| `lifecycle.drainTimeoutMs` | non-negative number | `0` | Bounds the drain step. `0` **skips the drain step entirely** (no `drainCommand`, no wait). |
| `lifecycle.stopCommand` | string | unset | Run to stop the process instead of sending `stopSignal`, if it is still running after draining. |

Because `stopCommand` runs **instead of** sending `stopSignal`, setting both a
`stopCommand` and a custom `stopSignal` is rejected — the signal would be silently
ignored. Use one or the other.

The full stop sequence is: run `quietCommand` → drain (`drainCommand`, or wait
`drainTimeoutMs` for the process to exit) → if still running, run `stopCommand`
or send `stopSignal` → `SIGKILL` after `gracefulStopMs` unless
`gracefulStopMs: "indefinite"` is set. Each hook command is run through a shell
with the process's environment plus `ROLLBRIDGE_PID` (the process-group leader's
pid, so a hook can `kill -TSTP -$ROLLBRIDGE_PID`). Every hook is **bounded by a
timeout** (its drain timeout, `gracefulStopMs`, or 30 seconds when the graceful
stop window is indefinite) and its failure is non-fatal — the sequence proceeds
to the stop signal/command so a slow or broken hook can't wedge a stop.

```js
{id: "worker", policy: "companion", command: "…", lifecycle: {quietCommand: "kill -TSTP -$ROLLBRIDGE_PID", drainTimeoutMs: 60000}}
```

### `processes[].nonBlockingDrain`

By default, when a release is retired its processes are stopped **after** the
proxied process's connections have drained (or `proxy.drainTimeoutMs` elapses).
That keeps a worker alive in case the draining web process still depends on it —
but it also holds a background worker open for the whole connection drain.

Set `nonBlockingDrain: true` on a `companion` whose work is independent of the
proxied process (a job worker on a shared queue). Rollbridge starts that
companion's configured stop sequence **as soon as the release is retired**, in
parallel with the connection drain, rather than after it. Its quiet command or
signal must make the worker stop accepting new handoffs. The asynchronous
release drain continues after the deploy response. Rollbridge simultaneously
quiesces same-release handoff services, so an old main stops new dispatch while
remaining available to its old workers.

```js
{id: "worker", policy: "companion", command: "…", nonBlockingDrain: true, stopSignal: "SIGINT", gracefulStopMs: "indefinite"}
```

### `processes[].restart`

Controls automatic restarts of a crashed process (a release's active processes
and daemon-wide `service`s). The base delay is the process's `restartDelayMs`;
when the policy's limit is reached the process is left `failed` and not
restarted again.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `restart.maxRestarts` | non-negative integer | unset (unlimited) | Maximum automatic restarts allowed within `windowMs` before Rollbridge stops restarting the process. `0` disables automatic restarts entirely. |
| `restart.windowMs` | non-negative number | `0` (process lifetime) | Rolling window over which `maxRestarts` is counted and after which the backoff resets. `0` counts over the process's whole lifetime. |
| `restart.backoffFactor` | number ≥ 1 | `1` (constant) | Multiplier applied to `restartDelayMs` on each successive restart in the window: `delay = restartDelayMs × backoffFactor ^ n`. `1` keeps a constant delay. |
| `restart.maxDelayMs` | non-negative number | `0` (no cap) | Upper bound on the backed-off delay. `0` means no cap. |

With the defaults a crashed process restarts indefinitely after `restartDelayMs`.
Pair `backoffFactor`/`windowMs` to back off and self-heal after a clean run, or
set `maxRestarts` to give up on a process stuck in a crash loop.

### `processes[].memory`

Monitors the resident memory (RSS) of the process and **gracefully restarts** it
(`SIGTERM`, then `SIGKILL` after `gracefulStopMs`) when it exceeds `limitBytes`.
RSS is measured across the whole managed process group (the spawned wrapper and
its children), not just the wrapper. Omit `memory` to disable monitoring. Memory
measurement uses `/proc` and is a no-op on platforms without it.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `memory.limitBytes` | positive integer | **required** | RSS limit in bytes; exceeding it restarts the process. |
| `memory.warnBytes` | non-negative integer | `0` (off) | Log a `memory warning` once when RSS first crosses this threshold (set below `limitBytes`). |
| `memory.checkIntervalMs` | positive number | `5000` | How often to measure RSS. |

```js
{id: "worker", policy: "companion", command: "…", memory: {limitBytes: 536870912, warnBytes: 402653184, checkIntervalMs: 5000}}
```

A memory restart is reported in `status` (`memoryRestarts`, `lastMemoryRestartAt`,
current `rssBytes`) and recorded in the event history (a `process started` event
with `reason: "memory"`). `status` also reports `children` — the sampled process
tree, with each group member's `pid`, `command`, and `rssBytes`.

### `processes[].health`

Only the `proxied` process is health-checked (before traffic switches to a new
release). Set `health: false` to disable it.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `health.path` | string | `proxy.healthPath` | HTTP path probed on the process's port. |
| `health.timeoutMs` | number | `proxy.healthTimeoutMs` | Total time to wait for the first healthy response. |
| `health.intervalMs` | number | `250` | Delay between probes. |
| `health.startDelayMs` | non-negative number | `0` | Wait this long after the process starts before the first probe (runs before the `timeoutMs` window). |

## Template variables

`command`, `cwd`, and `env` values support `{{...}}` placeholders, rendered when
the process starts. Referencing a placeholder with no value fails the process
start with a clear error.

| Placeholder | Value |
| --- | --- |
| `{{application}}` | `application` |
| `{{releaseId}}` | The deploy's release id. |
| `{{releasePath}}` | The deploy's `--release-path`. |
| `{{revision}}` | The deploy's `--revision` (falls back to the release id). |
| `{{processId}}` | This process's `id`. |
| `{{replicaIndex}}`, `{{replicaCount}}` | This instance's zero-based replica index and the total replica count (`0` and `1` for a single process). |
| `{{port}}` | The port allocated to this process. |
| `{{ports.<id>}}` | The port allocated to another process. |
| `{{proxy.host}}`, `{{proxy.port}}`, `{{proxy.upstreamHost}}` | The configured proxy bind host/port and upstream host. |
| `{{env.<NAME>}}` | A variable from the daemon's own environment, e.g. `{{env.HOME}}`. |

## Injected environment variables

Rollbridge sets these in every managed process's environment (the process's own
`env` is merged on top and can override them):

| Variable | Value |
| --- | --- |
| `ROLLBRIDGE_APPLICATION` | `application` |
| `ROLLBRIDGE_PROCESS_ID` | This process's `id` (the base id, not the `#index` instance id). |
| `ROLLBRIDGE_REPLICA_INDEX`, `ROLLBRIDGE_REPLICA_COUNT` | This instance's zero-based replica index and total replica count (`0` and `1` for a single process). |
| `ROLLBRIDGE_RELEASE_ID` | The release id. |
| `ROLLBRIDGE_RELEASE_PATH` | The release path. |
| `ROLLBRIDGE_REVISION` | The revision (or release id). |
| `ROLLBRIDGE_PORT` | This process's allocated port (only when it has one). |
| `ROLLBRIDGE_<ID>_PORT` | Each process's allocated port, where `<ID>` is the process id uppercased with non-alphanumerics replaced by `_` (e.g. `background-jobs-main` → `ROLLBRIDGE_BACKGROUND_JOBS_MAIN_PORT`). |

## Validation rules

`rollbridge validate` reports all of these at once with an example fix:

- Required `application` defaults are filled; `proxy` and `processes` must be present and well-typed.
- Exactly one process must be `proxied`, and the `proxied` process must define a `port`.
- Process `id`s must be unique.
- `port` must be a positive port number or an ascending `{from, to}` range.
- `control.mode` must be an octal mode between `0` and `0o777`.
- `control.owner` and `control.group` must each be a non-negative integer id or a non-empty name (resolved at daemon start).
- `outputLines` and `releaseRetention.keep` must be positive/non-negative integers; `health.startDelayMs` and `releaseRetention.maxAgeMs` must be non-negative numbers.
- `restart.maxRestarts` must be a non-negative integer (omit it for unlimited restarts); `restart.backoffFactor` must be a number ≥ 1; `restart.windowMs` and `restart.maxDelayMs` must be non-negative numbers.
- When `memory` is set, `memory.limitBytes` must be a positive integer, `memory.warnBytes` a non-negative integer, and `memory.checkIntervalMs` a positive number.
- `replicas` must be a positive integer; `replicas > 1` is allowed only on a `companion` process without a `port`. Process ids must not contain `#` (reserved for replica instance ids).
- `lifecycle.activateCommand`/`quietCommand`/`drainCommand`/`stopCommand` must be non-empty strings when set, and `lifecycle.drainTimeoutMs` a non-negative number; `lifecycle.drainCommand` requires a positive `lifecycle.drainTimeoutMs`. `activateCommand` is allowed on at most one handoff service, requires that service's `quietCommand`, and requires `statePath` plus `ownerRecovery`. A `lifecycle.stopCommand` may not be combined with a custom `stopSignal` (the `stopCommand` runs instead of the signal, so the signal would be ignored).
- `nonBlockingDrain` must be a boolean, and is allowed only on a `companion` process.
- `statePath` must be a string when set.
