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

NeedsystemdPM2
No extra installyesno
Manages app + worker in one fileno (two units)yes
Cross-platform (macOS, Windows)noyes
Live process dashboard (pm2 monit)noyes
Built-in log rotationno (use logrotate)yes
--hot file-watch reloadyes (via ExecStart flag)yes (via watch)
Survives server rebootyes (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

ProcessEntry pointRoleWatch
reepoleeapps/main/server.tsThe web server - serves HTTP requests, renders pages, handles CRUDtrue
workerworker.tsThe 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

CommandPurpose
pm2 start ecosystem.config.cjsStart both processes from the config
pm2 stop reepoleeStop the app server
pm2 stop workerStop the worker
pm2 stop allStop everything
pm2 restart reepoleeRestart the app server
pm2 restart workerRestart the worker
pm2 restart allRestart everything
pm2 reload reepoleeZero-downtime reload (cluster mode only; fork mode falls back to restart)
pm2 statusShow process table
pm2 monitLive dashboard
pm2 logsFollow all logs
pm2 delete reepoleeRemove the process from PM2's list
pm2 saveSnapshot 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.