---
title: 'POSSE to Mastodon: State, Media Uploads, and Safer Deploys'
description: 'How Mastodon syndication was added to the deploy pipeline: RSS-based posting for articles and notes, OG image uploads, idempotent state tracking, and rate-limit hardening.'
pubDate: '2026-03-24T15:10:00+01:00'
category: en/development
tags:
  - indieweb
  - mastodon
seriesParent: joining-the-indieweb
seriesOrder: 9
---

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:

```js
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.

## Idempotency and syndication links from state

**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:

```json
{
  "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:

```bash
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.
