Skip to content

Servicrab

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.

CI crates.io MSRV


Features (v0.2)

  • Declare your entire local stack in one servicrab.toml, or split it across files with include
  • Profiles: group the optional services and ask for them with --profile
  • ${VAR} substitution in every config value, strict about unset variables
  • servicrab init — scaffold a config in seconds
  • servicrab check — validate your config before running anything
  • servicrab list — see all services and their restart policies at a glance
  • servicrab 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 up
  • servicrab up — run your whole stack in the foreground: dependency-ordered start, interleaved and colour-prefixed output, reverse-order shutdown (Linux/macOS)
  • Health checks: command, http and tcp probes 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 worked
  • servicrab reload — apply config changes to a running stack without stopping the services you did not touch
  • servicrab events — follow a running stack live: logs, state changes, restarts and health verdicts, as text or JSON
  • servicrab generate systemd|launchd — hand the stack over to the init system, with systemctl reload wired to servicrab reload
  • Restart policies: never, on-failure, always, unless-stopped, with exponential backoff
  • Environment: per-service variables, working directories, and dotenv-style env_file layering
  • Shell completions for bash, zsh, fish, PowerShell and elvish, and man pages via servicrab man
  • Dependency declarations and a deterministic start order

Installation

Prebuilt binaries

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 --version

The tarball also contains the man pages in man/, one per command:

sudo install -m 644 "${dir}"/man/*.1 /usr/local/share/man/man1/
man servicrab

From crates.io

cargo install servicrab

From source

Building 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-cli

Development build

cargo build                        # debug build
cargo build --release              # optimised build
./target/debug/servicrab --help

Quick start

# 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 up

Example servicrab.toml

version = 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 reference

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.


Running a service in the foreground

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 transitions

Try it right now

version = 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 stop

Foreground semantics

servicrab 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.
  • stdout and stderr are inherited, so output appears live and unbuffered.
  • stdin is closed; interactive services are not supported in this mode.

Restart behaviour

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.

Shutdown behaviour

On SIGINT (Ctrl+C) or SIGTERM the runner:

  1. sends the configured shutdown_signal (default term) to the service's process group;
  2. waits up to shutdown_timeout (default 10s);
  3. escalates to SIGKILL for the whole group if anything is still alive;
  4. reaps the child so no zombie is left behind.

Pressing Ctrl+C a second time skips straight to SIGKILL.

Process-group guarantees

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.

Exit codes

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

Platform support and limitations

servicrab run supports Linux and macOS. Windows is out of scope for now; the command reports an unsupported-platform error there.


Running a command in a service's environment

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 $PGPASSWORD

exec 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.


Running a whole stack in the foreground

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 fails

Output 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.

Which services are started

  • with no arguments: every service with autostart = true that 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_on entry is pulled in, even when it has autostart = false or sits in a profile of its own.

Start and stop ordering

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.

Exit codes for up

Situation Exit code
Every service ended cleanly 0
Ctrl+C (SIGINT) 130
SIGTERM 143
A service failed or was skipped 1

Platform support and current limitations

servicrab up supports Linux and macOS. Current limitations:

  • up runs in the foreground and stops with your shell — use servicrab start for a detached stack;
  • no --json event stream yet;
  • servicrab down does not exist yet (Ctrl+C is the way to stop a stack).

Health checks

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 0
  • command runs the given executable with the service's environment and working directory; exit code 0 means healthy.
  • http speaks plain HTTP/1.1 — no TLS, no redirects. For anything else use a command probe such as ["curl", "-fsS", "https://…"].
  • tcp succeeds as soon as a connection can be established. [::1]:6379 works for IPv6 literals.

What health checks do

  1. 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".
  2. Liveness. Probing continues for as long as the process runs. After retries consecutive failures the service is declared unhealthy. With the default on_unhealthy = "restart" the process is stopped with its usual shutdown_signal/shutdown_timeout and the restart policy decides what happens next — so restart = "never" means an unhealthy service simply stops, while on-failure/always bring it back. Set on_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.


Dependency conditions

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.


Log files

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 disk

Every 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.


Restart on file change

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 restarting
servicrab watch          # like `up`, but insists that something is watched
servicrab up             # honours [watch] too, without the check
servicrab start          # so does the background daemon
api | ▶ 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.


Environment files

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 files

Paths 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 stripped

The 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.


Profiles

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 enough
servicrab 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 profiles

A 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 dev records the set for the lifetime of the daemon, so servicrab reload re-plans that stack rather than the smaller one a bare start would have produced. servicrab generate systemd --profile dev writes 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.


Splitting a config across files

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 include that quietly replaced a service would be a fine way to spend an afternoon wondering which file is in charge;
  • an include cycle, printed as the chain of files that closes it;
  • the same file included from two places;
  • version or [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.


Variables in the config

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 an env_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.


Shell completions

servicrab completions bash > /etc/bash_completion.d/servicrab
servicrab completions zsh  > ~/.zfunc/_servicrab
servicrab completions fish > ~/.config/fish/completions/servicrab.fish

powershell and elvish are supported too. The script is written to stdout, so nothing is installed behind your back.


Man pages

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-up

The 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.


Running in the background

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 exit

Individual 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 time

A 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

Services you stopped by hand

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 servicrab

With 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-stopped services. 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 start on 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.

Waiting for the stack to be ready

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 daemon

A 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 down

The 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.

The socket protocol

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.

Live event streaming

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.

Config hot-reload

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.


Running under systemd or launchd

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.


Workspace layout

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)

Development

# 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 check

The commands above are exactly what CI runs, so a clean local sweep means a green pipeline.


Roadmap

Phase 1 (current) — Minimal CLI ✅

  • Cargo workspace setup
  • servicrab.toml config format
  • init / check / list commands
  • Restart policy types
  • Dependency declarations and deterministic start order
  • CI on Linux + macOS

Phase 1.5 (current) — Foreground runner ✅

  • servicrab run <SERVICE> on Linux + macOS
  • Per-service process groups and group-wide shutdown
  • Restart policy enforcement with exponential backoff
  • Graceful shutdown with SIGKILL escalation

Phase 1.6 (current) — Stack runner ✅

  • servicrab up — concurrent supervision of the whole stack
  • Dependency ordering on start, reverse order on shutdown
  • Interleaved, prefixed, colourised output

Phase 1.7 (current) — Health checks ✅

  • command, http and tcp probes
  • Readiness gating: dependents wait for a healthy dependency
  • Unhealthy services are stopped and restarted by policy

Phase 1.8 (current) — Log files ✅

  • Opt-in capture to <dir>/<service>.log with size-based rotation
  • servicrab logs [SERVICE...] [-f] [-n N]
  • Per-service opt-out via [services.<name>.logs] enabled = false

Phase 2 — Background daemon ✅

  • 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 / restart through the daemon
  • Live event streaming over the socket

Phase 3 — Stack management ✅

  • .env file 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)

Phase 4 — Platform integration ✅

  • systemd unit generation (servicrab generate systemd)
  • launchd plist generation (servicrab generate launchd)
  • Live event streaming over the socket (servicrab events, subscribe)
  • --json event stream for up and watch

Phase 5 — Release engineering ✅

  • 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

Non-goals

  • 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.

License

Dual-licensed under MIT OR Apache-2.0 — see LICENSE-MIT and LICENSE-APACHE.

Contributing

See CONTRIBUTING.md.

About

A lightweight Rust process supervisor for local stacks, homelabs, and small servers.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages