PocketBase vs Supabase 2026: Why I Self-Host Every Backend in My Empire
I run 15-plus production backends on one free box. Supabase was in my code once. I tore it out. Here is the cost math, the migration, and the v0.22 schema trap that will eat your weekend if you skip it.

PocketBase vs Supabase 2026: Why I Self-Host Every Backend in My Empire
I run more than fifteen small production services across a handful of sites, and every one of them talks to a PocketBase backend. Not because PocketBase wins a feature checklist against Supabase. It is the shape of the bill, the shape of the lock-in, and one decision I made early: a backend should be a file I own, not a tenancy I rent.
Supabase was in my code once. One of my products, a tax tool, shipped with the Supabase JS client wired through the app. I tore it out and replaced it with PocketBase. This is the comparison I wish someone had handed me before that first createClient line.
PocketBase optimizes for ownership and a tiny operational surface. Supabase optimizes for managed Postgres and a polished platform you never patch. Neither is automatically right. The answer depends on your data model, your monthly burn, and whether Postgres features are central to the product or just nice to have.
What they actually are
PocketBase is a single Go binary. It bundles SQLite, auth, file storage, realtime subscriptions, an admin UI, and auto-generated REST APIs. You drop it on a server, run it behind a reverse proxy, and your entire backend state lives in one data directory you can copy.
POCKETBASE_VERSION="0.23.6" # check the latest release tag first
wget "https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/pocketbase_${POCKETBASE_VERSION}_linux_arm64.zip"
unzip "pocketbase_${POCKETBASE_VERSION}_linux_arm64.zip"
./pocketbase serve --http=127.0.0.1:8090
Note the arm64 build. My whole stack sits on an Oracle Ampere box, so every binary has to be ARM64, and PocketBase ships one. The whole install is those three lines. No control plane to provision, no managed cluster, no per-seat dashboard.
Supabase is a Postgres platform. You get Auth, Storage, Realtime, Edge Functions, client SDKs, a dashboard, real SQL tooling, and managed infrastructure on the hosted product. For a frontend-heavy app, it can be the shortest path from a Next.js component to production data.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
);
const { data, error } = await supabase.auth.signUp({
email: "[email protected]",
password: "use-a-password-manager",
});
if (error) {
throw error;
}
The split is architectural. PocketBase asks you to trust a compact app server and SQLite. Supabase asks you to trust Postgres and a managed service layer that lives on someone else's servers.
The cost math that decided it for me
Here is the part the feature tables miss. Supabase pricing is per project. The moment you run three or four products, you are either paying for three or four paid projects or cramming everything into one shared instance and praying the free-tier limits hold during your busiest month.
I do not have that problem, because all of my backends run on one Oracle ARM Always Free box. Four cores, 24 GB of RAM, Mumbai region, zero rupees a month, and it has held at zero for months. That single machine has comfortable headroom for fifteen to twenty small services. Each product gets its own PocketBase instance on its own subdomain, isolated, with its own data directory. The marginal cost of the next backend is not another subscription line. It is a few hundred more megabytes of RAM on hardware I already pay nothing for.
Run the comparison honestly:
| Cost area | PocketBase on Oracle ARM | Supabase hosted |
|---|---|---|
| Starting cost | Rs. 0 on the Always Free box | Free tier may cover one early MVP |
| Adding a second product | Another instance on the same box, Rs. 0 | A second project, on its own plan ceiling |
| Database | SQLite file in your data dir | Postgres, with plan quotas and limits |
| Auth | Built in | Built in, counted against plan limits |
| Storage | Local disk, or stream to object storage | Managed, with storage and bandwidth caps |
| Ops time | You own backups and upgrades | The platform owns most of the maintenance |
The constraint that settled it was monthly burn. When you are bootstrapped, every rupee on infrastructure is a rupee not earned, and the failure mode I refuse is a free tier whose limits bite in the one month revenue spikes. A tax product does most of its traffic in filing season, exactly when a metered backend turns into a surprise bill or a throttle. A binary on my own server has no such cliff. The trade is real: my time is not free, and I own the backups and the upgrades. But across many small products, one box I maintain beats many subscriptions I babysit.
The Supabase to PocketBase migration, honestly
When I pulled Supabase out of that tax tool, the work was not glamorous. Postgres itself is portable. What was not portable was everything Supabase-shaped wrapped around it: the Auth client, the Storage paths, the Realtime channels, the row-level-security policies. All of that had to be rewritten against PocketBase collections and rules.
What made it survivable was simple data. User records and document rows, not a web of complex joins and Postgres extensions. SQLite handles that profile perfectly. If the product had genuinely needed pgvector for embeddings or PostGIS for geospatial work, I would not have migrated, because forcing PocketBase to be Postgres is worse than paying for Postgres. The honest rule I landed on: migrate off Supabase only when you were never really using Postgres as Postgres.
The v0.22 schema trap that will eat your weekend
This is the gotcha that cost me real time, and it is the reason a casual port of old PocketBase code breaks in ways that make no sense at first. The full breakdown lives in PocketBase v0.22 schema migration breaking changes; the short version follows. PocketBase v0.22 and later rewrote the collection and admin model and flattened field options. If you are porting code or following a tutorial written against an older version, four things break in sequence. Fix them all together or you get a different error at each step.
The admin auth endpoint moved. The old
POST /api/admins/auth-with-passwordnow returns 404. Admins and users were unified into a single_superuserssystem collection, so the path isPOST /api/collections/_superusers/auth-with-password. The request body is unchanged.The collection schema key was renamed. This one is nasty because it fails silently. A collection definition using
{"name": "X", "schema": [...]}returns a 200 but creates an empty collection. The key is nowfields, notschema.Field options were flattened. A select field used to nest its options:
{"type": "select", "options": {"maxSelect": 1, "values": [...]}}. Now it returns a 400 with a "cannot be blank" error. Everything moves to the top level:{"type": "select", "maxSelect": 1, "values": [...]}. The same flattening applies to relation, file, and text field constraints.The
createdandupdatedcolumns are no longer auto-indexable. Trying to index them directly throws "no such column: created". You now declare them as explicitautodatefields first, then the index works.
Here is the working v0.22+ collection shape, after all four fixes:
spec = {
"name": "my_collection",
"type": "base",
"fields": [
{"name": "title", "type": "text", "required": True, "max": 200},
{"name": "category", "type": "select", "required": True,
"maxSelect": 1, "values": ["a", "b", "c"]},
{"name": "metadata", "type": "json"},
{"name": "created", "type": "autodate", "onCreate": True, "onUpdate": False},
],
"indexes": [
"CREATE INDEX `idx_cat` ON `my_collection` (`category`)",
"CREATE INDEX `idx_created` ON `my_collection` (`created`)",
],
}
The detection symptom for the flattening bug is a validation error keyed by field index, like {"fields": {"1": {"values": {"code": "validation_required"}}}}. The number is the position of the offending field. Once you have seen all four of these once, you never lose a weekend to them again. The first time, you will.
Backups: the part most self-host guides skip
The honest objection to self-hosting is "what happens when the box dies". My answer is Litestream. It streams each PocketBase SQLite file continuously to Cloudflare R2 object storage, also free at this scale, and the full Litestream to R2 disaster-recovery setup is one wiring job you do once. A dead instance is a restore, not a loss. If the whole Oracle box vanished tomorrow, I rebuild on any other server and point Litestream at the R2 bucket. This is what makes self-hosting a serious choice and not a hobby: you should be able to lose the entire machine without flinching. A managed platform hands you this in exchange for the bill and the lock-in. Self-hosting gives it to you for the cost of wiring one streaming-replication tool once.
Where each one genuinely fits
Use PocketBase when the app is straightforward, the team is small, and you want direct control. Internal dashboards, appointment systems, small marketplaces, admin portals, lightweight SaaS, content workflows. The JS SDK covers ordinary CRUD cleanly:
import PocketBase from "pocketbase";
const pb = new PocketBase("https://api.example.com");
await pb.collection("tasks").create({
title: "Call first paying customer",
done: false,
});
const tasks = await pb.collection("tasks").getFullList({ sort: "-created" });
Use Supabase when the database is a core product asset, not just a store. Postgres gives you constraints, real joins, views, functions, extensions, and mature operational patterns. Row-level security is the headline: you push per-user access into the database itself instead of trusting every API endpoint to get it right.
alter table projects enable row level security;
create policy "members can read their projects"
on projects
for select
using (
exists (
select 1 from project_members
where project_members.project_id = projects.id
and project_members.user_id = auth.uid()
)
);
Supabase is also the easier call for products that need pgvector for AI features, PostGIS for maps, or heavy SQL reporting you do not want to export into a separate warehouse. If your roadmap genuinely starts there, do not fight it.
Isolate the backend either way
Whichever you pick, do not scatter SDK calls across every component. Put reads, writes, auth helpers, and file uploads behind one small module. Lock-in is real on both sides: PocketBase ties you to its collections, rules, and client calls; Supabase ties you to its Auth, Storage paths, Realtime, and Edge Functions. A single boundary turns a future backend swap from a full frontend rewrite into a contained, painful-but-bounded job. I learned that the expensive way when I pulled Supabase out of code that had its client wired everywhere.
Quick takeaways
- PocketBase is one Go binary with SQLite, auth, files, and realtime. Supabase is a managed Postgres platform. The split is ownership versus convenience.
- The real cost difference is per-project billing. One Oracle ARM free box carries fifteen to twenty PocketBase backends at zero marginal cost; Supabase charges per project.
- Migrating off Supabase only makes sense when you were never using Postgres as Postgres. Simple document and user data moves cleanly;
pgvectorand PostGIS workloads should stay. - PocketBase v0.22+ broke four things at once: the auth endpoint,
fieldsversusschema, flattened field options, andautodatecolumns. Fix them together or chase a new error at every step. - Stream your SQLite to object storage with Litestream so losing the server is a restore, not a disaster.
- Isolate every backend call behind one module, on either platform, so a future switch is contained.
Related
More Automation

Cloudflare API Token Gotchas: The PUT That Wiped Mine Twice
I broke production twice by updating a Cloudflare token's scopes through the public API, then learned the wrangler auth fix and a secret-scrub habit the hard way. This is exactly what bit me and how I handle tokens now.

Fix NVIDIA Cursor and Video Stutter on Linux: GPU Clock Thrash
Cursor jitter and dropped video frames on NVIDIA Linux get blamed on the compositor every time. On my GTX 1660 the real cause was the driver bouncing graphics and VRAM clocks under light load. Here is the fix that held.

Litestream to Cloudflare R2: Disaster Recovery for SQLite
SQLite on one free box is one disk failure away from gone. Here is the exact Litestream-to-R2 setup I run across every PocketBase backend in my stack, including the restore drill and the gotcha that bites first.