Scheduled Posts On A Static Site

6 min readThis Blog, Web

Jake Marsh
In this post
  1. A Future Date Means Unlisted
  2. Building The Future Ahead Of Time
  3. Swapping It In
  4. Why rsync Is Good Enough Here
  5. Every Deploy Starts Fresh
  6. Building On Your Mac, Serving From The Server
  7. Everything Downstream Follows The Feed
  8. Drafts And Approval
  9. The Calendar Page

Deallocated Objects is a static site. A Bun/TypeScript generator on my Mac turns a folder of markdown into HTML, a deploy script copies the files to the server, and nginx serves them. There’s no app process for the blog, no database and nothing to restart.

I still wanted to finish a post on a Sunday, set it for Wednesday at 8am Central, and have it appear Wednesday at 8am. The server never runs the generator to make that happen. It just copies some files when the clock says so.

A Future Date Means Unlisted#

Every post has a date in its frontmatter. When that date is in the future, the post still gets a page, but it stays out of everything else. The whole rule is one line in src/content.ts:

src/content.tsTypeScript
// Scheduled posts: a future date means the page exists (noindex, unlinked) but the post stays out
// of every list until a build runs after that time. BUILD_AT=<iso> previews another moment.
export const buildNow = () => (process.env.BUILD_AT ? +new Date(process.env.BUILD_AT) : Date.now());
export const isListed = (p: Post, now = buildNow()) => !p.draft && +new Date(p.date) <= now;

orderPosts filters on isListed before it sorts anything, and the homepage, archive, topic pages, feeds and sitemap are all built from that ordered list. So a scheduled post isn’t in any of them. Its own page gets <meta name="robots" content="noindex">, no older/newer links, no markdown alternate, and a small banner at the top:

Scheduled for Wednesday, October 28 at 8:00 AM Central. This page is unlisted until then.

I like having the page exist early. I can send the link to someone for a read, and any link that goes out a little ahead of time (a social post, a newsletter draft) already works.

Building The Future Ahead Of Time#

Look at buildNow again. The generator never asks the system clock directly. It asks buildNow(), and BUILD_AT overrides it. So “what will the site look like Wednesday at 8:01am?” is the same question as “build the site with BUILD_AT set to Wednesday at 8:01am.”

That’s the entire scheduling engine. After the normal build and upload, scripts/deploy.sh finds every approved post with a future date and builds a complete copy of the site for one minute after each one goes live, each into its own folder:

scripts/deploy.shShell
rm -rf dist-scheduled && mkdir -p dist-scheduled
for t in $(bun -e 'const {loadContent}=await import("./src/content.ts"); const {posts}=await loadContent(process.cwd()); const n=Date.now(); console.log([...new Set(posts.filter(p=>!p.draft && +new Date(p.date)>n).map(p=>Math.floor(+new Date(p.date)/1000)+60))].join(" "))'); do
  echo "==> scheduled snapshot for $(date -u -r "$t" +%FT%TZ)"
  BUILD_AT="$(date -u -r "$t" +%FT%TZ)" DIST_DIR="$REPO/dist-scheduled/$t" bun src/build.ts >/dev/null
done

The extra minute makes sure the post’s own date is at or before BUILD_AT, so isListed says yes. The folder names are Unix timestamps. Those snapshots get uploaded to /opt/deallocated/scheduled/<unix time>/ on the server, next to the live site in /opt/deallocated/site/.

Each snapshot is the whole site as it will be at that moment. In the Wednesday snapshot the post is on the homepage and in the feeds, the noindex and the banner are gone, and the post before it has a “newer” link pointing at it. Two posts scheduled for different days get two snapshots, and the later one includes both.

Swapping It In#

On the server, a tiny shell script checks for snapshots whose time has passed:

deploy/deallocated-release.shShell
set -eu
dir=/opt/deallocated/scheduled
site=/opt/deallocated/site
[ -d "$dir" ] || exit 0
now=$(date +%s)
for d in $(ls -1 "$dir" | grep -E '^[0-9]+$' | sort -n); do
  [ "$d" -le "$now" ] || continue
  rsync -a --delete "$dir/$d/" "$site/"
  rm -rf "${dir:?}/$d"
  logger -t deallocated-release "released scheduled snapshot $d ($(date -u -d @"$d" +%FT%TZ))"
done

A systemd timer runs it every two minutes:

deploy/deallocated-release.timerINI
[Timer]
OnCalendar=*:0/2
Persistent=true

The service it starts runs as deallocated, the site’s own unprivileged user, with NoNewPrivileges=yes, ProtectSystem=strict and ReadWritePaths=/opt/deallocated. It can rewrite the blog’s files and nothing else on the box.

Persistent=true covers the case where the server is down or rebooting at the moment a timer should have fired. systemd remembers the missed run and does it as soon as it’s back up, and since the script looks for any snapshot at or before now (in time order), nothing gets skipped. If two are due at once, they’re applied oldest first and the newest one wins, which is the right end state.

Why rsync Is Good Enough Here#

rsync --delete from one local folder to another isn’t an atomic directory swap. While it runs, some files are new and some are old. I thought about flipping a symlink between two release folders instead and decided it wasn’t worth it for this site. rsync writes each file to a temporary name and renames it into place, so nginx never serves half a file, and it’s a copy between two folders on the same disk, so it’s over quickly. The only thing a reader could catch mid-copy is a new homepage linking to a post page that still has its “Scheduled” banner, and that’s harmless.

Every Deploy Starts Fresh#

The deploy script rebuilds the whole dist-scheduled/ folder every time and uploads it with --delete. That’s what keeps the schedule honest without any bookkeeping:

  • If I fix a typo in a scheduled post and deploy, its snapshot is rebuilt with the fix.
  • If I publish something else in between, every later snapshot includes it, because they’re all built from the current content.
  • If I move a post to a different day or pull it back to a draft, its old snapshot just isn’t in the new upload, so --delete removes it from the server.

There’s never a snapshot on the server that doesn’t match what’s in the repo.

Building On Your Mac, Serving From The Server#

The obvious alternative is putting the generator on the server and running a build on a timer. I’d rather not. The generator and its tools live on my Mac, and that’s where I want them: the AVIF and WebP images are encoded with macOS sips plus cwebp and avifenc from Homebrew, with a cache in ~/Library/Caches, and the deploy’s strict link check runs there too. The server’s job for the blog stays small. It serves files, and every two minutes it might copy some. Nothing on it ever builds the site, so nothing on it can build the site differently than my Mac does.

Pre-building also means I can open a snapshot folder and look at exactly what will go live on Wednesday before it happens.

Everything Downstream Follows The Feed#

The rest of publishing keys off one file: the live feed.json in /opt/deallocated/site/. Since the release script is the thing that changes it, everything downstream follows automatically.

The newsletter service has a pm2 cron (newsletter/autosend.ts) that runs every 10 minutes, reads feed.json straight off the disk, and emails any post that’s new since it started watching. It ignores anything more than three days old, so a rebuild can never re-send an old post.

The ActivityPub side of the same service has a 5-minute tick in newsletter/server.ts that does the same thing for followers:

newsletter/server.tsTypeScript
const apTick = async () => {
  try { for (const item of (await readFeed()).items) if (item.date_published > apSince! && ap.publish(item)) console.log(`ap: published ${item.url}`); }
  catch (e) { console.error(`ap tick: ${(e as Error).message}`); }
};
void apTick(); setInterval(apTick, 5 * 60e3);

Social announcements work the other way around. scripts/schedule-social.ts puts them on Octopost’s calendar ahead of time, two minutes after each post’s go-live time, with the title and description up top and the link in a threaded reply. If a post’s date changes it moves them, and if a post goes back to draft it cancels them. What got scheduled where is saved in content/announced.json, which is committed to the repo.

Drafts And Approval#

New posts start with draft: true. Drafts get a page too, with a different banner (“Draft preview. Not published; only people with this link can see it.”) and the same noindex. They never get a snapshot, because isListed is false for a draft at any time.

When I’m happy with one, scripts/approve.ts turns it into a scheduled post:

Shell
bun scripts/approve.ts scheduled-posts-on-a-static-site --at "wednesday 8am"

It reads times in Central, removes draft: true, writes the real date, generates the link-preview card and prints the next two commands: commit and run scripts/deploy.sh, then bun scripts/schedule-social.ts. There’s also --now for the next deploy and --undo to put a post back to draft.

The Calendar Page#

The build also writes a private editorial calendar at /review/<token>. The token lives in a file in the repo, the page is noindex and nofollow, it isn’t linked from anywhere, and /review/ is disallowed in robots.txt. It lists the drafts waiting for approval (with the exact approve.ts command for each), the scheduled queue with word counts and where each post will be announced, a five-week grid of color-coded chips for drafts, scheduled posts and published ones, and the last few posts that went out.

Since it’s built by the same generator with the same NOW, each snapshot’s copy of the calendar is correct for its moment too. On Wednesday at 8:02am, this post’s chip turns from “scheduled” to “published” without anyone touching it.