A Social Feed You SSH Into — One Domain Core, Two Front Doors
A friend, half-joking: “your feed’s just a backend with a REST API… could I ssh into it and read it in my terminal?” Yes — and you can do more than read: like, comment, reply, follow, delete, all over SSH, with your SSH key as the login. But here’s the part worth writing about: building the terminal UI was the easy bit. The interesting bit was making sure it reused all of the backend — zero duplicated logic — which forced a refactor I should have done anyway.
Liking, commenting, replying, and posting — all over SSH, identified by my public key, on a feed running on a 2008 PC.
(This rides on top of the whole news-feed — same Postgres, same everything. It adds a front door, not a feature.)
The idea, and the trap
Section titled “The idea, and the trap”The Go ecosystem has a lovely set of libraries from Charm: wish (an SSH server you write in Go), Bubble Tea (a terminal-UI framework), Lip Gloss (styling). Together: someone runs ssh feed.example.com and a TUI of the feed appears in their terminal. wish even hands you the client’s public key on connect — so the SSH key can be the login.
So the TUI needs to read posts and write likes/comments/follows/deletes. The lazy way: have the TUI talk straight to Postgres and run the inserts itself.
That’s a trap, and naming why is the whole point of this post. My write paths aren’t plain inserts — each carries real machinery:
- creating a post writes a transactional-outbox event so it gets fanned out and embedded;
- a like runs a Redis hot-counter with a cold-seed from the table;
- a delete enforces ownership;
- a comment can be a nested reply via a self-referencing parent.
If the TUI re-implemented those, I’d have two divergent copies of my logic — and the TUI’s copy would quietly skip the outbox (no fan-out), skip the counter, skip the ownership check. That bug isn’t hypothetical; it’s guaranteed the moment you copy-paste domain logic into a second caller.
So the real question was never “how do I write CRUD in a TUI.” It was: how does a second interface reuse the logic the first one already has?
The fix: a domain core, two adapters
Section titled “The fix: a domain core, two adapters”The answer is ports and adapters (hexagonal architecture). Pull the business logic out of the HTTP handlers into a package of plain functions — internal/service — and let both the HTTP handlers and the SSH TUI call them.
A service function looks like this:
func Like(ctx context.Context, q sqlc.Querier, counter *cache.Client, userID, postID int64) (LikeResult, error)No http.ResponseWriter, no ssh.Session — just domain inputs and a domain result (plus a sentinel error the caller translates). The HTTP handler shrinks to a translator:
res, err := service.Like(r.Context(), queries, counter, userID, postID)if errors.Is(err, service.ErrPostNotFound) { errorJSON(w, 404, "post not found"); return }writeJSON(w, LikeResponse{Liked: res.Liked, LikeCount: res.Count})…and the TUI calls the exact same function — it just renders the result as a line of green text instead of JSON.
Domain vs. delivery — the line that matters
Section titled “Domain vs. delivery — the line that matters”Doing this forces you to sort every feature into one of two buckets, and that sorting is the senior move:
| Feature | Shared (domain)? | In the TUI? |
|---|---|---|
| fan-out, outbox, embeddings | ✅ | yes — via the shared CreatePost |
| hot-counter likes | ✅ | yes |
| comment threading, ownership checks | ✅ | yes |
| idempotency keys | ❌ delivery (HTTP) | no — and correctly |
| rate limiting | ❌ delivery (HTTP) | no |
| JWT auth | ❌ delivery (HTTP) | no — the SSH key replaces it |
The ❌ rows aren’t losses. Idempotency keys guard against a network client retrying a request whose HTTP response was lost — a TUI keypress isn’t that, so bolting it on would be wrong. Rate limiting is public-endpoint abuse protection. JWT is HTTP’s login; the TUI’s login is the key. Each concern lives with its adapter; the domain stays clean.
The one subtlety: who owns the transaction
Section titled “The one subtlety: who owns the transaction”Creating a post writes the post and an outbox event, atomically. But the HTTP handler also wants to write an idempotency-key row in that same transaction. If service.CreatePost opened and committed its own transaction, the handler couldn’t slip its row in.
So the service takes a sqlc.Querier and does not own the transaction — the caller does:
// runs on whatever the caller passes: the pool, OR a transactionfunc CreatePost(ctx context.Context, q sqlc.Querier, authorID int64, content string) (sqlc.Post, error)The HTTP handler does pool.Begin → CreatePost(tx, …) → add idempotency row → commit. The TUI does pool.Begin → CreatePost(tx, …) → commit. Same domain code, each caller wrapping its own concerns. That “caller owns the boundary” choice is what makes the reuse actually work.
The SSH server: your key is the login
Section titled “The SSH server: your key is the login”With the logic shared, the front door is tiny. wish accepts every key, and we identify (not gate) the user by it:
wish.WithPublicKeyAuth(func(_ ssh.Context, _ ssh.PublicKey) bool { return true }),// ...then, per connection:authKey := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(s.PublicKey())))if u, err := queries.GetUserBySSHKey(ctx, authKey); err == nil { userID, username = u.ID, u.Username // recognized → you can act} // unknown key → read-only guestA small ssh_keys table maps a public key to a user. No password, no token — register your public key once, and from then on ssh is your authenticated session. The interview line writes itself: “same backend, two front doors — REST and SSH — and the SSH key replaces the JWT.”
The TUI: an Elm loop with modes
Section titled “The TUI: an Elm loop with modes”Bubble Tea uses the Elm Architecture: a Model (state), Update(msg, model) → model (events in, new state out), and View(model) → string (render). The runtime loops: draw → wait for a key → update → draw.
To support typing and a comment thread, the model became a small state machine with a mode — list / input / comments — and Update routes each keypress by mode. The actions are one-liners onto the service:
case "l": m = m.toggleLike(1) // → service.Likecase "d": m = m.deleteSelected() // → service.DeletePost (ownership enforced)case "f": m = m.followAuthor() // → service.FollowUsercase "n": // open a text box → service.CreatePost inside a transactionAnd the bit my friend was smuggest about — replying to a comment. In the thread view, r grabs the selected comment’s id and passes it as the new comment’s parent:
parentID := m.comments[m.commentCursor].IDservice.AddComment(ctx, q, postID, userID, &parentID, content) // &parentID ⇒ a replyReload the thread and the recursive CTE returns it one depth deeper — so it renders indented under its parent: a real nested thread, in a terminal you SSH’d into.
Where it actually runs
Section titled “Where it actually runs”The part I enjoy saying out loud: none of this is on a cloud VM. It runs on a 2008 Core-2-Duo desktop — my homelab’s “Old PC” — as a k3s cluster, installed from a Helm chart.
One Go image, built once, runs as four roles, each chosen at container start by which binary its Deployment launches:
| role | job |
|---|---|
api | the HTTP/JSON server (2 replicas) |
ssh | the wish + Bubble Tea front door from this post |
worker | consumes post.created, fans out, embeds via Ollama |
relay | drains the transactional outbox into RabbitMQ |
They sit in front of the same Postgres (pgvector), RabbitMQ, and Redis. kubectl get pods lists the whole thing — eight pods — humming on a machine old enough to vote. Behind every write runs the async chain post → outbox → relay → RabbitMQ → worker → Ollama → pgvector — and even the embeddings run on that same box: Ollama on the Core-2-Duo’s CPU (no AVX; ~14 s per embed — glacial, but it’s async, so nothing waits on it). No second computer is in the loop; the whole system, LLM included, is self-contained on one 2008 machine. (I reach it from my laptop over Tailscale — but the Old PC does all the actual work.)
So the real headline isn’t “a feed you can SSH into.” It’s: one domain core, two front doors, fronting a genuinely distributed system — multiple services, async messaging, a vector store — deployed on hardware from 2008.
Try it yourself
Section titled “Try it yourself”It’s all open-source. Because it lives on a home machine over a private Tailscale network — no public IP, and I’m not about to open an SSH server to the whole internet from my living room — the honest way to play with it is to run your own. It comes up identically anywhere:
git clone https://github.com/omkar619-dev/news-feed-go && cd news-feed-godocker compose up -d # Postgres (pgvector) + RabbitMQ + Redis# load the schema, start Ollama, run the four binaries — full steps in the READMEssh -p 23234 localhost # register your key once, and the feed is yoursThe README has the copy-paste version.
The honest caveats
Section titled “The honest caveats”- The service calls run synchronously inside
Update, briefly blocking the UI. For single fast queries it’s invisible; the idiomatic Bubble Tea fix is to return atea.Cmdthat does the work off-loop and sends a result message back. Known shortcut. - The SSH server is trusted to mint identity from keys — fine for a self-hosted, single-operator app; a multi-tenant version would need a proper key-registration flow.
- The thread view shows
user #<id>, not usernames — the tree query returns author ids; joining names is a small follow-up. - I lost ten minutes to a great bug: the mouse wheel sends an escape sequence whose leading
Escbyte my code read as the “quit” key — so scrolling closed the SSH session. Capturing mouse events properly (and not quitting on a bareEsc) fixed it.
The lesson
Section titled “The lesson”The novelty — a feed you SSH into — was a weekend’s fun. The thing worth keeping is the refactor it forced: if adding a second interface means copy-pasting your business logic, your business logic is in the wrong place. Move it to a domain core, make the interfaces thin translators, and a second front door costs almost nothing — and it sharpens your design, because you have to decide, feature by feature, what’s domain and what’s merely delivery.
It’s all in the repo — internal/service is the core, cmd/ssh is the terminal adapter, and the HTTP handlers are now thin translators over the same functions.