Skip to content

Backups & restore

The PDS keeps its records in one SQLite database and its blobs (media files) on the volume next to it, so backing those two things up is your disaster-recovery plan. Custos uses Litestream to stream the database to object storage continuously and restore it on boot, and its own blob mirror to do the same for blob files.

When the Litestream environment variables are set, the container runs the PDS under Litestream: it streams the SQLite write-ahead log to your bucket as writes happen (a continuous replica, not a nightly snapshot), and on boot it restores from that replica if the local database is missing. So a current restore point always exists.

The replica is defined in litestream.yml (committed in the repo) with force-path-style: false: virtual-hosted-style addressing, which Railway/Tigris-style buckets require.

Set these on the environment you want backed up (production; staging and local leave them unset and run the PDS directly):

VariableRole
LITESTREAM_S3_BUCKETThe bucket the replica is written to. Setting this is what switches Litestream on.
LITESTREAM_S3_ENDPOINTObject-storage endpoint (e.g. your Tigris/S3-compatible host).
LITESTREAM_ACCESS_KEY_IDAccess key for the bucket.
LITESTREAM_SECRET_ACCESS_KEYSecret key for the bucket.

Litestream replicates only the SQLite database. Uploaded blobs (avatars, post images, video) live as files on the deployment volume, where losing the volume would destroy every account’s media. The blob mirror is the Litestream analogue for those files: a periodic sweep uploads every stored blob to an S3-compatible bucket, and on boot the server attempts to restore any file missing from the volume out of the bucket before it takes traffic. The restore is best-effort per blob: a blob whose bytes exist in neither place, or whose bucket copy fails content-hash verification, is logged loudly (per-CID error plus a boot summary count) and boot continues; that blob stays unavailable rather than blocking startup.

Turn it on by setting a bucket (unset means disabled):

VariableRole
EZPDS_BLOB_MIRROR_BUCKETBucket the mirror writes to. Setting this is what switches the mirror on.
EZPDS_BLOB_MIRROR_ENDPOINTS3-compatible endpoint URL.
EZPDS_BLOB_MIRROR_ACCESS_KEY_IDAccess key for the bucket.
EZPDS_BLOB_MIRROR_SECRET_ACCESS_KEYSecret key for the bucket.

The sweep also propagates deletions, on a lag: after blob garbage collection or account deletion removes a blob’s database row, a subsequent sweep deletes the bucket object, so the mirror tracks the live blobstore rather than growing forever (the worst case of the lag is the bucket briefly retaining collected blobs). As a tripwire against acting on a wrong or empty database, a sweep that finds no blob rows at all while the bucket has objects skips delete propagation entirely. The full knob list (region, path-style addressing, key prefix, sweep interval) is in the configuration reference.

A backup only helps if the bytes it protects are still good. A periodic scrub sweep re-hashes every stored blob against its recorded CID and size, and walks the blob directory for orphans in both directions: a database row whose file has gone missing, and a file that no row owns. Bitrot, a truncated write, or a bad restore surfaces as an operator alarm (the blob_scrub_* metrics and GET /v1/admin/health) months before a migration would trip over it.

When the bucket mirror is configured, the sweep can do more than report: a file that fails its hash or has gone missing is auto-healed from the mirror’s verified-good copy. Auto-heal is on by default and has no effect when the mirror is disabled: with no verified copy to pull from, a bad file is only ever flagged, never silently replaced. Both knobs (blob_scrub.interval_secs, default 6 hours; blob_scrub.auto_heal) are in the configuration reference.

Two guarantees close the loop around the sweep, so it catches genuine bitrot rather than gaps the write and read paths should have handled themselves:

  • Writes are crash-durable. An uploaded blob is written to a temp file, fsynced, atomically renamed onto its content-addressed path, and the directory fsynced, before the database row is recorded. A crash or power loss can no longer leave truncated bytes at a valid path behind a row that already committed.
  • Reads are verified. getBlob re-hashes a blob against its CID before serving it and returns a 404 (flagging the scrub alarm counter) on a mismatch, so a corrupted file is never handed to a downstream cache. A verified response carries the long-lived immutable cache header the blob spec recommends.

To restore the database from the replica:

Terminal window
litestream restore <path-to-db>

Litestream pulls the latest state (or a point in time) from the bucket. On a fresh container the PDS does this automatically on boot when the local database is absent.

Schema migrations are forward-only; there is no down-path. Redeploying an earlier vX.Y.Z tag is safe only when the schema change was backward-compatible. If it wasn’t, roll back by restoring the database from the Litestream replica to a point before the promote, rather than by redeploying old code against a newer schema.