A practical guide to running a Node.js app as an always-on local service with clean .localhost URLs on macOS.
If you’re self-hosting a Node.js project on your personal machine and you’re tired of remembering port numbers, restarting crashed processes, and manually launching things after every reboot — this post is for you.
I recently went through the full journey of getting Paperclip running permanently on my MacBook using PM2 (a Node.js process manager) and Portless (Vercel’s local reverse proxy tool). Along the way I hit heap memory crashes, shell script interpretation bugs, 404 ghost pages, and HTTPS certificate headaches. Here’s the distilled version so you don’t have to.
What We’re Building
By the end of this guide, you’ll have:
- Your Node.js app running as a background daemon via PM2
- A clean URL like
https://paperclip.localhostinstead ofhttp://localhost:3100 - Auto-restart on crashes
- Auto-start on macOS boot — for both PM2 and the Portless proxy
- HTTPS with locally trusted certificates
Prerequisites
You’ll need Node.js (I’m using v22 via Homebrew), pnpm (if your project uses it), and a macOS machine. The concepts translate to Linux with minor tweaks to the boot persistence step.
Step 1: Build Your Project
PM2 should run the built version of your server, not the dev script. Development scripts spawn child processes, use live reload, and behave unpredictably under a process manager.
cd /path/to/your/project
pnpm build
Verify the build works before involving PM2:
cd server && node dist/index.js
You should see something like Server listening on 127.0.0.1:3100. If it works manually, you’re ready for PM2.
Step 2: Install PM2 and Portless
npm install -g pm2
npm install -g portless
Portless must be installed globally — don’t add it as a project dependency or run it via npx.
Step 3: Start the Portless Proxy
Portless runs a lightweight reverse proxy on port 1355. When you enable HTTPS, it auto-generates certificates and trusts them on your system (prompts for sudo once on first run).
portless proxy start --https
After this, any app registered with portless becomes accessible at https://<name>.localhost. The proxy assigns each app a random port in the 4000–4999 range via the PORT environment variable, and most Node.js frameworks (Express, Next.js, Fastify) respect this automatically.
Step 4: Create the PM2 Ecosystem Config
This is where everything comes together. Create an ecosystem.config.cjs file in your project root:
// ecosystem.config.cjs
module.exports = {
apps: ,
};
A few things to note here:
The script and args fields — instead of running tsx src/index.ts directly, we wrap it with portless paperclip, which registers the app with the proxy under the name “paperclip” and injects the PORT variable. The command portless paperclip pnpm exec tsx src/index.ts tells portless to register an app called “paperclip” and then run the remaining command.
NODE_OPTIONS: "--max-old-space-size=8192" — by default, Node.js caps heap memory at roughly 4 GB. If your app is memory-intensive (large datasets, heavy bundling, embedded databases), it can hit that ceiling and crash with FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory. Bumping it to 8 GB gives breathing room.
max_memory_restart: "6G" — this tells PM2 to automatically restart the process if its RSS (resident set size) exceeds 6 GB. It acts as a safety net for memory leaks.
The PATH variable — PM2 doesn’t inherit your shell’s PATH. If you installed Node via Homebrew, you need to include the Homebrew paths explicitly so that pnpm, tsx, and portless can be found.
Step 5: Start PM2
Navigate to your project root (where ecosystem.config.cjs lives) and start:
cd /path/to/your/project
pm2 start ecosystem.config.cjs
Verify it’s running:
pm2 status
You should see your app listed with status online. Check the logs to confirm the server started and portless registered it:
pm2 logs paperclip --lines 30
Look for output indicating the server is listening. Then open your browser and visit:
https://paperclip.localhost— if you started portless with--httpshttp://paperclip.localhost:1355— if you’re using HTTP
Step 6: Make Everything Survive Reboots
This is the part most tutorials skip. You want two things to start automatically when your Mac boots: the PM2 process list and the Portless proxy.
PM2 Boot Persistence
First, save the current process list so PM2 knows what to restore:
pm2 save
Then set up the startup hook:
pm2 startup
This command prints a sudo command specific to your system. Copy and run it. It creates a launchd service on macOS that starts PM2 on boot and restores your saved processes.
Portless Proxy Boot Persistence
If you’re running portless on port 1355 (the default, no sudo needed), you can manage it through PM2 itself:
pm2 start "portless proxy start --https" --name portless-proxy
pm2 save
If you need portless on port 443 (so https://paperclip.localhost works without a port number), it requires sudo, and you’ll need a launchd daemon instead. Create a plist file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.portless.proxy</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/portless</string>
<string>proxy</string>
<string>start</string>
<string>--https</string>
<string>-p</string>
<string>443</string>
<string>--foreground</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
Install it:
sudo cp com.portless.proxy.plist /Library/LaunchDaemons/
sudo launchctl load /Library/LaunchDaemons/com.portless.proxy.plist
Troubleshooting: The Pitfalls I Hit
The “JavaScript heap out of memory” crash
If you see a stack trace ending with FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed, your app is exceeding Node’s default ~4 GB heap. The fix is NODE_OPTIONS: "--max-old-space-size=8192" in your PM2 config. Monitor usage with pm2 monit and if memory keeps climbing, you may have a leak to track down.
PM2 isn’t picking up config changes
PM2 caches process configurations. If you edit ecosystem.config.cjs and just run pm2 restart paperclip, it may still use the old config. The reliable way to apply changes:
pm2 delete paperclip
pm2 start ecosystem.config.cjs
The SyntaxError: missing ) after argument list on tsx
This happens when PM2 tries to run a shell script (like node_modules/.bin/tsx) through the Node.js interpreter directly. The .bin/tsx file is a bash wrapper, not JavaScript. The fix: don’t set interpreter: "node" in your PM2 config when using portless as the script. Let PM2 use the default shell interpreter.
Portless shows 404 — “No apps running”
This means portless proxy is running but no app has registered with it. Common causes:
- PM2 started before portless proxy — restart PM2 after portless is running
- The app crashed on startup — check
pm2 logsfor errors - The
portlesscommand wasn’t found in PM2’s PATH — add Homebrew paths to theenv.PATHin your config
HTTPS says “connection refused”
If https://paperclip.localhost refuses connections but http://paperclip.localhost:1355 works, portless is listening on 1355 but not on 443. The browser expects HTTPS on port 443 by default. Either access https://paperclip.localhost:1355, or restart portless on port 443 with sudo portless proxy start --https -p 443.
On first HTTPS visit, your browser may show a certificate warning since portless uses auto-generated certificates. Click through to accept it, or run sudo portless trust to add the CA to your system trust store permanently.
Monitoring and Management Cheat Sheet
# Check what's running
pm2 status
# Watch logs in real time
pm2 logs paperclip
# Real-time CPU and memory dashboard
pm2 monit
# Restart after config changes
pm2 delete paperclip && pm2 start ecosystem.config.cjs
# Stop without removing from PM2
pm2 stop paperclip
# Fully remove
pm2 delete paperclip
# Check portless proxy
portless proxy status
# List registered apps
portless list
The Final Result
After all of this, here’s what happens when I open my MacBook:
- macOS boots and
launchdstarts the Portless proxy (with HTTPS on port 443) launchdstarts PM2, which restores the saved process list- PM2 launches Paperclip wrapped in Portless, which registers it as
paperclip.localhost - I open
https://paperclip.localhostin my browser and everything is just there
If the app crashes, PM2 restarts it automatically (up to 10 times, with a 5-second delay between attempts). If memory usage exceeds 6 GB, PM2 restarts it preemptively. I only need to intervene if I explicitly stop it myself.
No more localhost:3100. No more “is it running?” No more restarting things after a reboot. Just a clean URL that always works.
Have questions or ran into something I didn’t cover? Drop a comment or reach out — happy to help debug.