A lightweight, cross-platform process supervisor for local development stacks, homelabs, and small servers — written in Rust.
Think of it as a minimal, zero-dependency alternative to overmind or Honcho, with a roadmap toward daemon-based management and a local API.
- Declare your entire local stack in one
servicrab.toml, or split it across files withinclude - Profiles: group the optional services and ask for them with
--profile ${VAR}substitution in every config value, strict about unset variablesservicrab init— scaffold a config in secondsservicrab check— validate your config before running anythingservicrab list— see all services and their restart policies at a glanceservicrab run <service>— supervise a single service in the foreground with live stdout/stderr, restart policy, and process-group shutdown (Linux/macOS)servicrab exec <service> -- <cmd>— run anything in a service's environment and working directory, whether or not the service is upservicrab up— run your whole stack in the foreground: dependency-ordered start, interleaved and colour-prefixed output, reverse-order shutdown (Linux/macOS)- Health checks:
command,httpandtcpprobes with readiness gating and automatic restart of unhealthy services servicrab watch— restart a service as soon as its sources change, with ignore rules and debouncing- Log files: opt-in per-service capture with size-based rotation, plus
servicrab logs [-f]to read and follow them - Background daemon:
servicrab start/status/stop/restart/down, with a documented JSON-over-Unix-socket protocol (Linux/macOS) servicrab start --wait— block until every service is ready (health checks included), with an exit code that says whether it workedservicrab reload— apply config changes to a running stack without stopping the services you did not touchservicrab events— follow a running stack live: logs, state changes, restarts and health verdicts, as text or JSONservicrab generate systemd|launchd— hand the stack over to the init system, withsystemctl reloadwired toservicrab reload- Restart policies:
never,on-failure,always,unless-stopped, with exponential backoff - Environment: per-service variables, working directories, and dotenv-style
env_filelayering - Shell completions for bash, zsh, fish, PowerShell and elvish, and man pages via
servicrab man - Dependency declarations and a deterministic start order
Every release ships a tarball per platform (x86_64 and aarch64 for both
Linux and macOS) on the
releases page, each with a
.sha256 next to it:
tag=$(curl -fsSL https://api.github.com/repos/gaborini/servicrab/releases/latest | grep -m1 '"tag_name"' | cut -d'"' -f4)
arch=$(uname -m); [ "$arch" = arm64 ] && arch=aarch64
os=unknown-linux-gnu; [ "$(uname -s)" = Darwin ] && os=apple-darwin
dir="servicrab-${tag#v}-${arch}-${os}"
curl -fsSL "https://github.com/gaborini/servicrab/releases/download/${tag}/${dir}.tar.gz" | tar -xz
sudo install "${dir}/servicrab" /usr/local/bin/
servicrab --versionThe tarball also contains the man pages in man/, one per command:
sudo install -m 644 "${dir}"/man/*.1 /usr/local/share/man/man1/
man servicrabcargo install servicrabBuilding from source needs Rust 1.85 or newer (the minimum supported version, enforced by CI):
git clone https://github.com/gaborini/servicrab
cd servicrab
cargo install --path crates/servicrab-clicargo build # debug build
cargo build --release # optimised build
./target/debug/servicrab --help# 1. Scaffold a config
servicrab init
# 2. Edit servicrab.toml to match your stack
# (see the example below)
# 3. Validate
servicrab check
# 4. List services
servicrab list
# 5. Run a service in the foreground
servicrab run api
# 6. …or bring the whole stack up
servicrab upversion = 1
[project]
name = "my-dev-stack"
[project.env]
RUST_LOG = "info"
[services.db]
command = ["postgres", "-D", "/usr/local/var/postgres"]
restart = "always"
[services.api]
command = ["cargo", "run", "--bin", "api"]
cwd = "./api"
depends_on = ["db"]
restart = "on-failure"
restart_delay = "1s"
restart_max_delay = "30s"
max_restarts = 5
stable_after = "60s"
shutdown_signal = "term"
shutdown_timeout = "10s"
[services.api.env]
PORT = "3000"
[services.worker]
command = ["python", "worker.py"]
cwd = "./worker"
restart = "never"
[services.worker.env]
DATABASE_URL = "postgres://localhost/mydb"
QUEUE_URL = "redis://localhost"command is always a list: the first element is the executable, the rest are
arguments passed verbatim. Servicrab never runs your command through a shell,
so quoting, globbing, and && are not interpreted — configure sh explicitly
if you want shell semantics.
| Command | Description |
|---|---|
servicrab init [--path PATH] [--force] |
Generate an example servicrab.toml |
servicrab check [--config PATH] |
Parse and validate the config file |
servicrab list [--config PATH] [--json] |
List all services with their restart policies |
servicrab run <SERVICE> [--config PATH] [--no-restart] |
Supervise one service in the foreground |
servicrab exec <SERVICE> [--config PATH] -- <COMMAND>... |
Run a command in a service's environment and working directory |
servicrab up [SERVICE...] [--config PATH] [--no-restart] [--no-prefix] [--timestamps] [--abort-on-failure] [--json] |
Supervise a whole stack in the foreground |
servicrab watch [SERVICE...] [--config PATH] [--no-restart] [--no-prefix] [--timestamps] [--abort-on-failure] [--json] |
Like up, and restart services when their watched files change |
servicrab logs [SERVICE...] [--config PATH] [-f] [-n N] [--no-prefix] |
Show (and follow) the captured log files |
servicrab start [SERVICE...] [--config PATH] [--no-restart] [--wait] [--timeout DUR] |
Start the stack in the background, optionally waiting until it is ready |
servicrab status [--config PATH] [--json] |
Show what the background daemon is doing |
servicrab stop <SERVICE...> [--config PATH] |
Stop individual services in the running daemon |
servicrab restart <SERVICE...> [--config PATH] |
Restart individual services |
servicrab reload [--config PATH] |
Re-read the config and apply the difference to the running daemon |
servicrab events [SERVICE...] [--config PATH] [--json] [--no-prefix] [--timestamps] [--no-logs] |
Follow the daemon's live event stream |
servicrab down [--config PATH] |
Stop the daemon and every service it supervises |
servicrab daemon [--config PATH] [--no-restart] |
Run the daemon in the foreground (for systemd/launchd/containers) |
servicrab generate <systemd|launchd> [--config PATH] [--scope system|user] [-o PATH] [--user NAME] |
Generate an init-system unit that runs the stack |
servicrab completions <SHELL> |
Print a completion script for bash, zsh, fish, PowerShell or elvish |
servicrab man [-o DIR] |
Print the man page in roff, or write one page per command into DIR |
If --config is omitted, Servicrab discovers servicrab.toml by walking up
from the current directory.
servicrab run api # supervise `api` until it stops
servicrab run api --config ./stack.toml
servicrab run api --no-restart # ignore the configured restart policy
RUST_LOG=info servicrab run api # see lifecycle transitionsversion = 1
[project]
name = "demo"
[services.ticker]
command = ["sh", "-c", "i=0; while true; do echo tick $i; i=$((i+1)); sleep 1; done"]
restart = "on-failure"
shutdown_timeout = "5s"servicrab run ticker # Ctrl+C to stopservicrab run is a single-service, no-daemon mode: it stays in the
foreground, supervises exactly one service, and exits when that service is
finished. Nothing is written to disk and no background process is left behind.
- The executable is spawned directly — never through an implicit
sh -c. - The working directory is the validated, absolute
cwd. - The environment is the merged result of the process, project, and service environments, in that order.
stdoutandstderrare inherited, so output appears live and unbuffered.stdinis closed; interactive services are not supported in this mode.
| Policy | Clean exit (0) |
Non-zero exit | Killed by signal |
|---|---|---|---|
never (default) |
stop | stop | stop |
on-failure |
stop | restart | restart |
always |
restart | restart | restart |
unless-stopped |
restart | restart | restart |
unless-stopped restarts exactly like always; the two differ only in what a
daemon does with a service you stopped by hand — see
Services you stopped by hand. In this
single-service foreground mode there is nothing to remember, so the two are the
same thing.
Between attempts the runner waits:
delay = min(restart_delay * 2^attempt, restart_max_delay)
If the service stays up for at least stable_after, the attempt counter resets
and the next restart starts from restart_delay again. Once max_restarts
consecutive attempts are used up, the run fails with a non-zero exit code and a
clear message. An explicit shutdown (Ctrl+C or SIGTERM) never triggers a
restart, whatever the policy says.
On SIGINT (Ctrl+C) or SIGTERM the runner:
- sends the configured
shutdown_signal(defaultterm) to the service's process group; - waits up to
shutdown_timeout(default10s); - escalates to
SIGKILLfor the whole group if anything is still alive; - reaps the child so no zombie is left behind.
Pressing Ctrl+C a second time skips straight to SIGKILL.
Every supervised service is spawned into its own process group. That matters because services usually spawn descendants:
servicrab
└── npm ← direct child, process-group leader
└── node
└── esbuild
Signalling only npm would leave node and esbuild running. Servicrab
signals the entire group instead, and sweeps it with SIGKILL after the child
exits, so no descendant outlives the run.
| Situation | Exit code |
|---|---|
| Service exited normally | the service's own exit code |
| Service killed by signal N | 128 + N |
| Stopped with Ctrl+C | 130 |
Supervisor received SIGTERM |
143 |
| Restart limit exhausted, or a runtime error | 1 |
servicrab run supports Linux and macOS. Windows is out of scope for now;
the command reports an unsupported-platform error there.
servicrab exec api -- printenv DATABASE_URL # what would the service see?
servicrab exec api -- npm run migrate # a one-off, with the right env
servicrab exec db -- psql # interactive, with $PGPASSWORDexec reproduces the environment a service would get — its merged env, its
env_file layers, and its working directory — and runs something else in it.
That is the same layering the supervisor applies, computed by the same code, so
what you see is what the service gets.
The command inherits servicrab's stdin, stdout and stderr, so interactive tools work, pipes work, and nothing of servicrab's own ends up in the output. Its exit status becomes servicrab's:
| Situation | Exit code |
|---|---|
| The command exited | the command's own exit code |
| The command was killed by signal N | 128 + N |
| The command was not found | 127 |
| The command was found but not executable | 126 |
| Unknown service, or a config that does not load | 1 |
Everything after the service name belongs to the command, including its own
flags, so servicrab exec api -- ls --all passes --all to ls. Use -- to
make that explicit; a value that could be mistaken for one of servicrab's own
options needs it.
Unlike docker exec, this does not enter a running process: there is no
namespace to join, and no daemon is involved. That cuts both ways. It works on a
stack that is not running, which is what makes it useful for debugging a service
that will not start — but it cannot show you anything the process changed about
its own environment after it started.
servicrab up # start every service with autostart = true
servicrab up api # start `api` and everything it depends on
servicrab up --no-restart # ignore every configured restart policy
servicrab up --timestamps # prefix each line with a UTC timestamp
servicrab up --no-prefix # raw output, no service prefix
servicrab up --abort-on-failure # tear the stack down when a service failsOutput is interleaved and prefixed with the service name, each service getting its own colour:
servicrab up acme-stack → redis, api
redis | ▶ started (pgid 41234)
redis | Ready to accept connections
api | ▶ started (pgid 41235)
api | listening on 0.0.0.0:3000
A service's own stdout is written to Servicrab's stdout and its stderr to
Servicrab's stderr, so servicrab up > stack.log keeps the two apart. Colour is
disabled automatically when the output is not a terminal, when NO_COLOR is
set, or when TERM=dumb.
- with no arguments: every service with
autostart = truethat no profile holds back (see Profiles); - with explicit names: those services only;
- with
--profile NAME: the above, plus the services in that profile; - in every case each transitive
depends_onentry is pulled in, even when it hasautostart = falseor sits in a profile of its own.
Services start in the configuration's deterministic topological order, and a service is only spawned once every service it depends on is available. What "available" means is per dependency, and by default depends on the dependency itself:
- a dependency without a health check is available as soon as its process is up;
- a dependency with a health check is available only after its first successful probe;
- a one-shot dependency (a migration, a build step) that has exited counts as available too — otherwise a stack containing one could never come up.
Spell the condition out to override that (see Dependency conditions), which is also the only way to have the exit status of a one-shot checked.
Shutdown happens in reverse: dependents are stopped (and fully reaped) before
the services they depend on, each with its own shutdown_signal and
shutdown_timeout, escalating to SIGKILL per service exactly as run does.
If a dependency never comes up — it fails to spawn, or exhausts its restart budget — its dependents are skipped rather than started against a missing backend, and the run is reported as failed.
| Situation | Exit code |
|---|---|
| Every service ended cleanly | 0 |
| Ctrl+C (SIGINT) | 130 |
| SIGTERM | 143 |
| A service failed or was skipped | 1 |
servicrab up supports Linux and macOS. Current limitations:
upruns in the foreground and stops with your shell — useservicrab startfor a detached stack;- no
--jsonevent stream yet; servicrab downdoes not exist yet (Ctrl+C is the way to stop a stack).
Any service may declare a [services.<name>.health] block with exactly one
probe:
[services.db.health]
tcp = "127.0.0.1:5432" # healthy when the port accepts a connection
interval = "2s" # delay between probes (default 2s)
timeout = "5s" # per-probe timeout (default 5s)
retries = 3 # failures before unhealthy (default 3)
start_period = "10s" # failures ignored for this long (default 0s)
on_unhealthy = "restart" # restart | ignore (default restart)
[services.api.health]
http = "http://127.0.0.1:3000/healthz" # healthy on any 2xx/3xx response
[services.worker.health]
command = ["./scripts/queue-ok.sh"] # healthy when it exits with code 0commandruns the given executable with the service's environment and working directory; exit code0means healthy.httpspeaks plain HTTP/1.1 — no TLS, no redirects. For anything else use acommandprobe such as["curl", "-fsS", "https://…"].tcpsucceeds as soon as a connection can be established.[::1]:6379works for IPv6 literals.
- Readiness gating. Dependents of a health-checked service wait for its first successful probe instead of merely for its process to exist. This is the difference between "postgres has been spawned" and "postgres accepts connections".
- Liveness. Probing continues for as long as the process runs. After
retriesconsecutive failures the service is declared unhealthy. With the defaulton_unhealthy = "restart"the process is stopped with its usualshutdown_signal/shutdown_timeoutand the restart policy decides what happens next — sorestart = "never"means an unhealthy service simply stops, whileon-failure/alwaysbring it back. Seton_unhealthy = "ignore"to only report the failure.
Failures during start_period never count, which gives slow starters time to
come up without burning their retry budget.
depends_on = ["db"] says what to wait for but not when the wait is over.
The table form says both:
[services.api]
command = ["./api"]
[services.api.depends_on]
db = { condition = "service_healthy" }
migrate = { condition = "service_completed_successfully" }
cache = { condition = "service_started" }| Condition | Available once |
|---|---|
service_started |
the process is up; the exit status is never consulted |
service_healthy |
a health probe has passed |
service_completed_successfully |
the service has exited with status 0 |
Both forms may be mixed across services, but not within one service: a service either lists names or uses the table.
Leaving the condition out is not the same as service_started. An entry
without a condition waits for a probe when the dependency has a [health]
block, and for the process otherwise — so adding a health check to a service
automatically starts gating everything that depends on it.
service_completed_successfully is the one that exists for migrations and
seed steps: it is the only condition that looks at the exit status, so it is
the only one that stops a dependent from starting against a half-migrated
database. Under the other two conditions a one-shot that has exited counts as
available whatever its exit status, because a condition a finished process can
never meet again would deadlock the stack.
Conditions that can never be met are rejected at load time rather than at
2 a.m.: service_healthy on a service with no [health] block, and
service_completed_successfully on a service with restart = "always", which
never stays exited.
A dependency that can no longer meet its condition — including a one-shot that exited non-zero where success was required — leaves its dependents skipped, exactly as an unavailable dependency does.
Add a [project.logs] table and servicrab keeps a copy of everything its
services write:
[project.logs]
dir = ".servicrab/logs" # relative paths resolve next to servicrab.toml
max_size = "10MB" # rotate once a file grows past this (default 10MB)
max_files = 3 # how many rotated generations to keep (default 3)
[services.noisy.logs]
enabled = false # this one service is not written to diskEvery service gets its own <dir>/<service>.log. When a file crosses
max_size it is rotated to <service>.log.1, the previous .1 becomes .2,
and so on up to max_files; anything older is deleted. max_files = 0 simply
truncates the file instead of keeping history.
Both servicrab up and servicrab run write these files, and neither changes
what you see in the terminal — the files are a copy, not a redirect.
Read them back with logs:
servicrab logs # last 50 lines of every service, prefixed
servicrab logs api -n 200 # last 200 lines of one service, unprefixed
servicrab logs -f # follow new output (Ctrl+C to stop)logs -f notices rotation and keeps following the fresh file. Without a
[project.logs] table the command tells you how to enable capture rather than
printing nothing.
Sizes accept B, KB/KiB, MB/MiB, GB/GiB and TB/TiB suffixes;
all of them are powers of 1024.
Add a [watch] block to a service and it restarts whenever anything under the
watched paths changes:
[services.api]
command = ["node", "server.js"]
cwd = "./api"
[services.api.watch]
paths = ["src", "package.json"] # relative to the service's cwd
ignore = ["node_modules", "*.log"] # names, "dir/prefix" or "*.ext"
interval = "1s" # how often the tree is scanned
debounce = "300ms" # quiet period before restartingservicrab watch # like `up`, but insists that something is watched
servicrab up # honours [watch] too, without the check
servicrab start # so does the background daemonapi | ▶ started (pgid 40311)
api | ↻ server.js changed; restarting
api | ◼ stopping: stopped on request
api | ▶ started (pgid 40388).git and .servicrab are always ignored. The watcher polls rather than
using inotify or FSEvents: one code path on every platform, no extra
dependency, and a scan is just a comparison of file sizes and modification
times. A cargo build that rewrites a hundred files causes one restart,
because the watcher waits for debounce of quiet before acting.
A watch-triggered restart travels the same control channel as
servicrab restart, so it behaves identically — including under the daemon.
Trees larger than 20 000 files are reported once and scanned only up to that
limit; narrow paths or add ignore entries if you hit it.
Anything you would otherwise repeat in [project.env] or
[services.<name>.env] can live in a dotenv-style file instead:
[project]
name = "my-project"
env_file = ".env" # or a list: [".env", ".env.local"]
[services.api]
command = ["node", "server.js"]
env_file = [".env.api", ".env.api.local"]
[services.api.env]
PORT = "3000" # wins over anything in the filesPaths are relative to servicrab.toml. Files are read once, when the config is
loaded, and layered lowest to highest:
inherited shell environment
→ project env_file (in declaration order)
→ [project.env]
→ service env_file (in declaration order)
→ [services.<name>.env]
The file format is deliberately small:
# a comment
KEY=value
export KEY=value # `export` is accepted and ignored
QUOTED="hello world" # double quotes support \n \r \t \\ \" escapes
LITERAL='no $expansion' # single quotes are literal
EMPTY=
PORT=3000 # trailing comments are strippedThe file's own contents are never expanded: what is written is what the service
receives. (Values in servicrab.toml are — see
Variables in the config.) A missing file, an
unterminated quote or a line without = is a configuration error, reported by
servicrab check with the file name and line number — the stack never starts
with a half-loaded environment.
Most repositories have more than one "everything": the services you always
want, and the extras you only sometimes do. profiles puts a service in a
group that has to be asked for by name:
[services.api]
command = ["node", "server.js"] # no profiles: always part of the stack
[services.mailhog]
command = ["mailhog"]
profiles = ["dev"]
[services.seeder]
command = ["./seed.sh"]
profiles = ["dev", "test"] # any one of them is enoughservicrab up # api
servicrab up --profile dev # api, mailhog, seeder
servicrab up --profile test # api, seeder
servicrab up --profile dev --profile test
servicrab start --profile dev # same, in the background
servicrab list # shows each service's profilesA service that declares no profiles is always started; one that declares any
waits to be asked. Naming a service starts it whatever its profiles say, so
servicrab up mailhog needs no flag — and because that is a second way of
saying which services to start, naming services and passing --profile in one
command is refused rather than silently resolved.
Two things follow from profiles selecting what to start on its own:
- Dependencies come along regardless. If an always-on service depends on a
profiled one, the profiled one is started too — a service can never run
without what it declares in
depends_on. Put the dependent in the profile as well if it should stay out. - The daemon remembers.
servicrab start --profile devrecords the set for the lifetime of the daemon, soservicrab reloadre-plans that stack rather than the smaller one a barestartwould have produced.servicrab generate systemd --profile devwrites the flag into the unit for the same reason.
A --profile no service declares is an error listing the ones that exist,
because a typo that silently started less than you asked for is the kind of
thing you notice an hour later.
A config that describes a dozen services is easier to live with in pieces, so
include pulls services in from other files:
version = 1
include = ["services/db.toml", "services/api.toml"] # or a single path
[project]
name = "my-project"# services/db.toml
[services.db]
command = ["postgres", "-D", "data"]An included file holds [services.<name>] tables and, if it likes, an
include of its own. version and [project] stay in servicrab.toml: it is
the file every command is pointed at, and the project name decides where the
daemon keeps its socket.
Relative paths in an included file belong to that file. Both its own
include entries and the cwd and env_file of the services it declares
resolve against its directory, so services/db.toml can say cwd = "." and
mean services/, and a fragment can be moved together with the code it
describes.
Merging is not overriding. Each of these is a configuration error, reported by
servicrab check with both file names:
- two files declaring the same service — an
includethat quietly replaced a service would be a fine way to spend an afternoon wondering which file is in charge; - an
includecycle, printed as the chain of files that closes it; - the same file included from two places;
versionor[project]in an included file.
include paths are not ${VAR}-substituted, for the same reason the project
name is not: which files make up a config should not depend on who ran it.
Every value in servicrab.toml can refer to the environment of whoever runs
servicrab, so one committed config can serve checkouts that disagree about
where things live:
[services.api]
command = ["${NODE:-node}", "server.js"]
cwd = "${WORKSPACE}/api"
[services.api.env]
DATABASE_URL = "postgres://localhost:${PG_PORT:-5432}/app"| Written | Expands to |
|---|---|
${VAR} |
the value; an error when VAR is not set |
${VAR:-default} |
default when VAR is unset or empty |
${VAR-default} |
default when VAR is unset |
$${VAR} |
a literal ${VAR} |
An unset variable stops the load and names itself:
error: ✗ servicrab.toml has 1 error(s):
• service "api": cwd refers to ${WORKSPACE}, which is not set;
use ${WORKSPACE:-default} if it may be absent
That is the point of the feature: a cwd that quietly became /, or a
command that quietly lost an argument, is harder to diagnose than a config
that refuses to start.
Three details are worth knowing:
- The braces are required. A bare
$is never special, so the shell snippets that fill a process manager's config keep working —command = ["sh", "-c", "echo $HOME; echo $$"]reaches the shell verbatim. This is the one place the syntax narrows Docker Compose's. - Values come from the environment only, not from
[project.env],[services.<name>.env]or anenv_file. Those describe what the service will see; substitution happens earlier, while the config is still being read. - Names are not substituted. The project name and the service names are
literal, and so are table keys:
${...}in an[services.<name>.env]key stays as written. The project name in particular decides where the daemon keeps its socket, and a control socket that moves with the environment would be a debugging trap.
Expansion happens before every other check, and it does not recurse: a value
that expands to ${SOMETHING} is left at that.
servicrab completions bash > /etc/bash_completion.d/servicrab
servicrab completions zsh > ~/.zfunc/_servicrab
servicrab completions fish > ~/.config/fish/completions/servicrab.fishpowershell and elvish are supported too. The script is written to stdout,
so nothing is installed behind your back.
Release tarballs ship the pages under man/. To generate them from any build:
servicrab man # the main page, in roff, on stdout
servicrab man -o /usr/local/share/man/man1 # one page per command
man servicrab
man servicrab-upThe pages are generated from the same command definitions as --help, so they
cannot drift from it; the sections clap cannot know about — files, environment
variables, exit codes — are written by hand.
up is the interactive mode; start is the same stack without a terminal
attached:
servicrab start # supervise the stack in a detached daemon
servicrab status # a process table: state, pid, uptime, restarts, health
servicrab status --json # the same, machine-readable
servicrab logs -f # follow the captured output (needs [project.logs])
servicrab down # stop every service in reverse order, then exitIndividual services can be driven without disturbing the rest of the stack:
servicrab stop worker # stop it; the daemon and everything else stay up
servicrab start worker # start it again
servicrab restart api db # stop and start, one service at a timeA service stopped this way stays stopped — the restart policy does not bring it back, because the stop was deliberate.
SERVICE STATE PID UPTIME RESTARTS HEALTH
db running 41231 4m30s 0 healthy
api running 41244 4m12s 1 -
worker backoff - - 3 -
worker: stopped (exit code 1), last status: exit code 1
A hand-stopped service stays stopped for as long as that daemon lives, whatever its restart policy says. What the policy decides is whether the next daemon remembers:
[services.api]
command = ["node", "server.js"]
restart = "unless-stopped"servicrab stop api # you are running api in a debugger instead
servicrab down # end of the day
servicrab start # api is still yours; the rest of the stack comes back
servicrab start api # hand it back to servicrabWith restart = "always" that last daemon would have started api again,
because always means always. unless-stopped is the same policy plus a
memory, and only for the initial start: once running, the two behave
identically.
The memory is a list of service names in .servicrab/stopped, written by the
daemon whenever you stop or start a service. It is plain text on purpose —
deleting it, or a line from it, is a perfectly good way to forget a stop. Two
consequences worth knowing:
- It only affects
unless-stoppedservices. The file records every stop, but every other policy starts as it always has, so adopting this changes nothing about an existing stack. - Dependents are held back too. A service cannot run without what it
declares in
depends_on, so if a held-back service is a dependency, whatever depends on it starts out stopped as well rather than waiting for something nobody is going to start.servicrab starton the dependency brings it up; its dependents need starting too.
servicrab up ignores all of this: a foreground run has nothing to remember,
and there is no stop command while it is the thing holding your terminal.
start returns as soon as the daemon is up, which is not the same as the stack
being usable. --wait returns when it actually is:
servicrab start --wait # ... 60s by default
servicrab start --wait --timeout 2m # give it longer
servicrab start api --wait # wait for one service inside a running daemonA service counts as ready when it is running and — if it declares a health
check — that check has passed. A one-shot service that has already exited counts
as ready too: a migration that finished is not something to keep waiting for.
That is the default a dependent waits for as well, so with the default
conditions --wait returns exactly when the last dependent would have been
released. A depends_on entry that spells out
a condition is not reflected here: --wait asks
whether each service is ready, not whether it satisfies a particular dependent,
so it does not check the exit status of a one-shot. A service the daemon
deliberately left stopped is not waited for either — see
Services you stopped by hand.
The exit code is the point:
| Exit code | Meaning |
|---|---|
0 |
Every service is ready |
1 |
A service gave up (failed, or stopped unhealthy), or the timeout ran out |
Either way the daemon is left running — a stack that came up wrong is easier to
diagnose alive, with status, logs and events. Which makes this a usable CI
step:
servicrab start --wait --timeout 90s || { servicrab status; servicrab logs -n 100; exit 1; }
./run-integration-tests
servicrab downThe daemon keeps its runtime state next to the config file, in
.servicrab/: daemon.sock (the control socket), daemon.pid, daemon.log
(its own diagnostics — service output goes to the log files described above),
and stopped (the services you stopped by hand). Add .servicrab/ to your
.gitignore.
Each project gets its own daemon, so several stacks can run side by side.
start refuses to launch a second daemon for the same config, and both the
socket and the pidfile are removed when the daemon exits, however it exits.
servicrab daemon runs the same thing in the foreground, which is what you
want under systemd, launchd, or in a container: it supervises the stack, serves
the socket, and stops the whole stack cleanly on SIGTERM.
Clients speak newline-delimited JSON — one request per line, one response per line — so anything that can write to a Unix socket can drive servicrab:
echo '{"type":"status"}' | nc -U .servicrab/daemon.sock| Request | Response |
|---|---|
{"type":"ping"} |
{"type":"pong","project":"…","pid":123} |
{"type":"status"} |
{"type":"status","services":[…]} |
{"type":"shutdown"} |
{"type":"ok","message":"stopping the stack"} |
{"type":"start_service","name":"api"} |
{"type":"ok","message":"api started"} |
{"type":"stop_service","name":"api"} |
{"type":"ok","message":"api stopped"} |
{"type":"restart_service","name":"api"} |
{"type":"ok","message":"api restarted"} |
{"type":"reload"} |
{"type":"ok","message":"reloaded demo: 1 added, 1 changed, 0 removed"} |
{"type":"subscribe"} |
{"type":"ok"}, then a {"type":"event",…} line per event |
stop_service and restart_service only answer once the service has actually
stopped (and, for a restart, been replaced), so scripts can rely on the reply
instead of polling.
subscribe is the one request that turns a connection one-way: the daemon
answers ok and then keeps writing events until the client goes away. It takes
two optional fields — services (an allow-list; empty means all of them) and
logs (set it to false to leave captured output out).
$ echo '{"type":"subscribe","services":["api"]}' | nc -U .servicrab/daemon.sock
{"type":"ok"}
{"type":"event","service":"api","event":{"kind":"log","stream":"stdout","line":"listening on :8080"}}
{"type":"event","service":"api","event":{"kind":"state","state":"stopping"}}up --json and watch --json print the very same lines without a daemon in
sight, so a wrapper can consume a foreground stack the same way it consumes a
background one:
$ servicrab up --json
{"type":"event","service":"api","event":{"kind":"state","state":"starting"}}
{"type":"event","service":"api","event":{"kind":"log","stream":"stdout","line":"listening on :8080"}}In --json mode stdout carries nothing but event lines — the banner, warnings
and the closing summary stay on stderr — so servicrab up --json | jq works
unchanged.
servicrab events is the CLI on top of it, rendered like up:
$ servicrab events
servicrab events demo → all services
api | listening on :8080
worker | picked up job 41
api | ▶ started (pgid 5512)| Flag | Effect |
|---|---|
SERVICE... |
Only follow these services |
--json |
Print the raw protocol lines, one JSON object per line |
--no-logs |
Lifecycle events only — no captured stdout/stderr |
--no-prefix |
Drop the service-name column |
-t, --timestamps |
Prefix every line with a UTC timestamp |
The stream is a live feed, not a backlog: a subscriber sees what happens from
the moment it attaches (use servicrab logs for history). A client too slow to
keep up gets a {"type":"lagged","skipped":N} notice instead of silently
missing events, and the command exits cleanly when the daemon stops.
servicrab reload makes the running daemon re-read its servicrab.toml and
apply only what changed:
$ servicrab reload
✓ reloaded demo: 1 added, 1 changed, 1 removed
from /srv/demo/servicrab.toml| Difference | What the daemon does |
|---|---|
| A service was added | It is started (and becomes visible to status, stop, restart, …) |
| A service definition changed | It is restarted with the new definition |
| A service was removed | It is stopped and disappears from the stack |
| Nothing changed | Nothing at all — uptime and restart counters are preserved |
A service you stopped by hand stays stopped; it picks up its new definition the next time you start it. Comparison is exact: any field that affects the process (command, environment, env files, restart policy, health check, watch rules, …) counts as a change.
If the new config is invalid the reload is refused and the stack keeps running untouched, so a typo can never take a stack down:
$ servicrab reload
✗ servicrab.toml has 1 error(s); the stack was left untouched:
• service "api": depends on unknown service "ghost"Project-level settings — [project.logs], the log directory and rotation
rules — are bound to the daemon process and need a servicrab down +
servicrab start to change. File watchers ([services.x.watch]) are
re-created on every reload.
The wire types live in the servicrab-protocol crate, which depends on
neither the runtime nor Tokio.
servicrab generate writes a unit that starts servicrab daemon — the
foreground supervisor — so the init system supervises one process and
servicrab supervises the stack:
$ servicrab generate systemd
[Unit]
Description=servicrab stack "demo"
…
$ servicrab generate systemd -o . # writes servicrab-demo.service
$ servicrab generate launchd --scope user -o ~/Library/LaunchAgents/Install instructions are printed to stderr, so servicrab generate systemd > demo.service keeps the file clean.
| Flag | What it does |
|---|---|
--scope system (default) |
/etc/systemd/system or /Library/LaunchDaemons, started at boot |
--scope user |
systemctl --user or ~/Library/LaunchAgents, started at login |
--user NAME |
Run the daemon as another account (system scope only) |
-o PATH |
Write to a file, or into a directory using the conventional name |
The generated unit contains no service definitions — it points at your
servicrab.toml. Adding a service means editing the config and reloading, not
regenerating the unit. On systemd, ExecReload is wired to servicrab reload,
so systemctl reload servicrab-demo.service applies config changes without
touching the services that did not change. TimeoutStopSec (and launchd's
ExitTimeOut) follow the slowest shutdown_timeout in your config, so the init
system never kills the daemon while it is still stopping the stack.
servicrab/
├── crates/
│ ├── servicrab-cli/ # Binary crate — clap CLI + Tokio async runtime
│ ├── servicrab-core/ # Library — config models, validation, lifecycle + process runtime
│ └── servicrab-protocol/ # Library — daemon request/response wire types
├── Cargo.toml # Workspace manifest
└── servicrab.toml # (generated by servicrab init)
# Format
cargo fmt --all
# Lint (warnings are errors in CI)
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
# Test
cargo test --workspace --all-features --locked
# Run a specific test
cargo test -p servicrab-core config::tests
# Check against the minimum supported Rust version
rustup toolchain install 1.85
cargo +1.85 check --workspace --all-features --all-targets --locked
# Audit the dependency tree (licences, advisories, sources)
cargo deny checkThe commands above are exactly what CI runs, so a clean local sweep means a green pipeline.
- Cargo workspace setup
-
servicrab.tomlconfig format -
init/check/listcommands - Restart policy types
- Dependency declarations and deterministic start order
- CI on Linux + macOS
-
servicrab run <SERVICE>on Linux + macOS - Per-service process groups and group-wide shutdown
- Restart policy enforcement with exponential backoff
- Graceful shutdown with
SIGKILLescalation
-
servicrab up— concurrent supervision of the whole stack - Dependency ordering on start, reverse order on shutdown
- Interleaved, prefixed, colourised output
-
command,httpandtcpprobes - Readiness gating: dependents wait for a healthy dependency
- Unhealthy services are stopped and restarted by policy
- Opt-in capture to
<dir>/<service>.logwith size-based rotation -
servicrab logs [SERVICE...] [-f] [-n N] - Per-service opt-out via
[services.<name>.logs] enabled = false
- Detached daemon per project with a Unix-socket JSON API
-
servicrab start/status/down/daemon - Status snapshot: state, pid, uptime, restarts, health
- Per-service
start/stop/restartthrough the daemon - Live event streaming over the socket
-
.envfile support per project and per service - Shell completions (
servicrab completions <SHELL>) -
servicrab watch— restart on file changes, with ignore rules and debouncing - Config hot-reload (
servicrab reload)
- systemd unit generation (
servicrab generate systemd) - launchd plist generation (
servicrab generate launchd) - Live event streaming over the socket (
servicrab events,subscribe) -
--jsonevent stream forupandwatch
- Publishable crate metadata and a
CHANGELOG.md - Tagged releases with prebuilt Linux and macOS binaries (x86_64 + aarch64)
- Man pages (
servicrab man), shipped in the release tarballs - Dependency audit (
cargo-deny) and automated dependency updates - Published to crates.io
- Not a container orchestrator — use Docker Compose / Podman Compose / Kubernetes for containers.
- Not a production server daemon — use systemd, supervisor, or s6 for production workloads.
- No TUI — the focus is on simple CLI + a future HTTP/socket API; a TUI can be built on top.
- No plugin system — keep it small and auditable.
Dual-licensed under MIT OR Apache-2.0 — see LICENSE-MIT and LICENSE-APACHE.
See CONTRIBUTING.md.