POSSE to Mastodon: State, Media Uploads, and Safer Deploys

Category: Development

Tags: indieweb, mastodon


After the Bluesky flow settled down, Mastodon was next. The brief was the same: no manual copy/paste, no duplicate posts on re-runs, and enough robustness that a large backfill wouldn’t fall over on the first rate-limit. The result is a dedicated script, a separate state file, and a deploy hook that mirrors the Bluesky setup piece for piece.

The setup

  • Site: Astro 6 static build with per-type RSS feeds (articles.xml, notes.xml).
  • Deploy: shell script that builds, rsyncs to a VPS, then runs syndication scripts one per target.
  • Mastodon: standard REST API, app token, media upload + status creation in two calls.

A dedicated Mastodon syndication script

scripts/mastodon-syndicate.mjs reads two feeds — articles and notes — and posts anything not already in state:

const SOURCES = [
  { rssUrl: "https://adrian-altner.de/rss/articles.xml", label: "article" },
  { rssUrl: "https://adrian-altner.de/rss/notes.xml", label: "note" },
];

Each item is parsed, filtered against state, and — if new — posted with media and text.

Problem: the script is called on every deploy. Without persistent state, it would re-post everything, every time.

Implementation: state lives in .mastodon-posted.json, keyed by the canonical content URL with the Mastodon status URL as the value:

{
  "https://adrian-altner.de/articles/2026/03/24/.../": "https://mastodon.social/@altner/1162..."
}

Solution: one data structure, two jobs.

  1. Dedupe — already-posted URLs are skipped on the next run.
  2. Display — article and note pages render Shared on Mastodon automatically by looking up the canonical URL in this file.

Post text and media

Post bodies follow a fixed shape:

Title

Teaser

Canonical URL

For media, the script fetches the og:image from each content page, compresses it with sharp, uploads it to Mastodon’s media API, and attaches media_ids[] when creating the status.

If the media upload fails, posting continues without media instead of failing the whole run. A post with text but no image beats no post at all.

Deploy integration

The deploy script reads Mastodon env vars and runs the script after deploy:

MASTODON_BASE_URL=...
MASTODON_ACCESS_TOKEN=...
MASTODON_VISIBILITY=public

When .mastodon-posted.json changes, it’s committed and pushed automatically — exactly like .bluesky-posted.json. Without that commit step, state would reset on the next checkout and old items would be re-posted on subsequent runs.

Hardening for real-world runs

Problem: two operational issues showed up almost immediately.

  • Large backfills can hit media upload rate limits and return 429.
  • First-run validation is the worst time to discover a bug — you want to simulate before committing to posting everything.

Implementation: the script now supports:

  • Retry with backoff for media upload, honouring Retry-After when the server sends it.
  • MASTODON_DRY_RUN — simulate the run without calling POST endpoints.
  • MASTODON_LIMIT — post at most N items in this run, for controlled batches.

Solution: first-run and recovery runs become safe to attempt without changing the steady-state path. The flags are inert on normal deploys because in steady state there’s one new post, not 200.

What changed

After the wiring, new articles and notes syndicate to Mastodon on every deploy, with status URLs persisted to local state and rendered as u-syndication links on each post page. The site stays the canonical source — POSSE — and Mastodon becomes a distribution channel that can be rebuilt from feeds + state if needed.

What to take away

  • Mirror the shape that works. Separate script, separate state file, same deploy hook as Bluesky — no shared state, no shared failure modes.
  • Key state by canonical URL, value by remote URL. That one shape handles both dedupe and u-syndication rendering.
  • Fail open for media. If the image upload breaks, the text post should still go out.
  • Backoff and Retry-After are not optional for backfills. Rate limits are the one thing that absolutely will hit you on first run.
  • Dry-run and batch-limit flags save you once. And “once” is all it takes — build them before the first big backfill, not after.