Oink: Self-Hosting My Bluesky Feeds

When SkyFeed's backend went down and my Homegrowing feed stopped updating, I rebuilt both my Bluesky feeds as a tiny self-hosted Go service — and swapped the hosting underneath them without losing a single subscriber.

A small black home server on a wooden desk with blue and green LEDs and light trails suggesting a stream of data, with the post title overlaid
Both feeds now run on a ~20 MB Go binary on my own server.

It started, as these things often do, with a stranger being nice about it. Yesterday someone who follows my Homegrowing feed on Bluesky posted to ask — politely, a little worried — whether something had broken, because the feed had stopped updating. New posts weren't showing up. Had I pulled the plug?

I hadn't. But something had broken, and it wasn't on my end at all.

The thing that broke

Both of the custom feeds I run — Homegrowing and SelfHosting — were built in SkyFeed, the wonderful no-code builder for Bluesky feeds. You drag blocks together in a UI (a firehose input, a couple of regex filters, a "remove replies" block, a sort) and it hosts the whole thing for you. For a long time it just worked, and I never thought about it.

The catch with any hosted thing is that when it goes down, you're a spectator. I opened the SkyFeed builder and the preview pane lit up red:

The SkyFeed builder UI showing the Homegrowing feed with a red error in the preview pane: connection refused to the internal query-engine backend
Error trying to load feed… connection refused. The builder couldn't reach its own backend.

The builder UI talks to a hosted "query engine" — a service that keeps a rolling window of firehose posts in memory and runs your blocks against them. When that backend is unreachable, the front end throws Connection refused against an internal address and there is precisely nothing you can do about it from the outside. My feed logic was fine. The host was simply down.

I could have waited it out. Instead I decided this was the nudge I needed to own the whole thing, and spent the evening rebuilding both feeds as a small self-hosted service running on my own server. I called it oink.

How Bluesky feeds actually work (and why this is easy)

Here's the part that makes this a fun afternoon project instead of a scary migration: on Bluesky, a "custom feed" is almost nothing. It's just a record in your own repository — an app.bsky.feed.generator record — that holds a display name, a description, an avatar, and one important field: a did pointing at whatever backend actually serves the posts.

When someone scrolls your feed, the Bluesky AppView resolves that did, finds an HTTPS endpoint, and makes a single request:

GET /xrpc/app.bsky.feed.getFeedSkeleton?feed=at://…/homegrowing&limit=50

Your backend answers with a bare list of post URIs in the order you want them. That's it. No post content, no rendering, no auth for a public feed — just "here are the at-URIs, newest first," and the AppView hydrates the rest.

💡 The key insight: SkyFeed published the generator record into my repo and only pointed its did at SkyFeed's servers. So I don't have to publish a new feed and beg everyone to re-follow it. I just change that one did field to point at my server instead. Same feed URI, same subscribers, same everything — different backend.

So the job had two halves: build a backend that can answer getFeedSkeleton, then flip one field.

Crafting a feed by hand

oink is a single Go binary. It opens one websocket to the Bluesky Jetstream (a friendly JSON version of the firehose — no CBOR decoding), filters every incoming post against my feed rules, and stores the matches in a rolling-window SQLite table. When the AppView asks for a skeleton, it's just a keyset-paginated SELECT. Pure-Go SQLite, no CGO, a distroless image around 20 MB that idles at ~30 MB of RAM. It's almost embarrassingly small.

A laptop on a dark desk showing code in a terminal, lit by a single desk lamp, with a mug of coffee beside it
The whole feed lives in one YAML file — edit, restart, done.

The nice thing is that all the feed logic lives in one feeds.yaml file. No rebuild to tweak a feed — edit the YAML, restart, done. Here's the entire SelfHosting feed:

- rkey: aaaelrcv5zuxy          # matches the live generator record
  displayName: SelfHosting
  window: 72h                  # keep a rolling 3 days
  include:
    - { pattern: "self[\\s-]?host|home[\\s-]?lab|home[\\s-]?server", targets: [text, alt_text, link] }
  removeReplies: true

Read it top to bottom and it's exactly the SkyFeed block stack, just written down instead of dragged: pull from the firehose, keep posts whose text, image alt-text, or links match the pattern, drop replies, sort newest-first (the default). Each feed compiles to an ordered chain of predicates and a post is included only if it passes every one.

Homegrowing is the same idea with more opinions — a couple of include rules (cannabis and grow terms), a long exclude rule to keep out the news/market/police-blotter noise that those keywords drag in, and a block list:

- rkey: aaalje4tux676
  displayName: Homegrowing
  window: 168h                 # 7 days
  include:
    - { pattern: "\\b(?:cannabis|weedsky|marijuana|kush|sativa|indica)\\b|\\b(?:growmie|autoflower|thc|cbd)", targets: [text, alt_text] }
    - { pattern: "\\b(?:homegrow|grow|seed|clone|harvest|cultivat|flowering|phenotype)|\\bpheno\\b",          targets: [text, alt_text] }
  exclude:
    - { pattern: "\\b(?:revenue|market|lawmaker|business|crime|police|raid|news|narcotics|illegal)|\\btax(?:es)?\\b|\\bt\\.me", targets: [text, alt_text] }
  removeReplies: true
  blockLists:
    - at://did:plc:pkz74ndca2yxx4r5kgkibvd5/app.bsky.graph.list/3lkmmscqurh2x

Translating the rules by hand was also a chance to fix a long-standing annoyance. SkyFeed treats regex tokens loosely, so a naive raid in the exclude list also nukes "afraid," tax eats "taxonomy," and shop swallows "workshop." Since I was writing the patterns out anyway, I wrapped the whole-word terms in \b word boundaries and left a leading-only boundary on the stems I actually want to grow suffixes (so cultivat still catches "cultivate" and "cultivation"). Same feed, fewer false drops. That alone made the rebuild worth it.

The other endpoints are equally boring, which is the point — a static /.well-known/did.json so my did:web:feed.dwot.io identity resolves, a describeFeedGenerator that lists what I serve, and a /health check that reports row counts and the last event time so I can see at a glance that the firehose is still flowing.

Not starting from empty

One wrinkle: a brand-new feed generator starts with an empty database and only fills as new posts arrive over the firehose. Cut over cold and Homegrowing's seven-day window would have looked barren for a week. So before flipping anything I added a small -backfill mode that pulls the existing posts back out of SkyFeed one last time and seeds the SQLite store with them. The feed was full from the first second it went live.

The seamless swap

With the backend deployed behind a reverse proxy and TLS — and the did:web document resolving publicly — the actual cutover is one operation per feed: a com.atproto.repo.putRecord that rewrites the same record with the same rkey, the same name, the same avatar, changing only the did to point at my server. I wrote it as a tiny one-shot tool so I'd never have to hand-craft the XRPC calls:

# snapshot the current records first (saves the old did for rollback)
go run ./cmd/publish list

# spin up throwaway "TEST" copies and compare them side-by-side
# against the live SkyFeed feeds for a bit, to confirm parity
go run ./cmd/publish test -yes

# the actual cutover: swap did -> did:web:feed.dwot.io on both feeds
go run ./cmd/publish cutover -yes

Every write command is a dry run unless you add -yes, so you can watch exactly what it'll change before it changes it. And because the only thing that moved is one field, rollback is the same operation in reverse — publish rollback writes the saved original did back and you're on SkyFeed again in seconds. Knowing I could undo it instantly took all the tension out of pressing the button.

From a subscriber's point of view, nothing happened. No re-follow, no new feed to find, no broken link in anyone's pinned feeds. The feed URI they've always used just quietly started being served by a 20 MB Go binary on my own hardware instead of someone else's query engine. The posts started flowing again, and the only person who noticed the difference was me.

What I actually gained

SkyFeed is genuinely great, and I'm grateful it existed to get these feeds off the ground without writing a line of code. But a feed I care about shouldn't go dark because a backend I don't control had a bad night. Now the whole pipeline — firehose in, filters, storage, skeleton out — runs on a server I can SSH into, backed up with the rest of my homelab, with rules I can read in a text file and improve whenever I notice the filter being dumb.

And the headline finding, if you take nothing else from this: hand-building a Bluesky feed is a single evening of work, and swapping the hosting underneath an existing feed is one reversible field change. If you've been leaning on a hosted builder and quietly worrying about that exact dependency — it's a much smaller leap to owning it than you'd think.