PM2
systemd is the default process supervisor on Linux - it is already there, needs no extra install, and the reepolee.service unit file is ready to copy. But Reepolee's production setup is two processes: the app server (apps/main/server.ts) and the queue worker (worker.ts). The systemd page covers the app server alone; the worker needs its own unit file.
PM2 is an alternative when you want a single tool that manages both processes together, with a simpler config file, built-in log rotation, and pm2 monit for a live dashboard. It also works on macOS and Windows for development, where systemd is not available.
Reepolee ships with an operations/ecosystem.config.cjs that defines both processes. Install PM2, point it at the file, and both are running.
When to Choose PM2 Over systemd
| Need | systemd | PM2 |
|---|---|---|
| No extra install | yes | no |
| Manages app + worker in one file | no (two units) | yes |
| Cross-platform (macOS, Windows) | no | yes |
Live process dashboard (pm2 monit) | no | yes |
| Built-in log rotation | no (use logrotate) | yes |
--hot file-watch reload | yes (via ExecStart flag) | yes (via watch) |
| Survives server reboot | yes (enabled by default) | yes (via pm2 startup) |
If you are on Linux and already comfortable with systemd, staying with systemd is fine - just create a second unit file for the worker (see Worker Under systemd below). PM2 is the better fit when you want one config for both processes, are deploying to a non-Linux environment, or prefer the dashboard and log management PM2 provides.
The Ecosystem File
The shipped operations/ecosystem.config.cjs defines two apps:
module.exports = {
apps: [
{
name: "reepolee",
script: "./operations/start_reepolee.sh",
autorestart: true,
watch: true,
},
{
name: "worker",
script: "./operations/start_worker.sh",
autorestart: true,
watch: false,
},
],
};
Each app runs a shell wrapper (start_reepolee.sh / start_worker.sh) that execs Bun with the absolute path to the entry point:
#!/bin/bash
# start_reepolee.sh
exec /home/deploy/.bun/bin/bun run /home/deploy/app/apps/main/server.ts
#!/bin/bash
# start_worker.sh
exec /home/deploy/.bun/bin/bun run /home/deploy/app/worker.ts
Before starting, edit the two .sh files to match your server's paths. The bun binary path and the application directory must be absolute - PM2 does not resolve relative paths the way a login shell does.
The Two Processes
| Process | Entry point | Role | Watch |
|---|---|---|---|
| reepolee | apps/main/server.ts | The web server - serves HTTP requests, renders pages, handles CRUD | true |
| worker | worker.ts | The queue worker - picks up background jobs (email, translations, image processing) | false |
The app has watch: true so a git pull on the server triggers a hot reload - Bun detects the changed files and restarts the process automatically, the same as systemd's --hot flag. The worker has watch: false because file-watching a long-running queue consumer can interrupt in-flight jobs mid-handler; restart the worker deliberately after a deploy (see Deploying Updates).
See Queue / Job System for what the worker does and how jobs are enqueued and processed.
Installing PM2
PM2 is a Node CLI tool. If Node is not already on the server, install it first, then PM2 globally:
sudo npm install -g pm2
pm2 --version
You only need Node for the PM2 binary itself - Reepolee still runs under Bun. PM2 is the supervisor; Bun is the runtime.
Starting the Processes
From the application root directory:
pm2 start operations/ecosystem.config.cjs
PM2 reads the config, starts both processes, and shows a status table. Confirm both are online:
pm2 status
┌────┬─────────────┬──────────┬──────┬───────────┬──────────┬────────┬───────────┐
│ id│ name │ mode │ pid │ status │ restart │ uptime │ cpu │
├────┼─────────────┼──────────┼──────┼───────────┼──────────┼────────┼───────────┤
│ 0 │ reepolee │ fork │ 1234 │ online │ 0 │ 2s │ 0% │
│ 1 │ worker │ fork │ 1235 │ online │ 0 │ 2s │ 0% │
└────┴─────────────┴──────────┴──────┴───────────┴──────────┴────────┴───────────┘
Surviving a Server Reboot
PM2 does not start on boot by default. Generate and install the startup script so PM2 launches when the server does:
pm2 startup
PM2 detects your init system (systemd on most Linux distributions, launchd on macOS) and prints the exact sudo command to run. Run it, then save the current process list so the same apps restart on reboot:
pm2 save
pm2 save writes the current process list to ~/.pm2/dump.pm2. The startup script restores that list on boot. If you add or remove apps later, run pm2 save again to update the snapshot.
Logs
PM2 captures stdout and stderr from each process and writes them to timestamped files under ~/.pm2/logs/:
~/.pm2/logs/reepolee-out.log # app server stdout
~/.pm2/logs/reepolee-error.log # app server stderr
~/.pm2/logs/worker-out.log # worker stdout
~/.pm2/logs/worker-error.log # worker stderr
Follow logs live:
pm2 logs # all processes, interleaved
pm2 logs reepolee # app server only
pm2 logs worker # worker only
pm2 logs --lines 100 # last 100 lines from each
PM2 rotates logs automatically when they reach 10 MB (default), keeping 30 archived copies. You do not need logrotate - the rotation is built in. See Logs for the full logging story, including the SQL query log (logs/sql.ndjson) which PM2 does not manage.
The Dashboard
pm2 monit
Opens a live terminal dashboard showing CPU and memory per process, log streams, and process metadata. Useful for spotting a memory leak or a process that is stuck in a restart loop. Exit with q.
Lifecycle Commands
| Command | Purpose |
|---|---|
pm2 start ecosystem.config.cjs | Start both processes from the config |
pm2 stop reepolee | Stop the app server |
pm2 stop worker | Stop the worker |
pm2 stop all | Stop everything |
pm2 restart reepolee | Restart the app server |
pm2 restart worker | Restart the worker |
pm2 restart all | Restart everything |
pm2 reload reepolee | Zero-downtime reload (cluster mode only; fork mode falls back to restart) |
pm2 status | Show process table |
pm2 monit | Live dashboard |
pm2 logs | Follow all logs |
pm2 delete reepolee | Remove the process from PM2's list |
pm2 save | Snapshot the process list for reboot restoration |
Deploying Updates
Reepolee's recommended workflow is a production branch convention. From your local machine:
bun css:build # builds minified static/app.css
bun pm version patch --no-git-tag-version # bumps the patch version in package.json
git add static/app.css package.json
git commit -m "Release"
bun run git:production # force-pushes main to the production branch
On the server:
cd /home/deploy/app
git pull origin production
Because the app process has watch: true, Bun detects the changed files and reloads the app server automatically - no manual restart needed. The CSS was already compiled and pushed in the commit, and Reepolee has zero runtime dependencies.
The worker has watch: false, so after pulling, restart it deliberately:
pm2 restart worker
The worker handles SIGTERM gracefully - it drains in-flight jobs before exiting (see Graceful Shutdown). Any job still running when the process is killed will be picked up by the orphan reaper on the next worker startup.
Restart and Resilience
autorestart: true means PM2 restarts a process whenever it exits, with no delay by default. If the application crashes, it is back up immediately.
For crash-loop protection, PM2 has an exp_back_restart_delay option that exponentially backs off restarts when a process fails repeatedly. Add it to the ecosystem config if you see tight crash loops:
{
name: "reepolee",
script: "./operations/start_reepolee.sh",
autorestart: true,
watch: true,
exp_back_restart_delay: 100, // 100ms, then 200ms, 400ms, ...
}
For graceful shutdown, pm2 stop sends SIGTERM. The app server and the worker both handle SIGTERM correctly: they stop accepting new work, drain in-flight requests/jobs, then exit. No additional handler needed.
Multiple Environments on One Server
To run staging and production on the same machine, use PM2's --name and --env flags or create a second ecosystem file. The simplest approach is to add environment-specific entries to the config:
module.exports = {
apps: [
{
name: "reepolee-prod",
script: "/home/deploy/app/operations/start_reepolee.sh",
env: { PORT: 2338 },
watch: true,
autorestart: true,
},
{
name: "reepolee-staging",
script: "/home/deploy/staging/operations/start_reepolee.sh",
env: { PORT: 2339 },
watch: true,
autorestart: true,
},
// one worker per environment...
],
};
Use different PORT values and different script paths (pointing at different working directories). The reverse proxy points different hostnames at the different ports. Each process is independently start/stop/restart-able by name.
Development Config
A separate operations/dev.config.cjs is included for local development - it runs the CSS watcher and the dev server (not the production app + worker):
// pm2 start operations/dev.config.cjs
module.exports = {
apps: [
{
name: "tw",
script: "bun",
args: "css:watch",
watch: true,
},
{
name: "dev",
script: "bun",
args: "development",
watch: true,
},
],
};
This is an alternative to running bun dev:all in a terminal - PM2 keeps the CSS watcher and dev server running in the background, restarting on file changes. It is not for production.
Running the Worker Under systemd Instead
If you are using systemd for the app server (as described in systemd) and only need PM2 for the worker (or vice versa), you can run a hybrid setup. A systemd unit for the worker:
[Unit]
Description=Reepolee Queue Worker
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/app
ExecStart=/home/deploy/.bun/bin/bun /home/deploy/app/worker.ts
Restart=always
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
Install it the same way as the app server's unit:
sudo cp reepolee-worker.service /etc/systemd/system/reepolee-worker.service
sudo systemctl daemon-reload
sudo systemctl enable reepolee-worker
sudo systemctl start reepolee-worker
The worker does not use --hot - you do not want file-watching to interrupt a job mid-handler. Restart it deliberately after a deploy: sudo systemctl restart reepolee-worker.
This hybrid approach gives you systemd's zero-install advantage for both processes while still managing them as separate, independently restartable units.