BBC 6 Music → Spotify Sync: Learning Guide

8. The Server

Status, 27 October 2026: the server setup is designed and documented (implementation plan Phase 13.2, docs/guides/deployment.md on the #21 branch, SSH reachability check). Cutover from running locally (Phase 13.4) hasn't happened yet. Files marked † exist on the feat/21-continuous-deployment branch 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
Mail 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:

  1. Pick a time away from Tuesday 00:00–02:00 and the 1st.
  2. Stop all local runs. Two systems writing to the same playlists means duplicates.
  3. Migrate data/ to shared/data/ and check currentMonth and the playlist IDs. Without this step, the server thinks no playlist exists and creates a duplicate October. The deploy preflight refuses an empty shared/data/ for exactly this reason.
  4. Turn on DEPLOY_ENABLED and deploy.
  5. Watch for Spotify authentication successful, All schedulers started, and the result of the startup catch-up.
  6. 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.