bm2: A 5.5 MB MoonBit Process Manager That Runs Bun and Node.js with 1 ms Command Latency
bm2
bm2 is a lightweight Linux process manager written in MoonBit for managing Bun and Node.js applications.
bm2 consists of a command-line tool and a background daemon. The CLI sends commands over a Unix socket, and the daemon continuously hosts application processes.
Each user gets one independent daemon that can manage multiple projects simultaneously.
Hosting capabilities include multi-instance, automatic crash restart, memory limits, graceful stop, and state persistence.
Reverse proxying and load balancing are left to gateways like Nginx or Caddy; bm2 focuses solely on process hosting.
English documentation: README.en.md
bm2 vs pm2
| Comparison | bm2 | pm2 |
|---|---|---|
| Form | Native static binary | Node.js application |
| Extra dependencies | None | Node.js runtime |
| Size | ~`5.5 MB` (two binaries) | ~`23 MB` |
| File count | 2 |
3036 |
| Idle daemon memory | ~`2.6 MB` | ~`50 MB` |
| Command response | ~`1 ms` | ~`200–400 ms` |
| Log rotation | Built-in, 10 MB × 10 generations |
Requires extra pm2-logrotate module |
Features
- One
bm2.tomlconfigures one project (a single application, one or more independent instances). - Crash restart budget, memory limit, graceful stop timeout, state persistence, Unix socket control.
- Commands:
start,kill,list,reload,upgrade,version. startalways performs a full restart of its project.
It does not manage Nginx, domains, certificates, hot reload, auto-start on boot, or remote management.
Environment requirements
- Linux only; non-Linux builds refuse to run with a clear message.
- Requires Linux kernel >= 5.3 (pidfd process tracking).
- MoonBit (only needed to install/upgrade bm2)
- Bun
- Node.js (only for projects with
runtime = "node")
Installation and upgrade
Install the MoonBit toolchain first:
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
Then install bm2:
moon install chensuiyi/bm2/...
bm2 installs by default into ~/.moon/bin alongside the moon toolchain, requiring no PATH configuration.
Both bm2 and bm2d binaries are installed together.
Both must stay on PATH because bm2 launches bm2d by name.
Upgrade to the latest version on mooncakes; the new daemon is automatically swapped in after installation:
bm2 upgrade # compares versions, runs moon install, and automatically swaps in the new daemon
Configuration parameters
Create bm2.toml in the directory where you run bm2.
Below is the full template with all fields and defaults; copy and modify directly:
# Project name: starts with a letter, followed by letters, digits, and underscores.
# Also serves as the application name and must be unique among all registered projects.
name = "api"
# Application working directory (absolute path), defaults to the directory containing bm2.toml.
cwd = "/srv/api"
# Script path relative to cwd; `..` segments are forbidden.
script = "src/index.ts"
# Runtime: bun or node.
runtime = "bun"
# Number of instances (1..1024); ports are assigned consecutively starting from `port`.
instances = 2
port = 3000
# Per-instance memory limit (MiB); exceeding it is treated as an abnormal restart. Minimum 1.
max_memory_mb = 512
# Consecutive abnormal restart budget; 0 means first abnormal exit puts the instance into errored state.
max_restarts = 10
# Delay before auto-restart (ms); fixed after a crash, increments per attempt after a spawn failure.
restart_delay_ms = 1000
# Clean exit earlier than this duration (ms) counts toward the restart budget.
min_uptime_ms = 10000
# Grace period from SIGTERM to SIGKILL (ms), maximum 60000.
stop_timeout_ms = 10000
Port ranges of all registered projects must not overlap; bm2 refuses to start on conflict.
Environment variables
bm2 passes only its own PATH, HOME, and TMPDIR to managed processes, plus these reserved variables:
BM2_APP_NAME(project name)BM2_INSTANCE_ID(instance number; the first instance is"0")BM2_APP_INSTANCE(same instance number, named to align with PM2'sNODE_APP_INSTANCEconvention)BM2_APP_PORT(the port assigned to this instance, equal tobm2.toml'sportplus the instance number)NODE_ENV(always"production")
Port and instance number correspond one-to-one: BM2_APP_PORT = port + BM2_APP_INSTANCE. For example, with port = 3000 and instances = 3, the three instances listen on 3000, 3001, and 3002 respectively.
Typical in-app usage:
- Use
BM2_APP_PORTto bind the listening port; each instance gets its own port. - Use
BM2_APP_INSTANCE === "0"to identify the primary instance and run one-off logic like database migrations or cron jobs only there. - Use
NODE_ENV === "production"to take the production code path.
const PORT = Number(process.env.BM2_APP_PORT ?? 3000);
const isPrimary = process.env.BM2_APP_INSTANCE === "0";
Bun.serve({ port: PORT, fetch: () => new Response("ok") });
if (isPrimary) {
// Run only on the primary instance: DB migrations, cron jobs, etc.
}
The application's own environment variables are loaded by the app and its runtime; bm2 does not parse .env or participate in loading.
Variables already injected by bm2 cannot be overwritten by the runtime's .env loading.
Command usage
bm2 start # register/update the project in the current directory and start it
bm2 kill <name> # stop a project and deregister it; bm2d keeps running
bm2 kill -y # stop all projects, deregister them, and exit bm2d (bare `kill` refuses to run)
bm2 list [name] # show status of all registered projects
bm2 reload # swap in a new bm2d; managed applications keep running
bm2 upgrade # upgrade bm2 to the latest version on mooncakes
bm2 version # show bm2 version
start and kill <name> are asynchronous: the daemon responds immediately, and the CLI polls until the operation completes, so the daemon never blocks on a stop timeout.
bm2 list, bm2 kill, bm2 reload, and bm2 version can be run from any directory.
Only bm2 start must be run in the directory containing bm2.toml, because it registers the project from that configuration.
A bare bm2 kill (without -y) refuses to execute and prints a hint.
Re-running bm2 start in a project (or in another directory with the same name) updates the configuration and performs a full restart, so changing any field — including instance count, port, or script — takes effect on the next start.
A killed project (bm2 kill <name>) is fully deregistered: it disappears from bm2 list and does not reappear on daemon restart.
reload swaps in a new bm2d without stopping managed applications: the old daemon detaches, and the new daemon adopts the still-running instances under an unchanged PID.
Use this after manually replacing the binary; bm2 upgrade performs this step automatically.
list prints one line per active or errored instance, including PID, port, running status, memory, uptime, and the full project working directory in the last column CWD.
Intentionally stopped instances are omitted; restarting and errored instances remain visible for operational diagnosis.
If the daemon crashes unexpectedly and leaves a stale Unix socket, the next CLI request briefly waits for a response, removes the stale socket, starts a new daemon, and retries the request once.
Load balancing
bm2 focuses solely on process hosting; reverse proxying and load balancing are left to gateways like Nginx or Caddy.
Point the gateway at the instances' consecutive ports, then update the upstream list and reload the gateway when instances are added or removed.
Nginx example (assuming instances = 2, port = 3000):
upstream bm2_app {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://bm2_app;
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;
}
}
Caddy example:
example.com {
reverse_proxy 127.0.0.1:3000 127.0.0.1:3001
}
State and logs
bm2 always stores its socket, PID, state, and management logs under the current Linux user's ~/.bm2.
One daemon per user, managing all registered projects:
bm2.sock # Unix socket, permissions 0600
bm2d.pid # daemon PID
bm2.events.jsonl # CLI connection and retry events
bm2d.log # daemon stderr / runtime diagnostics
bm2d.events.jsonl # daemon and supervision events
<name>/project.json # per-project registration info (config path)
<name>/<name>-<id>.json # persisted instance state
<name>/logs/<name>-<id>.out.log # application stdout
<name>/logs/<name>-<id>.error.log # application stderr
<name>/logs/<name>-<id>.crash.log # abnormal exit diagnostics
Logs rotate by size: each file rotates when it reaches 10 MB, keeping ten generations (.1 .. .10, up to ~100 MB per file).
Application logs and bm2's own management logs are strictly separated.
The two *.events.jsonl files contain one JSON object per line.
They record only management metadata: timestamp, event name, and when applicable the app/instance/PID and operational reason.
They do not contain environment variable values, protocol payloads, or application output.
Common commands:
tail -f ~/.bm2/bm2d.events.jsonl
jq -c . ~/.bm2/bm2d.events.jsonl
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Deployment boundaries are clear
[Fist salute]