7 min read

Three Backends Was Two Too Many: Iron Boundaries in One Database


I run a few side projects on GCP: a gamified meal planner, a baby-name voting app where family and friends vote on names, and a billing service meant to be shared between them. Until this week that was three Cloud Run services, three Neon Postgres projects, three deploy pipelines, three sets of secrets, and a growing feeling that I was operating a distributed system for roughly twenty users.

This post covers the consolidation: everything onto one backend and one database, without giving up the isolation that made three services feel safe. The whole thing took two days, most of it delegated to coding agents while I reviewed. The interesting parts are the decisions, not the typing.

Why three backends existed at all

Each one had a reasonable origin story. The meal planner came first. The baby-names app was a quick standalone build. New idea, new repo, new service, new database. The billing service was deliberately separate: it was going to be shared infrastructure, a wallet that any future app could bill against over HTTP with a service token.

Then I made an architectural decision that quietly invalidated all of it. New agentic features in my main app became profiles hosted by one chassis, not new apps. The “many consumers” future that justified a standalone billing service now had exactly one consumer, forever. What remained was pure cost: a proxy hop, token rotation, two extra databases, and a local dev script that needed two Postgres containers with matching secrets before anything booted.

The kicker: when I audited the deployed config, my production app had never actually called the billing service. The env var that would have enabled it was never set. I was paying operational surface for a network boundary with zero traffic.

The fear that kept the services apart

Collapsing everything into one backend has an obvious objection, and I felt it strongly: I’m building LLM-agent features. I did not want agentic code anywhere near billing tables, and I did not want a name-generator agent able to read personal data. With separate services, the network was the boundary. Merge them and every module shares one database handle, and the only thing between a bug and the wallet is discipline.

My first instinct was architectural: does this mean I need hexagonal architecture? Ports and adapters, strict layers, the whole ceremony?

It doesn’t, and the reason took me a minute to articulate: hexagonal constrains dependency direction, not authority. You can build a perfectly hexagonal app where every adapter shares one god-credential connection and every agent gets every tool. The fear wasn’t about which module imports which. It was about what code can touch. That’s an access-control problem, and access control has a much older, much cheaper enforcement point.

Roles, not layers

Postgres has had the answer since before I was writing software. The consolidated design is:

  • Schemas are namespaces. Each domain that owns tables gets one: billing.*, babynames.*, the main app in public. Cheap, purely organizational, one per domain.
  • Roles are trust boundaries. These are deliberately few (three, not one per domain). An app role whose grants cover the game and baby-names. A billing role that can touch billing.* and nothing else. A personal role reserved for the sensitive data a future feature will hold.

Code connects with the role of the slice it lives in. The billing module gets a dbBilling handle; everything else gets dbApp. And here’s the part I like: when billing code has a bug (or an LLM does something surprising) and something tries to SELECT from the wrong schema, the database refuses:

ERROR:  permission denied for schema billing

No discipline required. No code review needed to catch it. The grant does not exist.

The enforcement stack ended up as five layers, each mechanical: agent tool allowlists (a capability absent from the turn can’t be called), runtime capability gates, an import-lint rule that keeps the billing DB handle inside the billing directory, the Postgres grants, and per-agent usage metering. Hexagonal would have addressed exactly one of those layers, more weakly than the lint rule I already had.

Things that bit me on the way

ALTER DEFAULT PRIVILEGES with FOR ROLE names your production migration user, so the rule silently never fires on a local test database with a different owner. Omit FOR ROLE and it binds to whoever runs the migration. Correct everywhere, discovered the annoying way.

On Neon, roles created through the console or CLI join neon_superuser, which quietly bypasses the entire point. Create trust-boundary roles in SQL, NOLOGIN, passwords granted out-of-band. Nothing secret belongs in a committed migration.

And env layering got me: my test env pinned the main database URL to the local container but not the two new role-scoped URLs, so the suite spent an afternoon writing test rows into my cloud dev branch and failing forty tests with permission errors. Every new connection-string variable needs a test-env pin the day it’s born.

What hasn’t bit me (yet)

The consolidation is a day old, so this section is speculation, but I know where I’m looking. Everything now shares one container’s fate: a bad deploy of the meal planner takes the baby-names app down with it, and one runaway query degrades every product at once. The blast radius I removed from the security story moved into the availability story. I think that’s the right trade at my scale, and I reserve the right to quote this paragraph ruefully later.

The boundaries themselves have a growth question too. Two roles are plenty today, but the roles are coarse: the game and the baby-names app currently share one, so they’re isolated from billing and from nothing else. There’s also a ladder above grants I haven’t climbed: row-level security if any schema ever holds multiple tenants who shouldn’t see each other, and per-role connection limits so one domain can’t starve the shared pool. And a few in-memory assumptions (an SSE pub/sub, a rate limiter) are honest only while this runs as a single instance. The day something needs to scale out, Postgres LISTEN/NOTIFY is waiting, and this section becomes its own post.

What it looks like now

One Cloud Run service. One Neon project, with a production branch plus a dev branch I can reset to a fresh clone of prod in one command. One deploy pipeline, one CI check, one local dev script that needs one database container. The domains all resolve through the same four DNS records to the same service, which routes by hostname. The baby-names app is a folder, a schema, and a build stage now, not infrastructure.

Nothing about the user experience changed. A voting room created before the fold still loads at its old shared link, 301’d to its new home with the path intact.

The full scoreboard, before and after: three backends became one. Three Postgres projects became one. Eight service accounts became two working identities. Fourteen secrets became six. Two identity federation pools became one. Four public domains now resolve to one service plus a CDN, two blog repos I kept losing track of became workspace folders with an auto-publish loop, and every doc in the repo finally describes the system that actually exists.

The thing I’d underline for anyone running solo projects: your constraint is operational surface, not compute cost. Scale-to-zero containers cost pennies whether you have one or five. What costs you is every additional thing that can break, expire, need rotation, or demand you remember how it works at 2am. Boundaries you want to keep deserve a cheaper enforcement point than a service boundary, and Postgres will sell you one for the price of a GRANT.