Skip to content
Mangofold· 6 min read

We moved Mangofold off Firebase because of a bill. It was the right call.

A billing dispute forced a full rewrite of the authorisation layer — Firestore to Postgres, security rules to row-level security. Six months on, it is the best architectural decision in the project's history.

Nobody chooses their database because of a dispute. Mangofold started on Firebase because it was fast, and it worked well for about a year. Then the bill arrived and we did the thing nobody wants to do mid-project: we rewrote the entire authorisation layer.

This is not a story about the dispute. It is a story about what we found when we had to look at our own security model closely enough to port it.

What actually changed

  • Firestore → Postgres with row-level security policies
  • Firebase security rules → SQL policies that the database enforces
  • Cloud Functions → 24 Deno Edge Functions, one per callable
  • Firebase Hosting → Cloudflare Pages

The headline is the third migration, but the important one is the second. In Firestore, authorisation is a set of rules files that ship with the client and are evaluated by the SDK. That model is fine right up until you want to be certain — because the rules are enforced by a library the caller controls.

Moving to Postgres meant writing the same decisions down as policies that the database enforces regardless of what the caller asks for. A member can only read messages in servers they belong to, because the SELECT policy says so — not because the client remembered to filter.

Blocking is a read policy

One of the more instructive details: when you block a member, Mangofold does not filter their messages in the client. It adds the block to the message read policy, so their messages simply stop being visible — even to someone running a modified app that skips the filter.

sql
-- The client never decides whether you can see this row.
create policy messages_read on messages for select
  using (
    exists (
      select 1 from memberships m
      where m.server_id = messages.server_id
        and m.user_id = auth.uid()
    )
    and not exists (
      select 1 from blocks b
      where b.blocker_id = auth.uid()
        and b.blocked_id = messages.author_id
    )
  );

The other benefit nobody planned

Postgres gave us something Firestore could not: the ability to express things that are relational and actually matter. Every Thought in Mangofold is bound to a server, and we enforce that with composite foreign keys so a cross-server like or repost is rejected by the schema itself rather than by application logic that someone could forget to call.

It also meant entitlements became a table rather than a concept. Free, Plus and Developer tiers are rows in a database the client can read but only the service role can write — so a modified client cannot promote itself.

If a permission decision can be influenced by a value the caller supplied, it is in the wrong place.

What it cost

Honestly: a lot. Twenty-six migrations, edge functions that all needed re-deriving, and a push pipeline that had to be rebuilt on pg_cron plus pg_net because Cloud Functions had been doing the scheduling. We also lost some Firestore conveniences and did not get them all back.

But we now own the trust boundary. It is in a database we can query, audit and test, and every product we have built since has started from that assumption rather than discovering it later.

#architecture#postgres#row-level security#cloudflare

Grallumae Technologies

Kerala

Get in touch →

[Get in touch]

Have something that needs building?

We take on a small number of contract projects — product builds, privacy-critical backend work, and taking an idea from zero to shipped. Tell us what you're working on.

Taking on select projects for 2026