# Attachment storage

> Keep attachment bytes in Postgres, or move them to S3-compatible storage, with garbage collection and a verified migration.

Source: https://baalda.com/docs/attachment-storage
Site: Baalda, https://baalda.com

Attachment bytes are stored separately from the notes themselves. `BLOB_STORAGE` decides where: `postgres`, which needs no configuration at all, or `s3` for any S3-compatible bucket.

## Choosing

|  | Postgres | S3 |
|---|---|---|
| Configuration | None | Bucket, credentials, region or endpoint, and a CORS policy |
| Per-file ceiling | 25 MB by default, a memory bound | 500 MB by default, a product decision |
| Where bytes flow | Through the server process, buffered | Direct between client and bucket |
| Backups | Covered by your database backup | Separate from the database, so back both up |

## Turning S3 on

Point the server at the bucket and restart it. An incomplete configuration is a fatal startup error rather than a silent fallback to the database, so a half-configured server will not quietly accept uploads it cannot keep.

> A browser-side upload needs the bucket's CORS policy from the deployment document. Without it uploads fail in the client while the server looks perfectly healthy.

## Garbage collection

Collecting attachments no note references any more is off by default, behind `BLOB_GC_ENABLED`. When it is on:

- An attachment must have existed for `BLOB_GC_ORPHAN_DAYS`, 30 by default, before it can be collected. An attachment is uploaded before the note embedding it is written, and that note may arrive days later from a device that was offline.
- The orphan pass runs at most every six hours, on top of a sweep every fifteen minutes.
- At most 200 deletions happen per run, which bounds the blast radius if the reference table is ever wrong.

The deletion queue is a different thing and is always on: those are objects whose row a vault or organization delete already removed, and the flag does not affect them.

## Moving existing attachments to S3

The migration runs in four phases and is reversible until the last one.

1. Dry run. It reports what would move and changes nothing.
2. Write the objects. Every blob's bytes are re-hashed, any row whose content disagrees with its recorded hash is refused rather than moved, each object is verified with a HEAD request, and only the storage key is recorded. Reads still come from the database, so this phase is undone by clearing one column.
3. Confirm downloads still work, while they are still being served from the database.
4. Flip the verified rows and release their bytes, after re-checking that each object is still present at the right size. After this the database copy is gone.
