← Back to Blog

Supabase or PocketBase: A Self-Hosted Firebase Alternative on Your Own Server

Published · by RS Computers

Supabase PocketBase Self-hosting

If you want a self-hosted Firebase alternative, choose PocketBase when one small app needs a database, logins, file uploads and live updates with almost no upkeep. Choose Supabase when you need real PostgreSQL, row-level security, SQL joins and room to grow. Both run fine on your own server. The difference is weight: in our lab, PocketBase was one 32 MB program using about 30 MB of RAM, while self-hosted Supabase was 11 Docker containers using about 1.2 GB of RAM and 8.4 GB of disk before we stored a single row. Below you will find the decision table first, then two separate tracks, one per tool, with every command we ran and the output we saw.

Key facts, checked on 9 October 2026:

Decide first: the comparison table

A "backend as a service" gives your web or mobile app the parts every app needs: a database reachable over HTTP, user sign-up and login, file storage and a way to push changes to open browsers. Firebase made the idea popular. PocketBase and Supabase give you the same building blocks on a server you control.

PocketBaseSupabase (self-hosted)
DatabaseSQLite, built inPostgreSQL 17
How it runsOne binary, started by systemd11 Docker containers
RAM at idle (our lab)About 30 MBAbout 1.2 GB
Disk after install (our lab)32 MB program8.4 GB of images
Access rulesAPI rules per collection, set in the dashboard or APIRow-level security policies written in SQL
Live updatesServer-sent events, works with plain curlWebSockets through the Realtime service
Server codeJavaScript hooks, or use it as a Go libraryEdge Functions (Deno)
ScalingOne server only: buy a bigger oneBigger server, connection pooler, standard Postgres replicas
Smallest RS Computers planVPS Nano (1 GB RAM)VPS Mini (4 GB RAM), VDS Small for production
Pick it whenA side project, an internal tool, a mobile app MVP, one developerA team, SQL reporting, many tables with relations, vectors or Postgres extensions

PocketBase's FAQ is honest about its limits: it scales "only on a single server", and it is "neither a startup, nor a business" (PocketBase FAQ). That is fine for most small apps, since SQLite in WAL mode handles a lot of reads on one machine. If you expect several servers, many writers at once, or analysts who want to connect with SQL tools, start with Supabase.

What our lab measured

We installed both on 9 October 2026 on our Amsterdam test server, each in its own Debian 13 container limited to 2 vCPUs and 3 GB of RAM. Supabase therefore ran below its documented 4 GB minimum. It started and passed every test, but leave it the headroom in production.

MeasurementPocketBase 0.40.5Supabase v0.8.2
DownloadOne zip with a 32 MB binary1 minute 32 seconds (docker compose pull)
Start until readyHealth check answered right after systemctl enable --now48 seconds until all 11 containers were healthy
RAM, idle30 to 34 MB1,160 MB after start, 1,262 MB after 15 minutes
Largest memory usersOnly one processRealtime 236 MB, Studio 216 MB, pooler 191 MB, Storage 131 MB, Postgres 113 MB
Disk, whole system558 MB including Debian11 GB including Debian and a 1.4 GB Git checkout you can delete
1,000 inserts from a shell loop11.0 seconds13.3 seconds

The insert loop mostly measures curl starting 1,000 times, so read it as "both are fast enough for a small app", not as a benchmark. The memory and disk numbers are the ones that decide which server size you need.

Track A: PocketBase with systemd

PocketBase keeps everything in a folder called pb_data: the SQLite database, uploaded files and settings. systemd, the service manager built into Debian and Ubuntu, starts it at boot and restarts it if it crashes.

A1. Download and check the binary

# as root
apt update
apt install -y curl unzip sqlite3 jq
useradd -r -m -d /opt/pocketbase -s /usr/sbin/nologin pocketbase
cd /tmp
curl -fsSLO https://github.com/pocketbase/pocketbase/releases/download/v0.40.5/pocketbase_0.40.5_linux_amd64.zip
curl -fsSLO https://github.com/pocketbase/pocketbase/releases/download/v0.40.5/checksums.txt
grep linux_amd64.zip checksums.txt | sha256sum -c -
unzip -o pocketbase_0.40.5_linux_amd64.zip pocketbase -d /opt/pocketbase
chown -R pocketbase:pocketbase /opt/pocketbase
/opt/pocketbase/pocketbase --version

The checksum line must print pocketbase_0.40.5_linux_amd64.zip: OK, and the last line prints pocketbase version 0.40.5. The dedicated pocketbase user cannot log in, so a bug in your app cannot touch the rest of the server.

A2. Run it as a service

# as root
cat > /etc/systemd/system/pocketbase.service <<'EOF'
[Unit]
Description=PocketBase
After=network-online.target
Wants=network-online.target

[Service]
User=pocketbase
Group=pocketbase
WorkingDirectory=/opt/pocketbase
ExecStart=/opt/pocketbase/pocketbase serve --http=127.0.0.1:8090
Environment=GOMEMLIMIT=512MiB
LimitNOFILE=4096
Restart=always
RestartSec=5s

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now pocketbase
curl -s http://127.0.0.1:8090/api/health

You should see {"message":"API is healthy.","code":200,"data":{}}. PocketBase listens on 127.0.0.1 only, so nothing is exposed until you put a reverse proxy with HTTPS in front of it, as shown in our Caddy, Nginx and Traefik guide. GOMEMLIMIT and the higher open-file limit follow the advice in the official going to production page; the file limit matters once many browsers hold live connections.

A3. Create the admin account

# as root
cd /opt/pocketbase
runuser -u pocketbase -- ./pocketbase superuser create admin@example.com 'your-long-admin-password' --dir /opt/pocketbase/pb_data

Expected: Successfully created new superuser "admin@example.com"!. The dashboard lives at /_/. Until your domain is set up, reach it through an SSH tunnel: run ssh -L 8090:127.0.0.1:8090 root@YOUR_SERVER_IP on your own computer and open http://localhost:8090/_/.

A4. A table, a user and a REST call

PocketBase calls tables "collections". This creates a tasks collection where every task belongs to a user, and users can only see and create their own:

# as any user on the server
PB=http://127.0.0.1:8090
ADMIN=$(curl -s -X POST $PB/api/collections/_superusers/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","password":"your-long-admin-password"}' | jq -r .token)

curl -s -X POST $PB/api/collections -H "Authorization: $ADMIN" \
  -H 'Content-Type: application/json' -d '{
  "name": "tasks",
  "type": "base",
  "fields": [
    {"name": "title", "type": "text", "required": true},
    {"name": "owner", "type": "relation", "collectionId": "_pb_users_auth_", "maxSelect": 1, "required": true},
    {"name": "created", "type": "autodate", "onCreate": true}
  ],
  "listRule": "owner = @request.auth.id",
  "viewRule": "owner = @request.auth.id",
  "createRule": "@request.auth.id != \"\" && @request.body.owner = @request.auth.id"
}' | jq -c '{name, listRule}'

Output: {"name":"tasks","listRule":"owner = @request.auth.id"}. Now sign up a user, log in and write a task, exactly as your app would:

# as any user on the server, same shell
curl -s -X POST $PB/api/collections/users/records \
  -H 'Content-Type: application/json' \
  -d '{"email":"ana@example.com","password":"Ana-test-pass-123","passwordConfirm":"Ana-test-pass-123"}' | jq -c '{id, verified}'

LOGIN=$(curl -s -X POST $PB/api/collections/users/auth-with-password \
  -H 'Content-Type: application/json' \
  -d '{"identity":"ana@example.com","password":"Ana-test-pass-123"}')
TOKEN=$(echo "$LOGIN" | jq -r .token)
USERID=$(echo "$LOGIN" | jq -r .record.id)

curl -s -X POST $PB/api/collections/tasks/records -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"title\":\"First task\",\"owner\":\"$USERID\"}" | jq -c '{id, title, created}'

curl -s $PB/api/collections/tasks/records -H "Authorization: $TOKEN" | jq -c '{totalItems, titles: [.items[].title]}'
curl -s $PB/api/collections/tasks/records | jq -c '{totalItems}'

We got {"totalItems":1,"titles":["First task"]} as Ana and {"totalItems":0} without a token. When we tried to create a task with someone else's ID as the owner, PocketBase answered HTTP 400. The rules work.

A5. Live updates from curl

PocketBase pushes changes with server-sent events, a plain HTTP stream, so curl can watch it. Open a second SSH window and start listening:

# as any user on the server, second window
curl -sN http://127.0.0.1:8090/api/realtime

It prints event:PB_CONNECT and a clientId. Back in the first window, subscribe that client to the collection and add a task:

# as any user on the server, first window
CID=paste-the-clientId-here
curl -s -o /dev/null -w '%{http_code}\n' -X POST $PB/api/realtime -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"clientId\":\"$CID\",\"subscriptions\":[\"tasks\"]}"
curl -s -X POST $PB/api/collections/tasks/records -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' -d "{\"title\":\"Second task\",\"owner\":\"$USERID\"}" > /dev/null

The subscribe call returns 204, and the listening window prints event:tasks with "title":"Second task" and "action":"create". The JavaScript SDK does the same thing for you in the browser.

One thing surprised us. Every collection you create from the dashboard or API is also written as a migration file into /opt/pocketbase/pb_migrations. When we deleted pb_data to start over, PocketBase rebuilt our old collections from those files on the next start. That is useful, because the folder holds your schema, but it means backups must include pb_migrations and pb_hooks as well as pb_data.

Track B: Supabase with Docker Compose

Docker runs each part of Supabase in its own container: Postgres, Auth (sign-up and login), PostgREST (the REST API), Realtime, Storage, the Studio dashboard, an Envoy gateway in front of them and a few helpers. Our Docker on a VPS guide explains containers if they are new to you.

B1. Install Docker and create a user

# as root
apt update
apt install -y ca-certificates curl git jq postgresql-client
curl -fsSL https://get.docker.com | sh
useradd -m -s /bin/bash -G docker supa
su - supa

We got Docker 29.9.0 and Compose v5.6.0. The postgresql-client package is only for checking backups later.

B2. Get the files and replace every default secret

# as the supa user
git clone --depth 1 --branch self-hosted/v0.8.2 https://github.com/supabase/supabase
mkdir supabase-project
cp -rf supabase/docker/. supabase-project
cd supabase-project && cp .env.example .env
sh utils/generate-keys.sh --update-env
sh utils/add-new-auth-keys.sh --update-env
grep -cE "your-super-secret|this_password_is_insecure" .env

This step matters more than any other. The example .env ships with a public JWT secret, public API keys and the dashboard password this_password_is_insecure_and_should_be_updated. Anyone who knows the default JWT secret can sign their own admin token. The first script writes a new JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, database and dashboard passwords and encryption keys. The second adds the newer sb_publishable_ and sb_secret_ API keys and asymmetric signing keys. The final grep must print 0. sh run.sh secrets shows the values when you need them.

Then point the URLs at your future domain. In the lab we used the container's own address instead of a domain. We also turned on auto-confirm because our lab had no mail server; switch it back off once SMTP is set:

# as the supa user, in ~/supabase-project
sed -i -e "s#^SUPABASE_PUBLIC_URL=.*#SUPABASE_PUBLIC_URL=https://api.example.com#" \
  -e "s#^API_EXTERNAL_URL=.*#API_EXTERNAL_URL=https://api.example.com/auth/v1#" \
  -e "s#^ENABLE_EMAIL_AUTOCONFIRM=.*#ENABLE_EMAIL_AUTOCONFIRM=true#" .env

B3. Keep the ports private, then start

Out of the box, Docker published the gateway on port 8000 and the Postgres pooler on 5432 and 6543 on every network interface. Docker writes its own firewall rules, so a ufw rule will not hide those ports. Bind them to 127.0.0.1 before the first start:

# as the supa user, in ~/supabase-project
sed -i \
  -e 's|- ${POSTGRES_PORT}:5432|- 127.0.0.1:${POSTGRES_PORT}:5432|' \
  -e 's|- ${POOLER_PROXY_PORT_TRANSACTION}:6543|- 127.0.0.1:${POOLER_PROXY_PORT_TRANSACTION}:6543|' \
  -e 's|- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp|- 127.0.0.1:${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp|' \
  docker-compose.yml
docker compose pull -q
sh run.sh start
docker ps --format '{{.Names}} {{.Ports}}' | grep -E 'pooler|envoy'

After about 50 seconds every container reports Healthy, and the last line shows 127.0.0.1:8000, 127.0.0.1:5432 and 127.0.0.1:6543. From outside the container, those ports no longer answered in our test. Your reverse proxy then sends your domain to 127.0.0.1:8000. Studio sits behind a browser password prompt at the same address: in our lab it returned 401 without a login and 200 with user supabase and the password from DASHBOARD_PASSWORD. Note that update.sh merges new upstream files into yours, so check these three lines after each update.

B4. A table with row-level security, a user and a REST call

Row-level security (RLS) is a Postgres feature that filters rows per user inside the database itself. Supabase builds its whole access model on it:

# as the supa user, in ~/supabase-project
docker compose exec -T db psql -U postgres <<'SQL'
create table public.tasks (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  title text not null,
  created_at timestamptz not null default now()
);
alter table public.tasks enable row level security;
create policy "read own tasks" on public.tasks
  for select to authenticated using (user_id = auth.uid());
create policy "add own tasks" on public.tasks
  for insert to authenticated with check (user_id = auth.uid());
alter publication supabase_realtime add table public.tasks;
SQL

Expected: CREATE TABLE, ALTER TABLE, two lines of CREATE POLICY and ALTER PUBLICATION. The last statement lets Realtime broadcast changes from this table. Now the same user test as with PocketBase:

# as the supa user, in ~/supabase-project
API=http://localhost:8000
KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2)

curl -s -X POST $API/auth/v1/signup -H "apikey: $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"email":"ana@example.com","password":"Ana-test-pass-123"}' | jq -c '{id: .user.id, email: .user.email}'

TOKEN=$(curl -s -X POST "$API/auth/v1/token?grant_type=password" -H "apikey: $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"email":"ana@example.com","password":"Ana-test-pass-123"}' | jq -r .access_token)

curl -s -X POST $API/rest/v1/tasks -H "apikey: $KEY" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -H 'Prefer: return=representation' \
  -d '{"title":"First task"}'
curl -s "$API/rest/v1/tasks?select=id,title" -H "apikey: $KEY" -H "Authorization: Bearer $TOKEN"
curl -s "$API/rest/v1/tasks?select=id,title" -H "apikey: $KEY"

The insert returned the full row with Ana's user_id filled in by auth.uid(). Ana's read returned [{"id":1,"title":"First task"}], and the read without her token returned [].

Supabase Realtime speaks WebSockets, so plain curl is the wrong tool for it. We tested it with @supabase/supabase-js 2.117.3 in a Node 22 container, subscribing to inserts on tasks. It worked, with one catch: an insert sent at the very moment the channel reported SUBSCRIBED was missed, while one sent 3 seconds later arrived as new task: Third task. Do not rely on the first second of a subscription in tests.

Backups that you have actually restored

Supabase: pg_dump, plus a cold copy

# as the supa user, in ~/supabase-project
mkdir -p ~/backups
docker compose exec -T db pg_dump -U postgres -d postgres -Fc > ~/backups/supabase-$(date +%F).dump
pg_restore -l ~/backups/supabase-$(date +%F).dump | grep -E "TABLE DATA (public tasks|auth users)|POLICY"

Our dump was 322 KB and listed the auth users data, the tasks data and both policies. Then we tested a restore and found a trap. We dropped the table and restored it with pg_restore -t tasks: the rows came back, but both policies were gone and RLS was switched off. A table without RLS in Supabase is readable by anyone who has your public key. Restore a single table from a filtered list instead, then re-add it to Realtime:

# as the supa user, in ~/supabase-project (use your own dump file name)
pg_restore -l ~/backups/supabase-2026-10-09.dump | grep -E " public (tasks|tasks_id_seq) " > ~/backups/tasks.list
docker compose cp ~/backups/tasks.list db:/tmp/tasks.list
docker compose cp ~/backups/supabase-2026-10-09.dump db:/tmp/restore.dump
docker compose exec -T db pg_restore -U postgres -d postgres -L /tmp/tasks.list /tmp/restore.dump
docker compose exec -T db psql -U postgres -c "alter publication supabase_realtime add table public.tasks"

This brought back all rows, both policies and RLS. For a full server copy, stop the stack for a moment and archive the data folder with your settings. In our lab it took 1.8 seconds and made an 8.4 MB file, and all containers came back healthy:

# as root, in /home/supa/supabase-project
su supa -c "sh run.sh stop"
tar -czf /root/supabase-volumes-$(date +%F).tar.gz volumes .env docker-compose.yml
su supa -c "sh run.sh start"

PocketBase: built-in backups or a SQLite copy

# as any user on the server, with $ADMIN from step A4
curl -s -o /dev/null -w '%{http_code}\n' -X POST $PB/api/backups -H "Authorization: $ADMIN" \
  -H 'Content-Type: application/json' -d '{"name":"before-upgrade.zip"}'
curl -s $PB/api/backups -H "Authorization: $ADMIN" | jq -c '.[] | {key, size}'

The first call returns 204 and the zip appears in pb_data/backups (226 KB for our test data). We then added 1,000 more tasks and sent a POST to /api/backups/before-upgrade.zip/restore. PocketBase restarted itself, and the count went back to the 2 tasks stored in the zip. For a copy that never stops the service, use SQLite's own online backup:

# as root
mkdir -p /root/pb-backups
sqlite3 /opt/pocketbase/pb_data/data.db ".backup '/root/pb-backups/data-$(date +%F).db'"
sqlite3 /root/pb-backups/data-$(date +%F).db "select title from tasks"

The dashboard can also run backups on a schedule and upload them to S3-compatible storage. Whatever you use, copy the files off the server. Our restic and MinIO guide shows how to send them to a second machine every night.

What it costs compared with Firebase and Supabase Cloud

OptionHow you payIncluded, then billed
Firebase BlazePay as you goFirestore: 1 GiB stored, 50,000 reads and 20,000 writes a day free. Auth: 50,000 monthly active users free, then Identity Platform rates. Functions: 2 million calls a month free, then $0.40 per million
Supabase Free$0500 MB database, 50,000 monthly active users, 5 GB egress, 2 projects; paused after one week of inactivity
Supabase Pro$25 a month, compute billed hourly100,000 users, 8 GB disk, 250 GB egress; then $0.00325 per user, $0.125 per GB of disk and $0.09 per GB of egress. 2 GB of RAM costs $15 a month, 4 GB costs $60
Your own serverOne flat monthly priceNo per-user or per-request fees; you do the updates and backups

Prices come from Firebase pricing and Supabase pricing, both checked on 9 October 2026. Two worked examples: Supabase Pro with the 4 GB compute size comes to $75 a month ($25 plus $60, minus the $10 credit) before usage, and 1 TB of monthly egress adds 750 GB over the allowance, which is $67.50 at $0.09 per GB. On Firebase, an app whose 2,000 daily users each open 30 documents makes 60,000 reads a day, already past the free 50,000. Self-hosting turns these into a fixed server price, which helps most when traffic is high or hard to predict.

Which server size to pick

PocketBase runs comfortably on a VPS Nano with 1 vCPU, 1 GB of RAM and 20 GB of NVMe; our idle process used 3% of that memory. Supabase needs more. A VPS Mini (4 vCPUs, 4 GB, 80 GB NVMe) meets its documented minimum, and a VDS Small (4 vCPUs, 8 GB, 240 GB NVMe, 10 Gb/s port) matches the recommended 8 GB for production. Our install took 11 GB of disk before any data, so the 20 GB Nano is too tight for it.

All RS Computers VPS and VDS plans run on KVM with shared vCPUs, NVMe storage, unmetered traffic and their own IPv4 and IPv6 addresses, in Amsterdam, Dublin and Prishtina. Availability by city is on the plans page. You can move to a bigger plan from the client area later with a short reboot, so starting small with PocketBase and moving to Supabase on a larger plan when the app grows is a reasonable path. If you would rather run Postgres on its own, see our database server guide.

Frequently asked questions

Is PocketBase a good Firebase alternative?

Yes, for small and medium apps that fit on one server. PocketBase gives you a database, email and OAuth logins, file storage, live updates and an admin dashboard in one binary of about 32 MB. It is still below version 1.0, so read the changelog before every upgrade.

How much RAM does self-hosted Supabase need?

Supabase documents a minimum of 4 GB of RAM and recommends 8 GB or more. In our lab, the 11 containers of version v0.8.2 used about 1.2 GB at idle, with Realtime, Studio and the connection pooler as the largest users. Real traffic, Postgres caches and Edge Functions need the rest.

Can I self-host Supabase for free?

The software is free under the Apache 2.0 licence, so you only pay for the server. Self-hosting gives you one project per installation, community support only, and no managed backups, point-in-time recovery or branching.

Can PocketBase handle many users?

PocketBase's FAQ says it can serve over 10,000 persistent realtime connections on a small VPS. It only scales vertically, on one server, so heavy write traffic from many users at once is where Postgres and Supabase pull ahead.

How do I back up self-hosted Supabase?

Run pg_dump inside the database container every night and copy the file off the server. Keep a cold archive of the volumes folder and .env as well. Test restores: in our lab, restoring one table with pg_restore -t brought back the rows but dropped its RLS policies.

Can I move from PocketBase to Supabase later?

Yes, but it is a migration, not a switch. You export the data from SQLite, recreate the tables and RLS policies in Postgres, move the user accounts and change your app from the PocketBase SDK to the Supabase SDK.

Put your backend on a server you control

Start with the decision table, follow one track, and keep the backup commands that you have tested. Pick a plan on the VPS and VDS plans page: a VPS Nano is enough for PocketBase, and a VPS Mini or VDS Small fits Supabase. If you would like us to install either one for you, message us on Telegram and we will quote the setup work.

← All articles

Chat on Telegram