BLOG

BUILD

8 MIN

One command, no build server

This site is three things that want different release cycles: a hand-written front end, a music catalogue that changes whenever I publish on Suno, and a page pulling live stats from an external API. None of them need a server. All of them were going stale between manual deploys.

The fix was one command that does the whole loop.

The shape of it

node tools/publish.mjs

  ▸ sync Suno catalogue      regenerate js/tracks.js from the public feed
  ▸ refresh mod stats        pull live numbers from CurseForge
  ▸ restage _site            copy the release files into a clean output dir
  ▸ deploy                   push _site to Cloudflare Pages

Every step is fallible, and each one fails in a way that is worse than doing nothing unless it is handled. That is the actual work: the deploy call itself is a one-liner.

Four catches worth knowing

1. A silent empty result is worse than an error

The music page renders whatever the catalogue contains. When the scrape returned nothing, that catalogue was empty, and the page shipped with no songs and no error. The || [] fallback swallowed the failure. The script now refuses to deploy a catalogue with zero tracks. An error you can see beats a page that quietly lost its content.

2. Atomic deploys delete what you forget

Each deployment replaces the whole site. Anything missing from the staging list does not get modified. It stops existing. When I renamed a directory, the old path kept working for a while and then simply vanished from the next deploy. The staging list is an explicit allowlist, never a globbed directory, precisely so that the set of published things is reviewable.

3. Static hosts answer 200 for things that do not exist

This is the one that cost the most time. A request for a path that does not exist came back 200 with a fallback page, not a 404. Every status-code health check passed while the page was completely broken, because the app tried to parse an HTML error page as JSON. Never health-check a static site by status code. Check the content type or, better, the actual content.

THE LESSON On a static host, 200 OK means "something answered", not "the thing you asked for exists". Verification has to look at what came back.

4. Relative paths break at depth

Relative asset references work beautifully at the root and fail the moment a URL has an extra path segment. The browser resolves them one level deeper than intended. The generated pages use explicit paths and the script that writes them takes the base as configuration rather than assuming a location.

Why not a CI pipeline

The obvious alternative is a git integration: push, let the host build, done. For this site that does not fit, because the thing that must regenerate on publish is live external data, not source files. A build server would need the API keys, and the data would still be stale between builds.

Keeping the sync on the machine that holds the keys means the secret never leaves it and the retrieval is always current. The trade is honest: deploys depend on that machine being around. For a personal site with one author, that is a fair trade; for anything with a team it would not be.

What I would do differently

  • Write the guard rails first. The empty-catalogue check took ten minutes and would have prevented a silent bad release.
  • Make generated files refetchable, not committed. Anything regenerated on every publish should be ignored by version control, or every release leaves the working tree dirty and the noise trains you to stop reading git status.
  • Verify by content, not by status. Already said, but it bears repeating. It is the single highest-value habit in this whole setup.

The full picture lives in the site's own README, and the music page you can see is the direct output of that first step.