8. The Server
Status, 27 October 2026: the server setup is designed and documented (implementation plan Phase 13.2,
docs/guides/deployment.mdon the #21 branch, SSH reachability check). Cutover from running locally (Phase 13.4) hasn't happened yet. Files marked † exist on thefeat/21-continuous-deploymentbranch until it merges.
What's on the box
| Layer | What | Why this one |
|---|---|---|
| Machine | An existing Ubuntu 22.04 VPS | Already there, so no new hosting to pay for or run |
| Control panel | Plesk | Already managing that VPS: mail, firewall, backups, Node versions |
| Runtime | Node 24 LTS from Plesk's Node.js extension, at /opt/plesk/node/24/bin |
LTS means long-term support. Plesk installs and updates it, and nothing conflicts with system Node |
| Run-as user | The Plesk subscription's system user, never root | If the app is ever compromised, the damage stays within that user's files |
| Process manager | PM2, installed for that user (~/.npm-global) |
Keeps the app alive, restarts it on crash, captures logs |
| Boot | A systemd unit generated by pm2 startup |
systemd starts PM2, and PM2 resurrects the saved app list |
| A mailbox on the Plesk mail server, with SPF and DKIM on | Alerts come from a domain you control and don't land in spam | |
| Backups | Plesk Backup Manager | Already backs up the subscription folder, which now includes shared/ |
The process supervisor stack
systemd ──starts at boot──▶ pm2 (pm2-<sysuser>.service)
pm2 ──keeps alive────▶ node dist/index.js (name: bbc-6music-sync)
node ──node-cron──────▶ the three jobs
ecosystem.config.js † is PM2's definition of the app. Every line has a reason:
| Setting | Value | Reason |
|---|---|---|
instances: 1, exec_mode: 'fork' |
Exactly one writer. Cluster mode would run several copies of the cron jobs | |
interpreter |
Plesk's Node 24 | Not whatever node happens to be on PATH |
cwd: __dirname |
the release folder | The app finds config/, data/ and logs/ relative to process.cwd(), and in a release folder those are symlinks into shared/ |
kill_timeout |
6 min | Longer than the 5-minute graceful shutdown, so a running weekly job can finish |
exp_backoff_restart_delay, max_restarts: 10, min_uptime: '30s' |
A crash loop backs off and then gives up, instead of restarting (and sending a startup-failure email) every second | |
TZ: 'Europe/London' |
Belt and braces. The code already sets the zone explicitly |
The release-folder layout
~/bbc-6music/
├── current -> releases/20261101T120000Z-abc1234
├── releases/<id>/ dist/, node_modules/, ecosystem.config.js
│ ├── config -> ../../shared/config
│ ├── data -> ../../shared/data
│ └── logs -> ../../shared/logs
├── shared/
│ ├── config/settings.json chmod 600, written by hand, never deployed
│ ├── data/ production state, migrated once from the dev PC
│ └── logs/
└── incoming/ tarballs waiting to be installed
This is the classic Capistrano-style layout (named after the Ruby deploy tool that popularised it). Code is immutable and versioned, and state is shared and persistent. Its strengths:
- A rollback is a symlink switch, because old releases are still on disk.
- A deploy can't touch state, because state isn't inside any release.
- No code changes were needed: the app already resolved paths from
process.cwd(), so the symlinks were enough.
Why not…
Plesk's built-in "Node.js application" feature? It runs apps through Phusion Passenger, which starts a process when an HTTP request arrives and stops it when it's idle. That's perfect for a website and fatal for a cron daemon that never receives a request: Passenger would put it to sleep and the jobs would never fire. This is a good example of a tool that's right for a different problem.
Docker? Docker gives isolation and reproducibility, at the cost of a daemon, images, a registry or build step, and harder access to logs and config. For one Node process on one server you control, PM2 gives you most of the operational value for much less. The ADR's verdict was "overkill", and that's still fair. Revisit it if a second host or a second service appears.
Plain systemd instead of PM2? It's a completely reasonable choice: one
unit file, Restart=on-failure, and journald for logs. PM2 won on
familiarity and tooling (pm2 logs, pm2 monit). Note that PM2 still uses
systemd underneath for boot.
A database (Plesk offers MySQL and Postgres)? The data is a few
kilobytes with one writer. JSON files are backed up with the folder and
readable with cat. Revisit if a second writer or real querying needs appear
(ADR 11.2).
SSH and the firewall question
GitHub's hosted runners connect from thousands of changing Azure IP addresses. That forces a decision, set out in the SSH reachability check:
| Option | For | Against |
|---|---|---|
| Open SSH to all, key-only auth, fail2ban | Simple, and the default | More bot noise in auth.log |
| Self-hosted runner on the VPS | SSH stays locked down, and the runner only makes outbound connections | Another service to keep patched. Only safe while the repo is private (fork PRs could otherwise run code on your server) |
The check covers sshd config, Plesk Firewall, other firewalls, fail2ban (which will ban a runner IP after a few failed attempts), the hosting provider's firewall, and a final dry-run login from GitHub.
Cutover: moving from the PC to the server
The plan (Phase 13.4) gets the order right, and the reasons matter:
- Pick a time away from Tuesday 00:00–02:00 and the 1st.
- Stop all local runs. Two systems writing to the same playlists means duplicates.
- Migrate
data/toshared/data/and checkcurrentMonthand the playlist IDs. Without this step, the server thinks no playlist exists and creates a duplicate October. The deploy preflight refuses an emptyshared/data/for exactly this reason. - Turn on
DEPLOY_ENABLEDand deploy. - Watch for
Spotify authentication successful,All schedulers started, and the result of the startup catch-up. - Rehearse recovery once: reboot the VPS and check PM2 brings the app back. Then roll back and redeploy. A recovery procedure you've never run isn't a procedure yet.
Then comes a one-month validation period covering a 1st of the month and four Tuesdays.
Questions you might get asked
"How is it hosted?" A single Node process under PM2 on an Ubuntu VPS managed by Plesk, running as an unprivileged user, started at boot by a systemd unit. It uses a release-folder layout with shared state, so deploys and rollbacks are symlink switches.
"Why not containers?" One process, one server, no scaling needs. PM2 plus a release layout gives me restarts, logs, boot persistence and instant rollback without a container runtime. If this grew into several services or hosts, Docker would earn its keep.
"What happens when the VPS reboots?"
systemd starts PM2, PM2 resurrects the saved app list (pm2 save runs after
every successful deploy), and the app's startup catch-up runs any job it
missed.
"How do you know it's alive?"
Through failure emails (scrape, Spotify or startup failures), the monthly
"playlist created" email, review emails when there's something to review, and
pm2 ls / pm2 logs on the box. The honest
gap is that there's no "dead man's switch": if the process is gone entirely,
nothing emails. See Known flaws.