One Post, Many Webs
A while ago I wrote about the little bridge that keeps my two blogs in sync โ Ghost as the canonical home, prose.sh as a plain-text mirror, and even a way to publish by sending an email. One post, two blogs, zero copy-paste.
That should have been the end of the story.
It wasn't, because I remembered I also had a Gemini capsule and a gopher hole quietly rotting somewhere, each with content from two eras ago. And once you have a bridge that reacts to "post published" and delivers things over SSH... well. You know how this goes.
This is part two: how one webhook ended up feeding the entire small web, two fediverse accounts, Bluesky, and every site I link to. Same rule as before โ one source of truth, one road out.
The small web speaks differently
Gemini and gopher aren't just "small HTTP". They have opinions.
Gemtext, Gemini's format, is radical: no inline links, no inline formatting. A link gets its own line, starting with =>. So you can't just dump Markdown there โ every [link](url) in the middle of a sentence has to go somewhere.
The community convention is to hoist links out of the paragraph and list them right below it. So the converter reads a paragraph, strips the link syntax, remembers what it found, and flushes it after:
markdown in:
Last year I [switched ISPs](https://example.com/isp) and lost my public IP.
gemtext out:
Last year I switched ISPs and lost my public IP.
=> https://example.com/isp switched ISPs
Gopher is even older-school: plain text, and the unwritten rule says keep it under ~67 columns because that's what classic clients render comfortably. Links become numbered footnotes, like a scientific paper written on a napkin:
gopher out:
Last year I switched ISPs [1] and lost my public IP.
Links:
[1] https://example.com/isp
The whole converter is one pass over the Markdown, line by line, with two renderers at the end. No AST, no dependency with 400 transitive packages. Gemtext is simple enough that respecting it is easier than fighting it.
Ghost is the manifest
Here's the design decision I'm most happy about, and it costs nothing: the bridge keeps no database of posts. None.
Every time a post is published, the bridge asks Ghost's Admin API for the full list of published posts and regenerates the indexes โ the gemlog index, the gopher menu, the Atom feed โ from scratch:
on publish:
1. deliver the new post (slug.gmi, slug.txt)
2. fetch ALL published posts (Ghost Admin API, paginated)
3. rebuild every index from that list
4. deliver the indexes
Stateless. Always correct. If an index ever looks wrong, restarting the service fixes it, because there's nothing to corrupt โ Ghost is the manifest.
And you get backfill for free: populating years of existing posts into a freshly wiped capsule is the same code path, just looped. One flag, one restart, the whole archive appears on gemini and gopher. Delete the flag, done.
Pagination, because archives grow
A gemlog index with the whole archive on one page is fine at 10 posts and absurd at 200. So indexes paginate โ twenty per page, with newer/older navigation:
gemlog/index.gmi <- newest twenty
gemlog/page-2.gmi <- next twenty
gemlog/page-3.gmi ...
phlog/gophermap <- newest twenty
phlog/page2/gophermap <- next twenty
Since indexes are rebuilt from zero every time, stale pages get deleted before writing. If posts disappear, pages disappear. No ghosts. (Well. Except the canonical one.)
Gopher's dirty little secret
Here's a protocol fact that cost me an evening: gopher requests don't include the hostname. No Host header, nothing. The client connects to an IP and sends a selector, and that's it.
Which means: if two domains point at the same server, port 70 cannot tell them apart. Name-based virtual hosting, the thing HTTP has had since the 90s, is physically impossible in gopher.
I had an existing gopher site on another domain, same box. The fix is beautifully dumb: the new hole owns the root, and the old site survives as symlinks inside it, so every old deep link still resolves:
/var/gopher/
gophermap <- new home (the menu controls what's visible)
phlog/
oldsite/ -> /home/oldsite/gopher (branding link)
archive/ -> /home/oldsite/gopher/archive (compat: old selectors)
The menu shows what I want; the symlinks keep two decades of gopher etiquette intact. Everyone's links still work, nobody notices anything changed. That's the best kind of migration.
Homepages worth the trip
If someone bothers to open a Gemini client or a gopher browser in 2026, the least I can do is greet them properly. Both homepages got rebuilt from scratch: a FIGlet banner, a piece of classic ASCII art (credited โ small web, good manners), and short English copy.
The static files are baked into the container image and pushed on startup, so "redeploy the homepage" is just "rebuild the container". The blog sections underneath them are regenerated by the bridge, so the homes never go stale.
Retiring the feed robot
For social announcements I used to run my posts through a hosted RSS-to-everywhere service. It worked, but it's polling โ the announcement shows up whenever the feed gets read next โ and it's one more account, one more dashboard, one more thing.
The bridge already knows the instant a post goes live. So it now announces natively:
- Mastodon-compatible instances (including GoToSocial): one
POST /api/v1/statuseswith a Bearer token. The post's feature image gets downloaded and attached as media, with the title as alt text. - Bluesky: a session, an optional thumbnail blob, and a post whose link lives in an external embed card โ so the URL doesn't eat into the 300-character budget, and you get the pretty preview.
The announcement text writes itself from what the post already has: an excerpt if I wrote one, otherwise the first lines of the content. And instead of a robotic "New post:", the opener rotates โ deterministically, hashed from the slug, so retries don't reroll it:
openers = [ "Hot off the press:", "Fresh from the lab:",
"Straight from the terminal:", "Just shipped:",
"New transmission:" ]
pick = hash(slug) % len(openers)
Same post, same opener, forever. Different posts, different flavor. Nobody suspects a robot.
The double-post problem
Mirrors are forgiving: deliver the same file twice and you've overwritten it with itself. Social is not. Every announcement is a new item in someone's timeline, and re-announcing a typo fix is how you teach people to unfollow you.
Two layers fix it:
One โ split the events. Ghost fires webhooks for "post published" and "published post updated". Give each its own query string:
.../hooks/ghost?event=published -> mirrors + announce + webmentions
.../hooks/ghost?event=updated -> mirrors + webmentions. NEVER announce.
Editing a post updates every mirror and stays silent on social. Exactly what you want.
Two โ keep a ledger. A tiny JSON file in a persistent volume records which slugs have been announced:
["my-first-post", "that-networking-rant", "one-post-many-webs"]
Webhook retries, container restarts, full backfills โ none of them can announce twice, because the slug is already in the book. The ledger only gets written when at least one network accepted the announcement, so a total outage retries cleanly next time.
Belt, suspenders. Timelines unspammed.
Webmentions: closing the loop
The last piece is the most indieweb thing of all. When a post links to someone's site, the polite move is to tell them โ that's a webmention. On publish (and update โ receivers dedupe, it's in the spec), the bridge:
- extracts every external link from the post,
- asks each target "do you accept webmentions?" โ an HTTP
Linkheader or arel="webmention"tag in the HTML, - and if yes, sends a tiny form:
source=my post, target=your page.
POST https://example.com/webmention-endpoint
Content-Type: application/x-www-form-urlencoded
source=https://myblog.example/one-post-many-webs/
&target=https://example.com/the-page-i-linked
It runs in the background after the webhook responds, with timeouts, skipping my own domains, capped at a sane number of targets. Most sites don't accept webmentions. The ones that do get a knock on the door.
What the flow looks like now
write in Ghost โโ or send an email โโโ
โ one webhook
โผ
โโโโโโโโโโโโโโโโโโโ
โ bridge โ
โโโโโโโโโโฌโโโโโโโโโ
โโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโผโโโโโโโโโโฌโโโโโโโโโโโ
โผ โผ โผ โผ โผ โผ
prose.sh gemini:// gopher:// fediverse Bluesky webmentions
(markdown) (gemtext) (67 cols) (2 accts) (card) (to whoever
I linked)
One post. Seven destinations. Zero extra effort at publish time โ the effort was spent once, building the pipes.
Lessons from part two
- Statelessness is a feature you choose. Making the CMS the single manifest deleted a whole class of bugs (and the database that would've hosted them).
- Respect each medium's grammar. Gemtext without inline links, gopher at 67 columns, Bluesky's 300 characters. Converting properly beats mirroring lazily.
- Separate "changed" from "new". Mirrors want both events; announcements want exactly one. A query string was all it took.
- Idempotency needs memory only where actions aren't reversible. Mirrors overwrite; social needs the ledger. Put state where it's mandatory, nowhere else.
- Old protocols deserve real engineering. The gopher hostname problem is unsolvable in-protocol โ but a menu plus symlinks made the migration invisible. Constraints breed elegant hacks.
The bridge started as "I'm tired of running scp twice." It's now a tiny syndication hub that speaks five protocols, and I still just click Publish.
Boring magic, compounding.
And yes. It's still working.
See you around.
Member discussion