docs · pre-release
Menu
Start here / Coming from pm2

Coming from pm2

shep import reads a real dump.pm2 and writes a Flockfile from it. It starts nothing: no daemon connection, no socket, just a file read and a file write.

careful

Two things do not survive the trip, and neither is silently swallowed. Both are named on stderr at import time, before you have started anything: cluster-mode socket sharing, and most of the shell environment pm2 flattened into the dump. Both are covered below.

What it reads

Exactly one file: ~/.pm2/dump.pm2, or whatever path--from names: whichever pm2 save last wrote. It does not read ecosystem.config.js or any other pm2 config format, and it never touches pm2's own state: nothing under~/.pm2 is written or deleted by anything on this page.

A dump row missing pm_exec_path is refused by name rather than imported as a broken app. Every app the importer does produce goes through the same config validation shep start applies to a hand-written Flockfile, so a rejected field fails at import, not three seconds into a sheep's life after a reboot.

shep import
shep import [OPTIONS]

--from <path> to read a dump somewhere other than~/.pm2/dump.pm2, --out <path> to write somewhere other than ./Flockfile.toml,--dry-run to print and write nothing, and--force to overwrite an existing Flockfile. Full flag reference on the CLI page.

Try it

The repo ships a synthetic three-app dump for exactly this: one clustered Node API, one Bun worker with a declared env block, and one one-shot migration script. This is the real output of runningshep import --dry-run against it.

$ shep import --from dump.pm2.json --dry-run
notice[read]: read 4 instance rows for 3 apps from dump.pm2.json
notice[cluster_mode]: api ran 2 instances in pm2 cluster mode; shep binds nothing, so api must set SO_REUSEPORT itself (Node's reusePort: true, needing Node >= 22.12) or every instance past the first hits EADDRINUSE at start
notice[instance_var]: api: reads its instance number from $NODE_APP_INSTANCE; imported as NODE_APP_INSTANCE = "{{instance}}" under [app.env] rather than copied as a value
notice[inherited_env]: api: BUN_INSTALL was running but neither declared nor recognized session junk; decide whether it belongs in the Flockfile, the unit, or nowhere
notice[inherited_env]: worker: JAVA_HOME was running but neither declared nor recognized session junk; decide whether it belongs in the Flockfile, the unit, or nowhere
notice[inherited_env]: migrate: DATABASE_URL was running but neither declared nor recognized session junk; decide whether it belongs in the Flockfile, the unit, or nowhere
Flockfile.toml (stdout)
[[app]]
name = "api"
script = "/srv/api/dist/server.js"
args = ["--port", "8080"]
cwd = "/srv/api"
interpreter = "node"
instances = 2
max_memory = "512M"

[app.env]
NODE_APP_INSTANCE = "{{instance}}"
NODE_ENV = "production"

[[app]]
name = "worker"
script = "/srv/worker/index.ts"
cwd = "/srv/worker"
interpreter = "bun"
autorestart = false
restart_delay = "5s"
merge_logs = true

[app.env]
QUEUE_CONCURRENCY = "4"
QUEUE_URL = "redis://127.0.0.1:6379/2"

[[app]]
name = "migrate"
script = "/srv/migrate/bin/migrate"
args = ["--once"]
cwd = "/srv/migrate"

Every notice lands on stderr in both --format table and--format json; stdout stays clean Flockfile TOML either way, so shep import --dry-run > Flockfile.toml is safe to pipe. A normal run (no --dry-run) writes./Flockfile.toml, or wherever --out names, and refuses to overwrite an existing file unless you pass--force.

Field by field

pm2shep
namename: the grouping key
pm_cwdcwd
pm_exec_pathscript
argsargs
exec_interpreterinterpreter: absent when it was "none"
autorestartautorestart
restart_delay (ms)restart_delay
merge_logsmerge_logs
max_memory_restart (bytes)max_memory: enforced against the whole process tree, not just the root pid
rows sharing a nameinstances: the row count
exec_mode == "cluster_mode"a stderr notice, and nothing in the Flockfile
NODE_APP_INSTANCE in a row's envan [app.env] entry set to "{{instance}}", plus a stderr notice

What doesn't survive the trip

Cluster-mode socket sharing

pm2's cluster master holds one listen socket and hands connections to its workers. shep has no such master. It binds nothing on any app's behalf. Running N instances of an app on one port only works if the app arranges the sharing itself, with SO_REUSEPORT(Node's reusePort: true listen option, which needs Node ≥ 22.12). Without that, every instance past the first hits EADDRINUSE the moment it starts.

shep import names every app it found running in pm2 cluster mode on stderr, and writes nothing into the Flockfile for it. It used to set reuse_port = true, which read like a mitigation and was not one: no part of shep consulted that field at the time, so the line did nothing while looking like it did something. The field is live now, and what it does is narrower than its name suggests: it opts a readiness_probe app back into a reload that overlaps its two instances. An app with no probe already overlaps, and so does one using wait_ready, neither of which needs the field. import still will not write it for you, because only you know whether the app really does callreusePort: true. The work is the app’s either way. If it was never written to share the socket, cluster mode does not work under shep however the Flockfile is written.

shep's instances is pm2's fork mode, not its cluster mode

pm2 cluster mode works because Node's cluster module intercepts server.listen(): the pm2 daemon binds the port once and every worker gets handed connections through that one socket, never binding anything itself. That is a Node runtime feature, not something a process supervisor does, and it can't be ported to a supervisor that runs any executable. shep's instances is pm2's other mode, fork, N separate processes with no socket sharing between them. Set instances = 3 on an app that hardcodes one port and you get one process holding the port and two crash-looping on EADDRINUSE, whatever language it's written in.

There are exactly two ways to get N instances behind one address.

Option 1: a port per instance, with a load balancer in front

Give each instance its own port with {{instance}}, and point something at all of them:

Flockfile.toml
[[app]]
name = "web"
script = "server.js"
interpreter = "node"
instances = 3
args = ["--port", "300{{instance}}"]
nginx.conf
upstream shep_web {
    least_conn;
    server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
    server 127.0.0.1:3002 max_fails=3 fail_timeout=10s;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://shep_web;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

max_fails and fail_timeout are what pull an instance out of rotation while shep restarts it; without them nginx keeps sending requests at a port nothing is listening on. Open-source nginx only does this passive kind of checking, watching real requests fail, not active probing; that's an nginx Plus feature, so don't expect more from the free build. Upgrade andConnection are there for websockets, and cost nothing if the app doesn't use them.

careful

300{{instance}} is string concatenation, not arithmetic. Slots 0 through 9 give ports 3000 through 3009, which is the point, but slot 10 gives 30010, not 3010. The shorthand only holds up to ten instances.

Past ten, pass a base and let the app add its own slot, sinceSHEP_INSTANCE is always injected:

Flockfile.toml
[[app]]
name = "web"
script = "server.js"
interpreter = "node"
instances = 24

[app.env]
PORT_BASE = "3000"
server.js
const port = Number(process.env.PORT_BASE) + Number(process.env.SHEP_INSTANCE);

Option 2: one shared port, no load balancer

If the app sets SO_REUSEPORT on its own listener, every instance binds the same port and the kernel spreads connections between them, no proxy required. That's close to what Node'scluster module does with its own shared-socket scheme, except it runs in the OS and works for any language. Setreuse_port = true in the Flockfile to tell shep the app does this, so a reload knows it's safe to overlap the old and new instances rather than draining one before starting the other. shep can't verify the app actually calls the option: it's set inside the child, after the fork, on a socket shep never sees. Claim it without doing it, and the failure shows up as EADDRINUSE at runtime instead of a refusal at config time.

An inherited shell environment

pm2 flattens whatever shell started pm2 start into every app it runs: PATH, JAVA_HOME, whatever else your login session happened to have set. None of that was ever the app's own configuration, and a daemon started by systemd or launchd has no login shell behind it to reproduce it. shep importapplies one rule per key:

  • A key declared in the app's own env_*block is always written.
  • A key that isn't declared, but matches a closed list of shell noise (PATH, SHLVL, SSH_TTY, ...) or pm2's own injected variables (PM2_HOME,pm_id, ...), is dropped silently.
  • Everything else is named on stderr and left out of the Flockfile. The importer doesn't guess whether a strayDATABASE_URL is the app's real configuration or more session noise the closed list doesn't happen to know. You decide where it belongs.

PATH specifically is never written into an app'senv, no matter how it got into the dump. Instead,shep startup captures the PATH of the shell that installed it straight into the systemd unit or launchd plist, so an interpreter under ~/.bun/bin or ~/.cargo/binis still findable after a reboot with no login shell behind the daemon at all.

What it costs to run

Measured on one machine on 2026-08-29: shep 0.1.12 against pm2 7.0.4 on node v26.5.0. Both tools ran the same two shell scripts under the same/bin/sh, with logs going to each tool's own default file capture, and the daemon is what was sampled rather than the children.

metricsheppm2ratio
Idle daemon RSS, ten apps13.90 MiB71.16 MiB5.1x
Log-plane CPU per line2.10 us4.03 us1.9x
Start ten apps, cold daemon0.158 s0.374 s2.4x
Start ten apps, warm daemon0.056 s0.197 s3.5x
Install footprint14.23 MiB23.10 MiB1.6x
Idle daemon CPU0.045%0.020%a tie
careful

Read the ratios, not the absolute figures. The box was not idle, and it changed power state partway through the run. The order is shep, pm2, shep, so a shift like that shows up as disagreement between the two shep rounds: they agreed within 2.6%, which is why these numbers stand. Idle CPU is printed as the instrument read it, and both figures are hundredths of one percent of one core, so the difference there is not a result.

The log-plane figure has only just gone this way. shep cost 32.8 us per line until an audit on 2026-08-28 buffered the writes and stopped encoding one bus event once per subscriber, so pm2 was ahead by 8x on that measure the day before this was taken.

The RSS gap is structural rather than clever: pm2's daemon is a node process and pays that runtime's floor whatever it does. Everything here is reproducible frombenches/versus-pm2/versus-pm2.sh in the repository, which drives the published npm package as a black box and keeps its raw samples.

The verb-by-verb table

pm2shepnote
pm2 start <script>shep start <script>
pm2 start ecosystem.config.jsshep import, then shep start Flockfile.tomlshep never reads a pm2 ecosystem file directly
pm2 stopshep stop
pm2 restartshep restart
pm2 reloadshep reloadN instances on one port need the app to set SO_REUSEPORT: see below
pm2 deleteshep delete
pm2 list / ls / statusshep flockalias: list, ls
pm2 describeshep describe
pm2 logsshep bleatsalias: logs
pm2 scale <name> <n>shep stock <name> <n>alias: scale (always an absolute count in both)
pm2 saveshep save
pm2 resurrectshep musterhidden alias: resurrect
pm2 startupshep startup
pm2 unstartupshep unstartup
pm2 killshep kill
pm2 flushshep flush
pm2 serve <dir>shep serve <dir>three defaults flip: see below
pm2-runtimeshep runtimeor the shep-runtime alias binary
pm2 sendSignalshep signal
-shep whisperno pm2 equivalent: writes a line to an app's stdin
pm2 triggershep triggerdifferent mechanism: pm2 calls a module method, shep sends over the app's own shepherd channel
pm2 monitshep lookoutalias: dash (a fuller dashboard, not a straight swap)
-shep enable / disable / adopt / rehomedogs have no pm2 analogue
-shep set / get / unsetthe KV store; nothing in pm2 does this

shep serve vs. pm2 serve

shep serve <dir> is a hand-rolled static file server run as a managed sheep by default (--foreground runs it in the current terminal instead). Three defaults are flipped from pm2's own serve, and each is a regression before it is a fix if you aren't expecting it:

  • Directory listing is off by default. pm2 lists a directory with no index.html; shep 404s it unless you pass --listing.
  • Dotfiles are refused by default. pm2's serve publishes them; shep 404s any path with a dotfile component unless you pass --hidden. shep serve . on a repo checkout would otherwise publish .env and the whole.git history.
  • Every symlink under the docroot is refused, not only one that leaves it, unless you pass --follow-symlinks: needed for a deploy layout like current -> releases/2026-08-15.

There's no PM2_SERVE_* environment compatibility: pass--port, --bind, --spa and--auth <file> on the command line instead.

pm2-runtime vs. shep runtime

shep-runtime is the container-entrypoint alias forshep runtime: a foreground, no-daemon supervisor that reads a Flockfile, boots the flock in-process, and exits once it empties: 0 if every sheep stopped clean, 11 if one endederrored, so an orchestrator can tell "finished" from "died". At PID 1 it also reaps re-parented zombies and forwards signals, which a container that skips this step accumulates untildocker stop waits out the full grace period.

The runbook

Import, save, install the boot unit, then reboot and check the flock came back: the sequence docs/specs/shep-v1.md describes as the flagship migration scenario. Step 3 is the only step that touches pm2, and the only irreversible one in the list.

1.  shep import --dry-run           # read the Flockfile before it is written
2.  shep import                     # writes ./Flockfile.toml; starts nothing
3.  pm2 delete all && pm2 kill      # the one destructive step, and it is pm2's
4.  shep start ./Flockfile.toml     # the flock comes up under shep
5.  shep flock                      # every app online, CPU and MEM populated
6.  shep save                       # names the roll it wrote and the app count
7.  sudo shep startup --user <you>  # writes and enables the unit
8.  systemctl status shep-<you>     # active (running), and green
9.  reboot
10. systemctl status shep-<you>     # active (running) WITHOUT anyone logging in
11. shep flock                      # the same apps, new pids, uptime near zero
note

Step 8 going active (running) means something specific: the generated unit is Type=notify, so systemd doesn't consider it started until the daemon itself says so: it only sends that signal once step 6's muster restore has finished. A green status at step 8 is already evidence the restore path works, before the reboot ever happens.

Rolling back

shep unstartup disables and removes whatever unitstartup installed. Run unprivileged, it prints the same kind of paste-able sudo command startup does. A machine that never ran startup reports the unitabsent and exits successfully. Nothing left to guess at. Nothing here is destructive to pm2 itself except the one step in the runbook that says so.

Where to go next