PocketBase как бэкенд: архитектура, паттерны и продакшн

Обновлено:

PocketBase подходит не только для прототипов: на нём работают мобильные приложения, небольшие B2B-сервисы и внутренние панели. В этой статье — не установка (она описана в материале что такое PocketBase), а то, как строить на нём бэкенд: схема коллекций, правила доступа, хуки, интеграция с Next.js и бэкапы.

Когда PocketBase — правильный выбор

PocketBase закрывает разрыв между «написать бэкенд с нуля» и «взять Firebase» (с которым у российских команд сложности с оплатой и доступом, плюс привязка к вендору). Типичные сценарии:

Мобильное приложение с синхронизацией. Flutter + официальный Dart SDK: авторизация, файлы, realtime из коробки.

Небольшой SaaS. Инструмент для узкой аудитории, который живёт на одном VPS. Предел определяется нагрузкой на запись, а не числом зарегистрированных пользователей, поэтому меряйте на своём сценарии.

Внутренние инструменты. Дашборды, CMS, служебные панели, где сложный RBAC не нужен.

Агентства. Отдельный экземпляр PocketBase на клиента — в своём контейнере или на своём VPS.

Проекты с AI-ассистентом. У PocketBase подробная документация и простой SDK, поэтому ассистенты генерируют для него рабочий код. Если хотите подключить агента к самому PocketBase, посмотрите обзор MCP-серверов для PocketBase.

Архитектура бэкенда на PocketBase

PocketBase даёт три уровня кастомизации:

┌─────────────────────────────────────────────────────────┐
│  Уровень 1: Collections + API Rules (без кода)          │
│  Схема данных, правила доступа, валидация полей         │
├─────────────────────────────────────────────────────────┤
│  Уровень 2: Хуки (JavaScript в pb_hooks/)               │
│  Бизнес-логика, письма, внешние API, cron               │
├─────────────────────────────────────────────────────────┤
│  Уровень 3: Go (PocketBase как библиотека)              │
│  Свои маршруты, тяжёлая логика, Go-пакеты               │
└─────────────────────────────────────────────────────────┘

Большинству проектов хватает первых двух уровней.

Проектирование коллекций

Схема коллекций — главное архитектурное решение: ошибки здесь дорого исправлять, когда данные уже есть.

Пример: SaaS с командами (multi-tenant)

Коллекции:
├── users (auth-коллекция, встроенная)
├── teams
│   ├── name: Text (required)
│   ├── slug: Text (unique)
│   ├── plan: Select [free, pro, enterprise]
│   └── owner: Relation → users
├── team_members (связующая коллекция)
│   ├── team: Relation → teams (required)
│   ├── user: Relation → users (required)
│   └── role: Select [admin, member, viewer]
├── projects
│   ├── name: Text (required)
│   ├── team: Relation → teams (required)
│   ├── settings: JSON
│   └── archived: Bool
└── tasks
    ├── title: Text (required)
    ├── description: Editor
    ├── project: Relation → projects (required)
    ├── assignee: Relation → users
    ├── status: Select [todo, in_progress, done]
    ├── due_date: DateTime
    └── attachments: File (до 5 файлов)

API Rules для multi-tenant

API Rules — основа безопасности PocketBase. Ошибка в правиле означает утечку данных между командами. У teams нет поля со списком участников, поэтому членство проверяем через обратную связь team_members_via_team или через @collection.team_members:

# teams: пользователь видит только свои команды
listRule:   team_members_via_team.user ?= @request.auth.id
viewRule:   team_members_via_team.user ?= @request.auth.id
createRule: @request.auth.id != ""
deleteRule: owner = @request.auth.id

# projects: видны всем участникам команды
listRule:   team.team_members_via_team.user ?= @request.auth.id
viewRule:   team.team_members_via_team.user ?= @request.auth.id

# projects: менять могут участники с ролью не ниже member
updateRule: @collection.team_members.team ?= team &&
            @collection.team_members.user ?= @request.auth.id &&
            @collection.team_members.role ?!= "viewer"

?= означает «хотя бы одна связанная запись удовлетворяет условию». Самое опасное место — проверки ролей: условия на user и role должны относиться к одной и той же записи членства, иначе viewer одной команды может получить права admin через другую запись. Прежде чем выкатывать правила, заведите тестовых пользователей с разными ролями в двух командах и проверьте каждую операцию запросами к API. Если правило получается слишком сложным, перенесите проверку роли в хук. Синтаксис и типовые ошибки — в статье API Rules в PocketBase.

JavaScript-хуки: бизнес-логика без Go

Команда при регистрации пользователя

// pb_hooks/on_user_create.pb.js

onRecordAfterCreateSuccess((e) => {
    e.next()

    const user = e.record
    try {
        const teams = $app.findCollectionByNameOrId('teams')
        const team = new Record(teams)
        const base = user.getString('name') || user.email().split('@')[0]
        team.set('name', `Команда ${base}`)
        team.set('slug', base.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' +
            $security.randomStringWithAlphabet(6, 'abcdefghijklmnopqrstuvwxyz0123456789'))
        team.set('plan', 'free')
        team.set('owner', user.id)
        $app.save(team)

        const membership = new Record($app.findCollectionByNameOrId('team_members'))
        membership.set('team', team.id)
        membership.set('user', user.id)
        membership.set('role', 'admin')
        $app.save(membership)
    } catch (err) {
        console.error('Не удалось создать команду для пользователя', user.id, err)
    }
}, 'users')

Webhook при смене статуса задачи

// pb_hooks/task_status_webhook.pb.js

onRecordUpdateRequest((e) => {
    const oldStatus = e.record.original().getString('status')

    e.next() // сначала сохраняем запись

    const newStatus = e.record.getString('status')
    const webhookUrl = $os.getenv('TASK_WEBHOOK_URL')
    if (!webhookUrl || oldStatus === newStatus) return

    $http.send({
        url: webhookUrl,
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            event: 'task.status_changed',
            taskId: e.record.id,
            projectId: e.record.getString('project'),
            oldStatus,
            newStatus,
            changedBy: e.auth ? e.auth.id : null,
        }),
        timeout: 10,
    })
}, 'tasks')

Приглашение в команду по email

// pb_hooks/team_invite.pb.js

routerAdd('POST', '/api/custom/teams/{teamId}/invite', (e) => {
    if (!e.auth) {
        throw new UnauthorizedError('Требуется авторизация')
    }

    const body = e.requestInfo().body
    const email = body.email
    const role = body.role || 'member'
    const teamId = e.request.pathValue('teamId')

    // Только admin команды; параметры передаём через плейсхолдеры, а не конкатенацией
    try {
        $app.findFirstRecordByFilter(
            'team_members',
            'team = {:team} && user = {:user} && role = "admin"',
            { team: teamId, user: e.auth.id }
        )
    } catch (_) {
        throw new ForbiddenError('Приглашать могут только администраторы команды')
    }

    const team = $app.findRecordById('teams', teamId)
    const token = $security.randomString(32)
    // Сохраните token в отдельной коллекции invites, чтобы потом проверить его

    const message = new MailerMessage({
        from: {
            address: $app.settings().meta.senderAddress,
            name: $app.settings().meta.senderName,
        },
        to: [{ address: email }],
        subject: `Приглашение в команду ${team.getString('name')}`,
        html: `<p><a href="https://myapp.ru/invite?token=${token}">Принять приглашение</a></p>`,
    })
    $app.newMailClient().send(message)

    return e.json(200, { message: 'Приглашение отправлено' })
}, $apis.requireAuth())

Интеграция PocketBase с Next.js

// lib/pocketbase.ts
import PocketBase from 'pocketbase'

// Клиентская сторона: один экземпляр на вкладку
let browserPb: PocketBase | null = null
export function getPocketBase(): PocketBase {
    if (!browserPb) browserPb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!)
    return browserPb
}

// Серверная сторона: новый экземпляр на каждый запрос,
// чтобы сессии разных пользователей не смешивались
export function getPocketBaseServer(): PocketBase {
    return new PocketBase(process.env.PB_URL!)
}
// app/projects/page.tsx — Server Component
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPocketBaseServer } from '@/lib/pocketbase'

export default async function ProjectsPage() {
    const pb = getPocketBaseServer()

    const cookieStore = await cookies()
    const pbAuth = cookieStore.get('pb_auth')?.value
    if (pbAuth) pb.authStore.loadFromCookie(`pb_auth=${pbAuth}`)

    if (!pb.authStore.isValid) redirect('/login')

    const projects = await pb.collection('projects').getList(1, 50, {
        filter: 'archived = false',
        sort: '-created',
        expand: 'team',
    })

    return (
        <div>
            {projects.items.map((project) => (
                <ProjectCard key={project.id} project={project} />
            ))}
        </div>
    )
}
// app/actions/projects.ts — Server Action
'use server'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { getPocketBaseServer } from '@/lib/pocketbase'

export async function createProject(formData: FormData) {
    const pb = getPocketBaseServer()
    const pbAuth = (await cookies()).get('pb_auth')?.value
    if (pbAuth) pb.authStore.loadFromCookie(`pb_auth=${pbAuth}`)
    if (!pb.authStore.isValid) throw new Error('Not authenticated')

    // API Rules на сервере PocketBase всё равно проверят права
    await pb.collection('projects').create({
        name: formData.get('name') as string,
        team: formData.get('teamId') as string,
    })

    revalidatePath('/projects')
}

Масштабирование PocketBase

Главное ограничение — SQLite допускает одного писателя в каждый момент. Чтение масштабируется хорошо, запись упирается в диск и длину транзакций. Практические выводы:

  • PocketBase масштабируется вертикально: быстрый NVMe-диск и больше CPU дают больше, чем любые ухищрения;
  • держите транзакции в хуках короткими, не делайте внешние HTTP-запросы внутри транзакции;
  • реальный предел измеряйте нагрузочным тестом на своих запросах — универсальных цифр «N пользователей на сервер» нет;
  • если нужен второй сервер на запись — это сигнал переходить на PostgreSQL.

Litestream: непрерывная репликация в S3

Litestream — это не горизонтальное масштабирование, а непрерывная выгрузка WAL в S3: он позволяет восстановить базу почти на любой момент времени.

# docker-compose.yml: PocketBase + Litestream
services:
  pocketbase:
    build: .
    volumes:
      - pb_data:/pb_data

  litestream:
    image: litestream/litestream
    volumes:
      - pb_data:/pb_data
      - ./litestream.yml:/etc/litestream.yml
    command: replicate
    environment:
      - LITESTREAM_ACCESS_KEY_ID=${S3_KEY}
      - LITESTREAM_SECRET_ACCESS_KEY=${S3_SECRET}

volumes:
  pb_data:
# litestream.yml
dbs:
  - path: /pb_data/data.db
    replicas:
      - type: s3
        bucket: my-pocketbase-backup
        path: pocketbase/data.db
        endpoint: https://storage.yandexcloud.net
        region: ru-central1
        force-path-style: true
        sync-interval: 1s

Litestream копирует только базу, не файлы из storage/. Чем он отличается от периодических версий и когда что выбирать — в сравнении Litestream vs cron-скрипт vs dbsend.

Бэкап и восстановление PocketBase

pb_data/ содержит не только базу, но и загруженные файлы (pb_data/storage/). Полный бэкап должен покрывать оба. Не архивируйте data.db через tar на работающем сервере: в WAL-режиме такая копия может оказаться неконсистентной.

# База: CLI dbsend сам делает консистентную копию SQLite перед загрузкой
dbsend backup /opt/pocketbase/pb_data/data.db \
  -d "$DBSEND_DATABASE_ID" -e sqlite -l "nightly-$(date +%F)"

# Файлы: отдельный архив как generic_file (хранится как есть, проверяется контрольная сумма)
tar -czf /tmp/pb_storage.tar.gz -C /opt/pocketbase/pb_data storage
dbsend backup /tmp/pb_storage.tar.gz -d "$DBSEND_FILES_ID" -e generic_file

Восстановление базы:

# Найти нужную версию
dbsend log "$DBSEND_DATABASE_ID"

# Остановить PocketBase и восстановить файл
systemctl stop pocketbase
dbsend restore "$SNAPSHOT_ID" /opt/pocketbase/pb_data/data.db --yes
chown www-data:www-data /opt/pocketbase/pb_data/data.db
systemctl start pocketbase

dbsend restore скачивает файл во временный, сверяет SHA-256, выполняет PRAGMA integrity_check, удаляет устаревшие -wal/-shm рядом с целевым файлом и атомарно подменяет базу. Полная схема с cron, встроенными бэкапами PocketBase и проверкой восстановления — в статье бэкап PocketBase.

Частые вопросы

Подходит ли PocketBase для мобильных приложений?

Да. Официальный Dart SDK для Flutter покрывает CRUD, авторизацию, файлы и realtime. Для React Native подходит JavaScript SDK. Realtime работает через SSE.

Сколько пользователей выдерживает PocketBase?

Зависит не от числа аккаунтов, а от характера нагрузки: доли записей, длины транзакций, размера файлов и диска. Приложения с преобладанием чтения на одном сервере держат заметную нагрузку, но конкретный предел измерьте нагрузочным тестом на своих сценариях. Если упираетесь в запись — сначала оптимизируйте хуки и индексы, затем увеличивайте сервер, а дальше думайте о PostgreSQL.

Как организовать права доступа в PocketBase для SaaS?

Связующая коллекция team_members с ролями плюс API Rules, которые проверяют членство через team_members_via_team или @collection.team_members. Обязательно протестируйте правила на пользователях с разными ролями — особенно комбинации проверок пользователя и роли.

Поддерживает ли PocketBase OAuth2?

Да: Google, GitHub, Apple, Discord, GitLab и другие провайдеры, а также универсальный OpenID Connect. Настраивается в панели в параметрах auth-коллекции.

Как перенести данные из Firebase в PocketBase?

Экспортируйте данные Firestore через Admin SDK в JSON, создайте коллекции в PocketBase и загрузите записи скриптом через API от имени superuser. Файлы из Firebase Storage скачайте и загрузите через PocketBase API. Хеши паролей Firebase напрямую перенести не получится — пользователям придётся сбросить пароль или войти через OAuth2.

Где хостить PocketBase в России?

На любом VPS у российского провайдера: Timeweb Cloud, Selectel, Yandex Cloud и других. Достаточно systemd-сервиса или Docker Compose. Сравнение провайдеров — в статье облачная инфраструктура: Cloud.ru, Yandex, Selectel.