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_datadirectory - 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 value | Meaning |
|---|---|
locked (null) | Only superusers can perform the action (the default for new collections) |
empty string "" | Anyone, including unauthenticated requests |
| a filter expression | Allowed 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. getFullListfetches every page in a loop. It is convenient for small collections and a slow, memory-hungry call once a collection has thousands of records. UsegetListwith 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.comto 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.