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.
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 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=50Your 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 itsdidat SkyFeed's servers. So I don't have to publish a new feed and beg everyone to re-follow it. I just change that onedidfield 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.

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: trueRead 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/3lkmmscqurh2xTranslating 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 -yesEvery 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.