< the skull

FROM PROSE.SH TO POSSE
======================
2026-10-14

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.


< the skull