Coming from pm2
shep import pm2 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.
The verb-by-verb table
Every pm2 verb and the shep one to type instead is its own chapter, because it is the part of this page a reader comes back to long after the migration: pm2 verb reference.
Side by side
The fixture every example on this page draws from: one clustered Node API, one Bun worker with a declared env block, and one one-shot migration script.
module.exports = {
apps: [
{
name: "api",
script: "./dist/server.js",
args: "--port 8080",
cwd: "/srv/api",
interpreter: "node",
instances: 2,
exec_mode: "cluster",
max_memory_restart: "512M",
env: { NODE_ENV: "production" },
},
{
name: "worker",
script: "./index.ts",
cwd: "/srv/worker",
interpreter: "bun",
autorestart: false,
restart_delay: 5000,
merge_logs: true,
env: { QUEUE_URL: "redis://127.0.0.1:6379/2" },
env_staging: {
QUEUE_URL: "redis://127.0.0.1:6379/2",
QUEUE_CONCURRENCY: 4,
},
},
{
name: "migrate",
script: "./bin/migrate",
args: "--once",
cwd: "/srv/migrate",
interpreter: "none",
},
],
};[[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"What doesn't survive the trip
Two things do not survive the trip, and neither is silently swallowed.
Cluster-mode socket sharing
Every instance past the first fails to bind unless the app shares the socket itself.
pm2's cluster master holds one listen socket and hands connections to its workers. shep has no such master. 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 pm2 names every app it found running in pm2 cluster mode on stderr, and writes nothing into the Flockfile for it. Setting reuse_port = true does less than its name suggests: it opts a readiness_probe app back into a reload that overlaps its two instances. import still will not write it for you, because only you know whether the app really does call reusePort: true. 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
shep's instances runs N separate processes; nothing shares a port between them for you.
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. 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.
Option 1: a port per instance, with a load balancer in front
[[app]]
name = "web"
script = "server.js"
interpreter = "node"
instances = 3
args = ["--port", "300{{instance}}"]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.
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, since SHEP_INSTANCE is always injected:
[[app]] name = "web" script = "server.js" interpreter = "node" instances = 24 [app.env] PORT_BASE = "3000"
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. Set reuse_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
Most of pm2's shell environment doesn't come along. An undeclared variable gets named on stderr.
pm2 flattens whatever shell started pm2 start into every app it runs: PATH, JAVA_HOME, whatever else your login session happened to have set. shep import pm2 applies 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 stray
DATABASE_URLis the app's real configuration or more session noise the closed list doesn't happen to know.
PATH specifically is never written into an app's env, 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/bin is still findable after a reboot with no login shell behind the daemon at all.
Field by field
namename: the grouping keypm_cwdcwdpm_exec_pathscriptargsargsexec_interpreterinterpreter: absent when it was "none"autorestartautorestartrestart_delay (ms)restart_delaymerge_logsmerge_logsmax_memory_restart (bytes)max_memory: enforced against the whole process tree, not just the root pidnameinstances: the row countexec_mode == "cluster_mode"a stderr notice, and nothing in the FlockfileNODE_APP_INSTANCE in a row's envan [app.env] entry set to "{{instance}}", plus a stderr noticeHow the import works
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 pm2 [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.
Running shep import pm2 --dry-run against that fixture prints this:
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[[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 pm2 --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.
What it costs to run
Measured on one machine on 2026-09-14: shep 0.8.0 against pm2 7.0.4 on node v26.8.1, an Apple M4 Pro. 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. The log row is a 2026-09-28 rerun with shep's log_timestamps off, pm2's default, so both files hold the same 58-byte lines.
17.41 MiB65.88 MiB3.8x2.45 us5.69 us2.3x0.195 s0.366 s1.9x0.095 s0.201 s2.1x53.85 MiB23.11 MiBpm2, by 2.3x0.138%0.065%a tieFootprint is what lands on disk either way. Installing shep gives you three binaries, shep, shep-runtime and shep-dev, at 17.94 MiB each, against pm2's whole node_modules tree. The download is 20.20 MiB compressed. That tree does not run on its own: Node is another 72.73 MiB on the machine this was measured on, which puts the totals at 53.85 against 95.84 and turns the row around. It stays out of the table because most people running pm2 have Node already, and what a Node install weighs varies by platform and packager.
Four of these moved against shep since the same harness last ran, on 2026-08-29 against shep 0.1.12. pm2 is the same version and its figures barely moved, so this is shep changing rather than the machine: idle memory 13.90 MiB to 17.41, a warm start 0.056 s to 0.095, and the shep binary 14.23 MiB to 17.94, one of the three the footprint row counts. shep is still ahead on everything but footprint and idle CPU.
The memory gap is structural rather than clever: pm2's daemon is a node process and pays that runtime's floor whatever it does. All of this is reproducible from benches/versus-pm2/versus-pm2.sh, which drives the published npm package as a black box and keeps its samples.
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.envand the whole.githistory. - Every symlink under the docroot is refused, not only one that leaves it, unless you pass
--follow-symlinks: needed for a deploy layout likecurrent -> 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 for shep 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 ended errored, 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 until docker stop waits out the full grace period.