SeriesRunning it6

Rebuilding only what changed

Running it — part 1 of 6

A publish rebuilds the site, and until 1.6 that meant rebuilding all of it: every page rendered, and every file read back off disk to find out whether its bytes had moved. On an archive of a few thousand posts that is most of a minute, every time, for a change that touched a dozen files.

Since 1.6 the build writes down what it produced -- in .build_cache.json in the installation directory -- and the next build leaves alone the pages whose inputs have not moved. Nothing you do changes: ./blog.sh publish and ./blog.sh rebuild work exactly as before, only quicker. The summary at the end says how many pages were left alone, so you can see it working.

Measured on this project's own archive of 6,639 posts, on the server it lives on: a rebuild that changes nothing now costs a seventh of what it did, and an ordinary publish about two fifths. Backdating is the case it can do least about, and the measurement says so plainly -- a post dated into the early 2000s still costs four fifths of the old build. A post arriving in the middle of the archive shifts every listing page between the front page and where it lands, so those pages really did change and really do have to be built. Importing an archive, which is backdating several thousand times over, is a full build for the same reason.

Read more

A published picture is the archive's own file

Running it — part 2 of 6

Until 1.6 every picture existed twice: once in media.nosync/, and once as a copy of itself under public.nosync/. On this project's own installation that was 1.8 GB counted twice, and every import doubled again.

They are one file under two names now. Nothing about publishing changes, and neither does the archive's copy: deleting a picture from public.nosync/ -- by hand, or by the sweep, or by a deploy with --prune -- drops that name and leaves the archive's. What DOES follow the link is the mode: making the published picture readable makes the original readable too, which is the direction that was wanted, because a picture the web server cannot read was the bug this makes impossible.

It saves disk and it does not save backups. Measured against this project's own: 200 files under two names take 101 MB on the disk and 201 MB in the backup, because the backup copies file by file and stores each name in full. Back up media.nosync/ as you always did -- that is the archive's copy, and it is the one that matters.

Deploying

Running it — part 3 of 6

./blog.sh rebuild = build + deploy with --prune in one step; the publish/edit flows run it for you. The deploy script alone:

./scripts/deploy-web.sh             # only new/changed files
./scripts/deploy-web.sh --dry-run   # print what would happen, touch nothing
./scripts/deploy-web.sh --prune     # also delete files the build no longer generates
./scripts/deploy-web.sh --force     # ignore the manifest, re-upload everything
./scripts/deploy-web.sh --only=A,B  # just the listed files

Things worth knowing:

  • The safety guards. Four of them, all measuring this build against the last build a deploy accepted -- recorded in .deploy_baseline.json before the first byte goes out, so no upload failure can move it:
GuardTrips atWhat it does
File count dropped>20%, at least 8 filesstops
Total bytes dropped>50%, at least 25 MBstops
File count grew>20%, at least 25 filesstops
Total bytes grew>50%says so, continues
Read more

Cron (sidebar widgets and post stats)

Running it — part 4 of 6

scripts/refresh-sidebar.sh refetches the widget JSON (toots, Bluesky, Pixelfed, commits, RSS) and per-post stats, then uploads only those files -- no site rebuild:

*/30 * * * * /path/to/blog.sh/scripts/refresh-sidebar.sh

Every 30 minutes is plenty. The crontab lines in this section are recommendations; what actually runs is whatever the installation's own crontab says -- installing it is step 7 of Getting started. Post stats refresh live for posts younger than ~90 days; older posts get a full refresh about once a week (tracked in .stats_full_refresh_at). A failed fetch keeps the last known content rather than publishing an empty widget -- a one-minute network hiccup never blanks the sidebar. Systems without cron: a systemd timer or launchd job invoking the same script does the same thing. No widgets configured = no cron needed.

The on_this_day card (see On this day) fetches nothing, but its day turns over only here: its file is rewritten when the date changes and left alone on every other tick, in the site root and in every language tree.

Read more

Backup

Running it — part 5 of 6

Back up the per-deployment data -- the engine itself is a git clone and everything generated is rebuildable:

PathWhy
content.nosync/posts/the posts -- the one thing that's truly irreplaceable
content.nosync/versions/what each edited post said before its last ten saves, which is what [v] restores from (Properties and actions). It sits beside the content and dies with it, so a backup of the posts alone keeps the archive and loses the undo
media.nosync/their images and videos. A published picture has been a hardlink to this file since 1.6, so a backup still stores it once per name -- this is the copy that matters (A published picture is the archive's own file)
config/site.ymlsite identity and integrations
config/site.<lang>.ymlwhat the site says about itself in each further language: its title, menu, the words for its tags and series, and where a language without a locale borrows its interface. Gitignored like site.yml, so a fresh clone has none -- and a site that names ui_language in one does not build without it
assets/images/header.png, assets/images/favicon.pngyour banner and icon -- gitignored, so a fresh clone brings back the engine's defaults instead, silently (Banner and favicon)
env.shtokens (or re-create them; mind the file's 600 mode in backups too)
trash/optional -- deleted-but-recoverable posts
config/palettes.ymlonly if you added a palette of your own -- the file itself ships with the engine
Read more

Troubleshooting

Running it — part 6 of 6

SymptomCause and fix
Anything at all, and you need to know what you're running./blog.sh version -- it needs neither env.sh nor a config, on purpose.
The site looks wrong, or you want to change how it looks./style.sh -- a menu over everything that decides how the site looks and what it says about itself; it lists its own sections. The bio, the footer note, the copyright line and the banner's claim are Markdown, the same as a post, and raw HTML still works in them; a multi-paragraph value needs YAML's literal block (`-) -- the notes in config/site.yml.example` show why. Picking a palette offers a preview (light and dark side by side, uploaded to the site too so a phone can see it) before anything is written -- temporary on purpose, the next build takes it down -- and the run ends by offering a rebuild.
Anything config-shaped, and you want the whole picture./blog.sh doctor -- it reads whatever is on disk and reports every problem at once, each with a fix line. It runs on a config too broken for anything else to load, including one whose YAML won't parse, and needs neither env.sh nor a valid config. Add --online to also ask whether the feeds, the analytics script and the access token still answer, and whether the deploy target does -- with what stands in its root that the site did not put there, and --strip-location to clean the location out of photos saved before the engine did it on the way in (Writing from a phone); rebuild afterwards, since the site is built from what it cleans.
Anything archive-shaped -- a hole where a picture should be, a link that leads nowhere./blog.sh check -- it walks every post and every media file and says what is broken inside the archive, each finding with a fix line; --online additionally asks whether the links that leave the site still answer (Checking the archive). It only ever reports: nothing is deleted or rewritten, so it is safe to run at any time, including from cron.
config/site.yml is not valid YAMLThe message names the line and column. Almost always a tab where spaces belong, a missing quote, or a colon inside an unquoted value (title: Colon: here). ./blog.sh doctor says the same thing without stopping at the first problem.
A save aborted and took your text with itIt didn't: the text is in .last-edit.md, and the next add/edit offers it back -- [r] opens the editor on it, [d] throws it away, [c] leaves it alone. Text from an interrupted edit <slug> is only offered to that same post: restoring it into an add would make a second post out of it, so the offer names the command that does continue it.
Missing env.sh./setup.sh, or copy the template by hand: cp env.sh.example env.sh && chmod 600 env.sh. An unedited copy works locally.
Missing config/site.ymlSame two ways: ./setup.sh, or cp config/site.yml.example config/site.yml and fill it in -- the build refuses to guess.
Duplicate year/slug ... build stoppedTwo posts resolve to the same URL and media directory. Rename one slug; the build aborts rather than silently overwriting one with the other.
Deploy stopped with a "% drop/increase" messageOne of the four guards (Deploying) -- broken build until proven otherwise, --force only when the change is intended.
Deploy or save stopped naming an oversized fileThe one file-size limit (Deploying) -- shrink the file, or take it out of the post and link to it instead.
N deploys in a row have not finishedSomething is refused every time; the failures listed under that line say which (Deploying).
Surfer ... refused the access token (HTTP 401)Surfer 7 no longer takes access tokens. Create an app password in your Cloudron profile (Profile -> App Passwords) and set SURFER_USERNAME and SURFER_PASSWORD in env.sh (Getting started).
Surfer ... refused the username and password (HTTP 401)SURFER_USERNAME is the Cloudron username, not the e-mail, and SURFER_PASSWORD an app password, not the Cloudron sign-in password. A refused sign-in stops the deploy at the first file, and ./scripts/deploy-web.sh then exits with 4 rather than 1, so a script can tell "fix env.sh" from "try again".
Mastodon API returned 401 / toot was not createdMASTODON_ACCESS_TOKEN missing, expired, or lacking the write:statuses scope. The post itself is fine -- fix the token and use ./blog.sh toot <slug>.
Posting to Bluesky failed / announcement not sentBLUESKY_APP_PASSWORD missing, revoked, or it's the account password instead of an app password (Settings → Privacy and security → App Passwords). The post itself is fine -- fix it and use ./blog.sh bluesky <slug>.
Every comment disappeared after turning on comments.approvalExpected until you star them -- moderation publishes only favourited replies (Cron). If starring changes nothing either, ./blog.sh doctor --online catches the usual cause: a token without read:statuses.
A comment you starred still isn't on the siteIt arrives with the next refresh-sidebar.sh run; an older post waits for the full pass, which --full does now (Cron). Check the cron job is actually installed.
comments.approval is on but MASTODON_ACCESS_TOKEN is not setThe cron run refused to publish rather than emptying every thread; the pages keep their last known comments until the token is there (Cron). Same for BLUESKY_APP_PASSWORD on a Bluesky site.
Sidebar widget disappeared from the pageIts fetch returned nothing repeatedly (refresh-sidebar logs say which) -- the widget card hides when its JSON is empty/unreachable. Check the instance/feed URL in config/site.yml.
Read more