← Pablo Murad

From prose.sh to POSSE

What started as a simple way to avoid publishing the same blog post twice gradually turned into a personal POSSE system connecting my main blog to prose.sh, Gemini, Gopher, Mastodon, Bluesky, and other corners of the small web. This article follows that process, from the first Ghost webhook to the less glamorous problems of keeping everything in sync, handling failures, and preventing old events from deleting live content. Along the way, a small collection of scripts became a service that lets me publish once and have my writing find its way across different platforms and protocols, without depending on any single one of them.

Contents

At the end of May 2026, I started a blog on prose.sh. It's part of pico.sh, a collection of small services accessed over SSH. Publishing suited me: write a Markdown file, copy it to the server, and you're done.

scp my-post.md prose.sh:/

No dashboard or browser-based editor. No login page. A second later, the post is online. I liked the arrangement enough to set up a dedicated folder on June 1, along with a small command-line tool to publish, retrieve and delete posts without having to remember the flags.

There was one problem. I already had a blog.

Back then, pablomurad.com ran on Ghost, and that was where everything went first. After publishing there, I had to turn the same post into Markdown and send it to prose.sh. Twice the work, same words.

In an earlier post, I described the original motivation as 'I'm tired of running scp twice.' That remains the most accurate explanation. What I hadn't anticipated was that, four and a half months later, this little shortcut would be sending posts to nine destinations, pulling from six sources, and maintaining several ledgers to avoid publishing the same thing twice.

What POSSE means

POSSE stands for Publish (on your) Own Site, Syndicate Elsewhere. The idea comes from the IndieWeb: keep the original on a site you control, then send copies to the places where people read you. A network can shut down, change its rules or ban your account. Your original post stays where it belongs, at its own address.

The service is called pico-server, a name left over from its beginnings in that pico.sh folder. It runs on Node 20 and TypeScript, with roughly 7,000 lines of code at this point, inside a Docker container on a Debian machine at home. There is no database. A few JSON files on a volume hold everything it needs to remember.

The first version (June 27)

Initially, there was only one job. Ghost sends webhooks when a post is published, edited or deleted. pico-server would receive the request, verify its signature, retrieve the post through Ghost's Admin API, convert the HTML to Markdown using turndown, and transfer the file to prose.sh with scp. Deleting a post in Ghost deleted its copy on prose.sh as well.

That same day, email became another way in. Mailgun forwards messages to the server, which uses Ghost's API to create a post. Ghost then triggers its usual webhook, and everything follows the same path. I didn't want two separate publishing pipelines. Email only handles the first step.

That was supposed to be it.

The small web (July 2)

Then I remembered my Gemini capsule and Gopher hole. Both had been neglected, with content dating back a couple of eras. Once publishing events were already triggering file transfers over SSH, adding them seemed like a natural next step.

Gemini and Gopher are smaller, older protocols with little use for HTML. Gemini uses gemtext, which doesn't allow inline links. Links go on separate lines. Gopher uses plain text, typically around 67 columns wide. The converter moves links out of paragraphs and places them underneath in Gemini; in Gopher, they become numbered footnotes. Whenever a post changes, the service also regenerates the gemlog, the phlog and an Atom feed, with twenty posts per page.

I covered that stage in 'One Post, Many Webs'. One decision from then has stayed the same: the bridge doesn't maintain its own inventory of posts. It asks the source site for all published posts and recreates the indexes from scratch. If an index goes wrong, a restart can put it right. There's no separate index state to repair.

Social announcements arrived on July 2 as well. Previously, a hosted RSS-to-social tool polled my feed and published whenever it noticed something new. But pico-server already knew exactly when a post appeared, so there was no reason to wait for a poll.

It now posts to Mastodon-compatible instances through their APIs, including the featured image. Bluesky gets a preview card, which keeps the link from using up its 300-character limit. Webmentions followed: when an article links to someone else's page, the server checks for a webmention endpoint and, if one exists, sends a notification.

Breaking things in production

July and August brought the sorts of problems that don't show up until software has to work every day.

The private network between home and the VPS hosting my capsule could take a while to reconnect after a restart. Early writes sometimes failed. The server now waits up to three minutes for connectivity, retries individual operations, and uploads files under temporary names before moving them into place. That last detail prevents someone from loading a half-written page. Each remote machine also has its own lock, so two posts published close together can't overwrite each other's files.

A retry queue appeared on July 17. If delivery fails, the job stays in a queue that runs every 15 minutes, with a limit of 20 attempts. That day also brought replay protection. Webhooks include a timestamp and signature; an event more than five minutes old, or one already processed, is rejected.

August 9 was less pleasant. Ghost began timing out on webhooks because the server was taking too long to respond while uploading to slower destinations. The remedy was straightforward: check the signature, return HTTP 202 immediately, and continue the work asynchronously.

Then came an outage. When the service returned, Ghost resent older events out of order. A stale deletion event removed a prose.sh copy even though the post was still live on the main site. Deletions now require a fresh check against the source: if the article still exists and remains public, the event is ignored.

There was another compromise in August. SSH host keys are no longer pinned for these destinations. One machine gets reinstalled occasionally, and every reinstall had meant another round of manually clearing known_hosts before uploads would work. These are machines on my own network, so I accepted the security trade-off.

A BBS in a browser (August 17)

theskull.org lives on my home network and looks like an old bulletin board system: a white page, black monospace text, an 80-column layout, and an ASCII skull at the top. It became one more mirror. The Gopher renderer provides the text, a little HTML wraps it, and SSH handles delivery, as it does for the other destinations.

Two small rules arrived around the same time. Text mirrors omit images but retain their alt text. URLs have tracking parameters, such as utm_ tags, stripped before publication. Posts tagged #photo remain on the main site; a photo gallery doesn't translate particularly well to Gopher.

Reply by email

Your message goes straight to Pablo Murad. Your address is only used to write back.

Comments

Leave a comment

Comments are read before they show up here. No email address needed.