---
title: More Backend Than a Blog Needs
date: '2026-07-05'
description: 'How pablofr.com is actually built. Three services, a shared data-stack of Postgres, Meilisearch and Redis, one real work queue, and a lot of SSE. Why a personal site is over-engineered on purpose, and where that pays off.'
author: Pablo Fernandez
tags:
  - Architecture
  - Microservices
  - Hono
  - Next.js
  - Redis
  - Meilisearch
source: 'https://www.pablofr.com/blog/more-backend-than-a-blog-needs'
---
A blog needs a folder of HTML and a way to serve it. This one runs three services, a shared data-stack of three engines, a work queue, and more Server-Sent-Event streams than a personal site has any business having. I want to be upfront about that, because everything else follows from it: pablofr.com is deliberately more system than the reading experience needs, because the site is also the workshop where I build the things it shows off.

The nice surprise is that most of it earns its keep anyway. Full-text search over the writing, versioned dataset downloads that get converted on the fly, a private dashboard that updates live. None of those are things a static folder does well. So the question I'm answering here isn't "what's the least a blog needs" (nothing like this), it's "what shape does it take once you decide the site is a real app and you want to own the whole thing."

## The shape

Everything sits behind one reverse proxy. Your browser only ever talks to two things, the site and the API, and those two lean on a small shared data-stack. One background worker hangs off the side for the slow, streaming work.

```
                     you
                      │  https
                      ▼
                ┌───────────┐
                │  Traefik  │   TLS · one door
                └─────┬─────┘
              ┌───────┴────────┐
              ▼                ▼
        ┌───────────┐    ┌───────────┐
        │    web    │ ─▶ │  core-api │
        │  Next 16  │    │  Hono·Bun │
        └───────────┘    └─────┬─────┘
                               │
                 ┌─────────────┼─────────────┐
                 ▼             ▼             ▼
           ┌──────────┐  ┌──────────┐  ┌──────────┐
           │ Postgres │  │  Meili   │  │  Redis   │
           │ pgvector │  │  search  │  │ cache·bus│
           └──────────┘  └──────────┘  └────┬─────┘
                                            │ stream
                                            ▼
                                       ┌──────────┐
                                       │  worker  │
                                       └──────────┘
```

*One door in. Two services the browser can see, three engines it can't, and a worker off the side for the slow work.*

There are three runtime services, and they're genuinely separate processes, not one app wearing three hats. There's **web** ([Next.js](https://nextjs.org) on [Bun](https://bun.sh)), **core-api** (a [Hono](https://hono.dev) API on Bun), and **worker** (a small Bun loop). They share nothing but the data-stack and a monorepo, wired with Bun workspaces and driven by [Turborepo](https://turborepo.com), so a single `bun run dev` boots all three at once. In production each one is its own container.

## The web is mostly a reader

The front of the house is [Next.js 16](https://nextjs.org) on the App Router, almost entirely React Server Components. The public side is small on purpose (home, projects, downloads, and the blog), and the blog is the fun part, because the page you're reading isn't a database row poured into a template. It's an MDX file compiled on the server.

When you open an article, the route reads the `.mdx` off disk, pulls the frontmatter with [gray-matter](https://github.com/jonschlinkert/gray-matter), and renders the body with [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote) through a remark/rehype pipeline: GitHub-flavoured markdown, a [Shiki](https://shiki.style)-based code highlighter, and a handful of custom plugins. Every diagram, callout and code card in this very post is a React component the MDX is allowed to call.

Behind the login there's the other half of the web app, a dashboard I use to run the site: create short links, send newsletter broadcasts, read the download and short-link analytics, edit and reorder links, and publish versioned downloads. It's drag-and-drop and live-updating, and it's out of scope here on purpose. It exists so I never have to open a database console to run the place.

## The API is where the work is

`core-api` is a [Hono](https://hono.dev) app on Bun. Hono is the router and the middleware, Bun is the runtime and the server. The whole thing is one `fetch` handler exported to Bun, and two settings quietly give away what it does:

```typescript:index.ts {3}
const app = new Hono();
// idleTimeout for long-lived SSE; a big body cap for dataset uploads.
export default { port, fetch: app.fetch, idleTimeout: 120, maxRequestBodySize: 300 * 1024 * 1024 };
```

Inside, it's boringly layered, and the boredom is the whole point. Every request walks the same path, so there's only ever one place to look.

```
   request
      │
      ▼
   route  ──▶  controller  ──▶   core   ──▶  repository  ──▶  Postgres
   /v1/*       Hono + zod       the logic     Drizzle SQL
                   │
                   └──────────▶  Redis    cache · pub/sub · rate-limit
```

*Every request walks the same four steps. Redis sits to the side of the whole path, not inside it.*

A route mounts a controller. The controller is a small Hono sub-app that validates input with [zod](https://zod.dev) and calls into a `core/` module. The core module holds the actual logic and leans on a Postgres repository built with [Drizzle](https://orm.drizzle.team). There are about a dozen of these route groups: posts, search, downloads, newsletter, short-links, projects, media, auth, and the generative-art endpoint that renders the strips in my other posts. Data lives in [Postgres](https://www.postgresql.org) (the [pgvector](https://github.com/pgvector/pgvector) image, with UUIDv7 primary keys), reached through Drizzle for reads and writes.

## Search that's half switched off

Search is the clearest example of building the whole thing and then being honest about what's actually running.

The design is hybrid. A query fans out two ways at once: [Meilisearch](https://www.meilisearch.com) for fast keyword hits over post metadata, and [pgvector](https://github.com/pgvector/pgvector) for a semantic pass, cosine distance over embeddings stored right next to the rows in Postgres. The two result sets get merged with a recency boost, and the vector call is wrapped in a 350&nbsp;ms timeout so a slow semantic pass can never hold up the keyword answer.

```
                    query
                      │
          ┌───────────┴────────────┐
          ▼                        ▼
     Meilisearch                pgvector
     keyword hits              cosine rerank
     (metadata)               (embeddings · off today)
          │                        │
          └───────────┬────────────┘
                      ▼
              merge  +  recency boost
                      ▼
                   results
```

*Built for hybrid, running as keyword-only. The semantic half is wired and waiting on embeddings I've turned off to save money.*

Here's the honest bit. The embeddings come from [OpenAI's embedding models](https://platform.openai.com/docs/guides/embeddings) ([`text-embedding-3-small`](https://platform.openai.com/docs/models/text-embedding-3-small), 1536 dimensions), and that call sits behind a flag I keep off. The reason is money, plainly: I didn't want every post edit to hit a paid API, and I didn't want the site to depend on a billed third party just to save a row. The vector columns and their HNSW indexes exist in the schema, they're just empty. So today, in practice, search is Meilisearch keyword-only, and the semantic half is a switch I can flip the day I decide the embeddings are worth paying for. The machinery is real, the bill is opt-in.

## One real queue, and the worker on the end of it

Most of what looks like "events" in here is [Redis](https://redis.io) Pub/Sub, fire-and-forget notifications that fan out to live dashboards. There's exactly **one** durable work queue, and it exists for a specific job: turning an uploaded dataset into other formats without blocking the request that asked for it.

It's a Redis Stream. When you ask for a conversion, `core-api` doesn't do the work, it appends a job and returns straight away:

```typescript:conversion-queue.ts /xadd/
await redis.xadd("pablofrcom:downloads:conversions", "*", "payload", JSON.stringify(job));
```

The worker is a single Bun process blocking on that stream as part of a consumer group. It pulls one job at a time, streams the file through the conversion (JSON into Parquet, CSV or Excel), pushes the result to object storage, marks the format ready in Postgres, and acknowledges the job. Progress doesn't come back through the queue, it goes out over Pub/Sub, which `core-api` relays to your browser as [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events), so the download page fills in live.

```
   browser ──POST──▶ core-api
                        │  XADD   pablofrcom:downloads:conversions
                        ▼
                    ┌─────────┐
                    │  Redis  │   stream · consumer group "worker"
                    └────┬────┘
                         │  XREADGROUP  (blocking)
                         ▼
                      worker ──▶ json → parquet · csv · excel ──▶ storage
                         │
                         │  XACK, then PUBLISH  downloads:progress
                         ▼
                    core-api ──SSE──▶ browser   (live progress bar)
```

*core-api to worker is a durable stream. worker to you is Pub/Sub relayed as SSE. Two directions, two mechanisms.*

The split matters. The stream gives the job durability and at-least-once delivery, so if the worker dies mid-convert the job is still there when it comes back. Pub/Sub gives the progress bar immediacy without persistence, which is exactly the right trade for something nobody needs replayed.

## Redis is not the cache

It's tempting to file [Redis](https://redis.io) under "cache" and move on, but on this box it's closer to the nervous system. Here's what it's doing, in rough order of how much I'd miss it:

- **The message bus.** Every live view in the dashboard (new short-link clicks, download progress, session revocations) is a Redis Pub/Sub channel fanned out to browsers over SSE.
- **The work queue.** The one stream above.
- **The rate limiter.** A sliding-window counter (a small `MULTI` of `GET` / `INCR` / `EXPIRE`) keyed by route and a client fingerprint.
- **Sessions and idempotency.** Sessions verify Redis-first with a Postgres fallback, and write endpoints stash their response under an idempotency key so a retried request replays the original result instead of doing the work twice.
- **And, yes, a cache**, for short-link lookups, search results, and the "now playing" I pull from [Last.fm](https://www.last.fm).

None of that is exotic. The point is that a single, boring Redis instance covers five different jobs, and not reaching for a sixth engine to do any of them is a feature, not a compromise.

## How this very page got here

My favourite loop in the whole system is the one that published this post. There's no CMS step for writing, I just add an `.mdx` file, and a watcher in `core-api` does the rest.

```
   write   more-backend-than-a-blog-needs.mdx
             │
             ▼
        fs.watch   (750ms debounce)
             │
             ▼
      gray-matter  ──▶  Postgres   row · status PUBLISHED
             │             │
             │             └──▶  Meilisearch   title · description
             ▼
        live at  /blog/…            ← you are here
```

*Save a file, and a filesystem watcher fans it out to Postgres and Meilisearch. No dashboard, no deploy for content.*

The watcher debounces, parses the frontmatter, upserts a row into Postgres (as `PUBLISHED` on first sight), and pushes the searchable fields to Meilisearch. The article body never goes to the search index, only the metadata does, which is why search finds posts but the reading always comes from the MDX. In production the same sync runs on every boot, so the posts baked into the image and the database can't drift apart.

## Where it runs, briefly

All of it lives on a single VPS. [Traefik](https://traefik.io) terminates TLS and routes `pablofr.com` to web and `api.pablofr.com` to core-api, with certificates from [Let's Encrypt](https://letsencrypt.org). Deploys are unglamorous and I like them that way: no CI, just rsync the source to the box and `docker compose build && up` over SSH, with [Watchtower](https://containrrr.dev/watchtower/) redeploying a service when its image tag moves. The box itself (the shared engines, the backups, the tenant model) is [its own story](/blog/pfrctl-infrastructure-as-code).

## The honest limits

Saying the bet out loud means owning the costs of it too.

- **The contract is duplicated by hand.** `core-api` and the worker agree on the job's shape and the stream's name by both hard-coding them. There's no shared package keeping them in sync, just two files and some discipline.
- **Half the search is dark.** The semantic path is built and switched off. Until the embeddings are on, "hybrid search" is an accurate description of the code and a generous one of the behaviour.
- **One box means one blast radius.** The engines are shared, so a runaway query or a memory spike is everyone's problem at once. That's the right trade for a handful of low-traffic services and the wrong one the day any single part gets popular.
- **The deploy is a person, not a pipeline.** rsync-and-SSH is fine for one maintainer and honest about it, but it's not what I'd hand to a team.

None of these are accidents. They're just the shape of choosing to own the whole thing on one machine, as one person, and preferring a system I can hold in my head over one that's someone else's to run. A blog needs none of it. But the site was never only a blog, it's where the rest of the work lives, and that turns out to be exactly the amount of backend that's worth having.
