Storage and database
Blob storage
Raw messages, attachments and files live in object storage. Postgres keeps their metadata and who uses them. Each file is stored once per workspace, under its SHA-256, however many messages carry it. A file nothing uses any more is deleted by an hourly sweep once CAMPBOOKS_BLOBS__SWEEP_GRACE_HOURS (default 24) have passed. Files larger than CAMPBOOKS_BLOBS__MAX_OBJECT_BYTES (default 50 MiB) are refused.
Local disk (default)
CAMPBOOKS_BLOBS__BACKEND=fs stores files under CAMPBOOKS_BLOBS__PATH (/data/blobs, the blobs-data volume). Writes are atomic: a temporary file, flushed to disk, then renamed into place.
S3-compatible store
Any store that speaks the S3 API works (AWS S3, Garage, MinIO, Ceph, Cloudflare R2). Create a private bucket and an access key limited to it, then set this in .env:
CAMPBOOKS_BLOBS__BACKEND=s3
CAMPBOOKS_BLOBS__S3__BUCKET=campbooks-blobs
CAMPBOOKS_BLOBS__S3__ENDPOINT=https://s3.example.com # unset for AWS
CAMPBOOKS_BLOBS__S3__REGION=us-east-1 # what your store expects
CAMPBOOKS_BLOBS__S3__ACCESS_KEY_ID=...
CAMPBOOKS_BLOBS__S3__SECRET_ACCESS_KEY=...
CAMPBOOKS_BLOBS__S3__PATH_STYLE=true # most self-hosted stores
The server checks that it can reach the bucket on startup and refuses to start otherwise. Large uploads go up in parts. Add a lifecycle rule to the bucket that aborts incomplete multipart uploads after a day, so a crash mid-upload leaves nothing behind. The blobs-data volume is unused with this backend.
Encryption at rest
Campbooks does not encrypt files itself yet. Use your store's encryption: server-side encryption on the bucket, or an encrypted disk under blobs-data for the local backend.
Moving between backends
Copy the whole tree (<workspace>/<aa>/<sha256> keys, the _staging/ prefix excepted) to the new store with the server stopped, then switch the settings. The keys are identical on disk and in a bucket.
Database logins
Postgres skips row-level security for superusers and BYPASSRLS roles. A query that can RESET ROLE can also climb back to the login's own power. So the server uses two logins:
| Login | Setting | Used for | Must be |
|---|---|---|---|
| runtime | CAMPBOOKS_DATABASE_URL (and optional CAMPBOOKS_DATABASE_PASSWORD_FILE) | every request and job | LOGIN, not superuser, no BYPASSRLS, owns nothing, member of campbooks_app |
| migration | CAMPBOOKS_DATABASE_MIGRATION_URL | applying migrations on startup, nothing else | the schema owner (or a superuser) |
Bundled Postgres
Nothing to do. The one-shot db-init service creates the campbooks_runtime login on every up (it is idempotent and also upgrades an existing installation). It generates the password once into the db-secrets volume, and the server reads it from /run/db/runtime-password. The owner login stays campbooks with POSTGRES_PASSWORD, used for migrations only.
To rotate the runtime password, delete the file in the volume and start again:
docker compose run --rm --entrypoint rm db-init /run/db/runtime-password
docker compose up -d
Your own or a managed Postgres
Create the login once, as an administrator. The migrations then grant campbooks_app what each table needs.
CREATE ROLE campbooks_app NOLOGIN; -- skip if it exists
CREATE ROLE campbooks_runtime LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS
NOCREATEDB NOCREATEROLE;
GRANT campbooks_app TO campbooks_runtime;
-- Allow the migration user to run the first migration's grants:
GRANT campbooks_app TO <migration user>; -- skip for a superuser
Then set CAMPBOOKS_DATABASE_URL=postgres://campbooks_runtime:<password>@host/db, or omit the password and point CAMPBOOKS_DATABASE_PASSWORD_FILE at a file holding it. Set CAMPBOOKS_DATABASE_MIGRATION_URL to the owner's URL. On managed services the owner is the user that created the database. Do not reuse it as the runtime login.
You also need pgvector, which the bundled image (pgvector/pgvector:pg17) includes.
Workspace isolation
Every workspace's rows are guarded by Postgres row-level security as well as by the queries. The server queries as a login that cannot bypass the policies, and refuses to start if it could.
At startup the server refuses to start, and says why, when the role it queries as:
- is a superuser,
- has
BYPASSRLS, - is a member of such a role, or
- owns tables with row-level security.
When a migration login is configured, it also refuses if the runtime login could still switch to a privileged role. Only the old single-URL setup (a privileged login that runs SET ROLE campbooks_app) keeps working, with a warning to split the logins. If you cannot split them yet, see Troubleshooting.