PocketBase: SQLite Backend with Auth, Realtime Subscriptions, File Uploads and Production Setup

Key takeaways

PocketBase is a single Go binary that bundles SQLite, a REST API, auth, file storage, realtime subscriptions and an admin dashboard. It removes a lot of setup for small and medium apps; the trade-offs are single-server scaling and a pre-1.0 API that still changes between releases.

What PocketBase is

PocketBase is an open-source backend distributed as a single executable written in Go. Running it gives you:

  • an embedded SQLite database (in WAL mode) stored in a local pb_data directory
  • a REST-style API generated from your collections
  • authentication (email/password, OAuth2 providers, one-time passwords depending on version)
  • file storage on local disk or any S3-compatible bucket
  • realtime subscriptions over Server-Sent Events
  • an admin dashboard at /_/ to design collections and browse data

The appeal is operational: no separate database server, no container orchestration, one process to run and one directory to back up. The trade-offs come from the same design. SQLite allows one writer at a time, the database lives on the local disk of one machine, and realtime subscriptions are held in memory by that process. That fits a large share of real applications (internal tools, side projects, SaaS products with modest write rates) and rules out others (write-heavy workloads, multi-region, horizontal scaling).

The project is pre-1.0. Releases have contained breaking changes; v0.23, for example, reworked the Go and JavaScript extension APIs and replaced the separate admin accounts with a _superusers collection. Pin a version, and read the changelog before upgrading.


Installation and first run

Download the archive for your platform from the GitHub releases page, unzip it and start the server:

./pocketbase serve

The API listens on http://127.0.0.1:8090 and the dashboard is at http://127.0.0.1:8090/_/. On first start PocketBase prints a link for creating the initial superuser; you can also create one from the command line:

./pocketbase superuser upsert [email protected] 'a-long-random-password'

Everything the server stores (the SQLite files, uploaded files when using local storage, logs) goes into pb_data next to the binary unless you pass --dir. That directory is your backend. Server-side JavaScript hooks go in pb_hooks/*.pb.js, and migrations in pb_migrations/. If you need more control, PocketBase can also be imported as a Go framework and compiled into your own binary with custom routes and hooks.


Collections and API rules

A collection is a table plus configuration. Base collections hold ordinary records, auth collections hold users with passwords and tokens, and view collections are read-only results of a SQL query. Each collection has five API rules: list/search, view, create, update and delete.

API rules are the security model, and their semantics are the first thing to get right:

Rule valueMeaning
locked (null)Only superusers can perform the action (the default for new collections)
empty string ""Anyone, including unauthenticated requests
a filter expressionAllowed when the expression matches the record and request

Typical rules for a posts collection:

List/View:  published = true || author = @request.auth.id
Create:     @request.auth.id != "" && @request.body.author = @request.auth.id
Update:     author = @request.auth.id
Delete:     author = @request.auth.id

The create rule checks that the submitted author matches the logged-in user; without that, any user can create posts on behalf of anyone else. (Older releases used @request.data instead of @request.body, which is one of the renames that makes old tutorials fail silently.)

The failure I would warn about first is unlocking a rule to "" while debugging a 403 and forgetting to lock it again. Because PocketBase generates the API for every collection, that turns the collection into a public endpoint: anyone can list and, depending on the rule, edit every record by calling /api/collections/<name>/records directly, whatever your frontend shows. When a request returns 403 or an unexpectedly empty list, fix the rule expression rather than removing it, and check the rules of every collection before launch.


JavaScript SDK

npm install pocketbase
// lib/pocketbase.ts
import PocketBase from 'pocketbase';

export const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL ?? 'http://127.0.0.1:8090');

In the browser, the SDK stores the auth token in localStorage through pb.authStore and attaches it to every request. For server-side rendering, create a new client per request (a shared module-level instance would share one user’s auth state with everyone) and load the auth state from a cookie.

CRUD

// Create
const post = await pb.collection('posts').create({
  title: 'My first post',
  content: 'Hello PocketBase!',
  author: pb.authStore.record?.id,
});

// Read one, a page, or everything
const one = await pb.collection('posts').getOne(post.id, { expand: 'author' });
const page = await pb.collection('posts').getList(1, 20, {
  filter: pb.filter('published = true && title ~ {:q}', { q: searchText }),
  sort: '-created',
});
const all = await pb.collection('posts').getFullList({ sort: '-created' });

// Update and delete
await pb.collection('posts').update(post.id, { title: 'Updated title' });
await pb.collection('posts').delete(post.id);

Two details worth adopting from the start:

  • Build filters with pb.filter(), which escapes placeholder values. Concatenating user input into the filter string (`title ~ "${searchText}"`) lets a quote character change the expression, which is the same class of bug as SQL injection. API rules still limit what can be returned, but a broken filter is at best a confusing error.
  • getFullList fetches every page in a loop. It is convenient for small collections and a slow, memory-hungry call once a collection has thousands of records. Use getList with pagination for anything user-facing.

expand: 'author' resolves relation fields in the same request, so you do not need a second round trip for each post’s author. The expanded data appears under record.expand.author, and it is subject to the related collection’s view rule.


Authentication

// Sign up
await pb.collection('users').create({
  email: '[email protected]',
  password: 'correct-horse-battery',
  passwordConfirm: 'correct-horse-battery',
  name: 'Jane',
});

// Sign in
const auth = await pb.collection('users').authWithPassword('[email protected]', 'correct-horse-battery');
console.log(pb.authStore.isValid, pb.authStore.record?.id);

// OAuth2 (the provider must be configured in the collection's settings)
await pb.collection('users').authWithOAuth2({ provider: 'google' });

// React to login/logout
pb.authStore.onChange((token, record) => {
  console.log('auth changed', record?.email);
});

// Sign out
pb.authStore.clear();

In recent SDK versions the current user is pb.authStore.record; the older pb.authStore.model still works but is deprecated. isValid only checks that a token exists and has not expired locally; it does not ask the server. If a user is deleted or their token is revoked, you find out on the next request, which returns 401, so handle that by clearing the store and redirecting to login.

authWithOAuth2({ provider }) opens a popup and completes the flow over a realtime connection, so popup blockers can break it when it is not called directly from a click handler. Email verification and password reset need SMTP settings configured in the dashboard; without them the emails are not sent.


Realtime subscriptions

// All changes in a collection the user is allowed to see
const unsubscribe = await pb.collection('posts').subscribe('*', (e) => {
  console.log(e.action, e.record.id); // 'create' | 'update' | 'delete'
});

// A single record
await pb.collection('posts').subscribe(postId, (e) => console.log('updated', e.record));

// Stop listening
await unsubscribe();

Subscriptions use Server-Sent Events. The server checks the collection’s list/view rules for each event, so users only receive changes to records they could have fetched. subscribe returns a function that removes that one listener; calling unsubscribe() on the collection without arguments removes all listeners on it, including ones registered by other components.

A React component that stays in sync

'use client';

import { useEffect, useState } from 'react';
import type { RecordModel } from 'pocketbase';
import { pb } from '@/lib/pocketbase';

export default function Posts() {
  const [posts, setPosts] = useState<RecordModel[]>([]);

  useEffect(() => {
    let unsubscribe: (() => Promise<void>) | undefined;
    let cancelled = false;

    pb.collection('posts')
      .getList(1, 50, { sort: '-created', requestKey: null })
      .then((res) => { if (!cancelled) setPosts(res.items); });

    pb.collection('posts')
      .subscribe('*', (e) => {
        setPosts((prev) => {
          if (e.action === 'delete') return prev.filter((p) => p.id !== e.record.id);
          if (e.action === 'update') return prev.map((p) => (p.id === e.record.id ? e.record : p));
          return [e.record, ...prev];
        });
      })
      .then((fn) => {
        if (cancelled) fn();
        else unsubscribe = fn;
      });

    return () => {
      cancelled = true;
      unsubscribe?.();
    };
  }, []);

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Applying the event to local state avoids refetching the whole list on every change. requestKey: null disables the SDK’s auto-cancellation for this call. Without it, React StrictMode in development mounts the effect twice, the second identical request cancels the first, and the console shows ClientResponseError 0: The request was aborted (most likely autocancelled; ...). It looks like a network failure and is one of the most common questions about the SDK. The cancelled flag handles the other StrictMode issue: subscribe is asynchronous, so cleanup can run before the unsubscribe function exists.


File uploads

const formData = new FormData();
formData.append('title', 'Post with image');
formData.append('image', fileInput.files![0]);

const record = await pb.collection('posts').create(formData);
const url = pb.files.getURL(record, record.image, { thumb: '300x200' });

A file field stores only the filename; PocketBase appends a random suffix to avoid collisions. pb.files.getURL (the older getUrl spelling is deprecated) builds the download URL, and thumb returns a resized image for image files when that size is listed in the field’s thumbnail settings. Set the maximum size and allowed MIME types on the field; the defaults are permissive. Files in a field marked protected require a short-lived file token (pb.files.getToken()), which stops a leaked URL from being usable forever.


Deploying to production

Docker

FROM alpine:3
ARG PB_VERSION=0.xx.x   # pin the exact release you tested
RUN apk add --no-cache ca-certificates unzip wget \
 && wget -q https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip -O /tmp/pb.zip \
 && unzip /tmp/pb.zip -d /pb \
 && rm /tmp/pb.zip
EXPOSE 8090
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data"]
docker run -d -p 8090:8090 -v pb_data:/pb_data my-pocketbase

The volume is the essential part. Without -v, pb_data lives inside the container, and the next docker run from a fresh image starts with an empty database. The same applies to platforms with ephemeral file systems: attach a persistent disk.

Other production concerns:

  • One instance only. Do not scale to multiple replicas; each would have its own database and in-memory subscriptions. Rolling deployments that briefly run two instances on one shared volume risk SQLite locking errors.
  • No network file systems. SQLite’s locking is unreliable over NFS and similar mounts; use a local or block-storage volume.
  • Backups. The dashboard can create scheduled backups of pb_data, optionally uploaded to S3. Copying the directory while the server is writing can capture an inconsistent database, so use the built-in backups or stop the server first. Restore a backup somewhere as a test before you rely on it.
  • TLS and proxies. Put PocketBase behind a reverse proxy (Caddy, nginx) for HTTPS, or use serve yourdomain.com to have it obtain certificates itself. Proxies must not buffer SSE responses, or realtime events arrive late or in bursts.
  • Upgrades. Test the new version on a copy of pb_data: migrations run on startup and changes are not always reversible.

I would rather hit SQLite’s write limit than any of the above, because the write limit shows up gradually as rising latency, while the data-loss mistakes happen all at once. The painful stories people report are nearly always the missing volume on a redeploy, or an untested backup that turned out to be empty.


When to choose something else

PocketBase is a good fit when one server is enough and you value simplicity: prototypes, internal tools, apps with mostly reads, projects run by one or two people. Choose a PostgreSQL-based stack such as Supabase, or a managed service like Firebase, when you need high write concurrency, replicas across regions, SQL features that SQLite lacks, a stable API with long-term compatibility guarantees, or a provider handling operations for you.