DocsDevelopers

Files, backups and proxies

File fields on disk or S3, protected files behind short tokens, backups made while the app writes, restores, and the real client address behind a proxy.

File fields#

orders: base({
    fields: {
        scan: file({ maxSize: 10 << 20, mimeTypes: ['application/pdf', 'image/png'] }),
        docs: file({ maxSelect: 5, protected: true }),
    },
}),
  • A file field holds stored names: string, or string[] when maxSelect > 1. The default size limit is 5 MB.
  • Names are PocketBase's: <snake base>_<10 random chars>.<ext>.
  • A write sends files in the data and the client makes it multipart (Client and data layer). docs+ appends, docs- removes by stored name, scan: '' clears.
  • Files are written before the commit and removed if it rolls back. Replaced and removed files are deleted after the commit.
  • The schema form shows the stored files as links, with a remove button and a picker.

Protected files#

A protected: true field is served only with a file token, and only to a caller the record's viewRule lets through.

const token = await client.fileToken()                         // POST /api/files/token, valid 180 s
const href = client.fileUrl(order, order.docs[0] ?? '', { token })

GET /api/files/:collection/:record/:filename serves the bytes under a sandboxing content security policy. ?download=1 asks for an attachment. A plain file needs no token.

Files on S3#

import { createApp, s3Storage } from 'locosdk/server'

const app = createApp({
    schema,
    storage: s3Storage({
        bucket: 'orders-files',
        region: 'fra1',
        endpoint: 'https://fra1.digitaloceanspaces.com',
        accessKey: process.env.S3_KEY ?? '',
        secret: process.env.S3_SECRET ?? '',
        forcePathStyle: false,
        prefix: 'orders',
    }),
})
  • A file lives at <collectionId>/<recordId>/<filename>, PocketBase's key, so a bucket PocketBase filled serves as is.
  • prefix puts every key under <prefix>/: one bucket for several apps.
  • forcePathStyle: false puts the bucket in the host (DigitalOcean Spaces, AWS). true puts it in the path (MinIO).
  • The file route streams the object through the app, protected files included. It uses Bun's own S3 client, no dependency.

Backups#

A backup is PocketBase's zip of the data folder: app.db, then the uploaded files on disk storage, the review media and the rest. It never holds the backups themselves or the S3 secret.

const app = createApp({
    schema,
    backups: { cron: '0 3 * * *', maxKeep: 7 },                  // on disk, in data/backups
})
  • The database is copied with VACUUM INTO while the write lock is held, and disk files are hard linked under the same lock. The zip is one consistent moment, even while the app writes.
  • On S3 file storage, the files stay in their bucket and only the database is zipped.
  • backups.storage is 'disk' or an S3 config. Give backups their own bucket, or a prefix.
  • The cron writes @auto_pb_backup_<app>_<stamp>.zip and keeps the newest maxKeep (0 keeps all).
  • One backup or restore runs at a time. A second one gets 400 Try again later.
  • Hooks: onBackupCreate can rename the zip and leave entries out, onBackupRestore can keep entries in place.

Restore#

A restore stages first, with the app still serving. It extracts the zip apart, checks every entry and opens the staged database. Only then the app stops, saves what it replaces as @pre_restore_<app>_<stamp>.zip, swaps the folder entry by entry, and restarts. A failure before the swap leaves the data as it was.

The process ends on purpose: the hooks and crons of server.ts belong to the old database. The host starts the app again on the restored data in about one second. A PocketBase zip restores too: its data.db becomes app.db.

Routes and cli#

what
GET /api/backupsthe list: key, size, modified. Superuser only
POST /api/backupsmake one now, optional name
POST /api/backups/uploadupload a zip, streamed, no size limit
GET /api/backups/:key?token=download, with a superuser's file token
DELETE /api/backups/:keydelete one
POST /api/backups/:key/restorerestore, then restart
golo backup create [name]the same from the app folder, the app running or not
golo backup list [--json]the store the app last started with
golo backup restore <key>refuses while the app runs: stop it first

The cli runs no app code, so no backup hook fires from it.

Behind a proxy#

Without help, an app behind a proxy sees every request come from the proxy. The auth rate limit then sees one client for everybody.

const app = createApp({ schema, trustedProxy: { headers: ['X-Forwarded-For'] } })

For each listed header the request carries, the rightmost valid IP is e.realIp (the leftmost with useLeftmostIP: true). The rate limit keys on realIp. e.remoteIp stays the socket's address. The setting is off by default, like PocketBase. The app template turns it on for the header our front door sets.

Not here yet#

This page as markdown