Database integration and management
The database is the product. Everything Lumaft keeps — observed projections, the durable operation timeline, accounts, retention audits — lives in one database. Treat it as sensitive infrastructure data and protect it like the state bucket it describes.
Lumaft supports two providers:
| Provider | Edition | Replicas | Who operates the server |
|---|---|---|---|
| SQLite (default) | Community | Exactly one | Nobody; it is a file |
| Customer-operated PostgreSQL | Business | One | You |
| PostgreSQL with replicas | Enterprise | Up to a declared ceiling | You |
The license gates adopting PostgreSQL, never running it. An installation that lapses keeps its PostgreSQL database, every record, and export. Only a new cutover requires the entitlement.
SQLite
Location and permissions
Lumaft opens /data/lumaft.db unless LUMAFT_SQLITE_PATH names another absolute, normalized
path. The parent directory must already exist, be owned by UID 1000, and not be writable by
group or others; mode 0700 is the norm. Lumaft enforces mode 0600 on the database file.
Storage contract
The database runs in WAL mode, which requires host-local shared memory and file locking. A
supported deployment therefore runs one replica with /data on an exclusive, durable,
block-backed volume whose lifecycle is independent of the container.
| Supported | Not supported |
|---|---|
A local disk or block device bind-mounted at /data |
The container writable layer or task scratch storage |
| An EBS volume, Azure managed disk, or VMDK | EFS, NFS, SMB, Azure Files, or any ReadWriteMany volume |
| A Kubernetes ReadWriteOnce block PVC on one replica | One volume mounted by several replicas or environments |
| An ECS service-managed EBS volume (deleted with the task) |
Ownership lock
At startup Lumaft takes an exclusive lock on lumaft.db.owner beside the database and holds it
for the life of the process. A second replica pointed at the same file fails closed with
ownership-conflict instead of corrupting the store. The operating system releases the lock when
the owning process exits, so a crash never strands the deployment.
Sizing
One vCPU and 2 GiB of memory served 20,000 operation summaries across 200 stacks with page reads under 250 ms p95 and cold readiness under 30 seconds. At 100,000 summaries the same resources crossed that read criterion. Measure your own workload. If SQLite read latency or cold readiness repeatedly exceeds those figures at your intended allocation, plan PostgreSQL adoption or requalify your resources and retention.
PostgreSQL
This section covers configuration and adoption. Running the server day to day — connections, replicas, maintenance, backups, capture capacity — is PostgreSQL operations.
Requirements
- A PostgreSQL server you run, size, patch, back up, and make highly available. Lumaft provides none of that; it owns only its schema and migrations. Use a currently supported PostgreSQL major version.
- A dedicated database or schema.
- A role that can manage Lumaft's schema (create and alter tables in that database).
- TLS appropriate to your deployment (
sslmode=requireat minimum). - Network reachability from every Lumaft replica.
Fresh installation on PostgreSQL
Set exactly one of:
LUMAFT_POSTGRES_URL=postgresql://lumaft:<password>@db.internal:5432/lumaft?sslmode=require
LUMAFT_POSTGRES_URL_FILE=/run/lumaft/postgres-url
Prefer the file. It must meet the same rules as every other secret file: direct regular file,
mode 0600, protected parent. Do not also set LUMAFT_SQLITE_PATH, do not set both PostgreSQL
sources, and do not combine either with a UI-managed provider file (below). Conflicting sources
refuse startup. No separate provider-selection variable exists.
Connection values never appear in logs or health responses.
Adopt PostgreSQL from an existing SQLite installation
Adoption is an administrator workflow in the console. It moves the durable record, verifies it, and makes PostgreSQL authoritative. It requires a Business or higher entitlement.
- Take and verify a cold SQLite backup. Keep it after the cutover; a SQLite file is not a PostgreSQL backup format.
- Open Administration → Database.
- Enter the PostgreSQL connection string in the write-only form, then confirm your username and current password.
- Review the preflight before anything moves.
- Confirm adoption. Lumaft pauses application writes, transfers and verifies the data, and saves the provider configuration atomically in the data directory.
- Restart the container when prompted. On restart, PostgreSQL is authoritative.
Rules that protect the cutover:
- A failed or cancelled migration leaves SQLite authoritative, does not activate the provider file, and removes temporary target credentials.
- Starting PostgreSQL against a non-empty SQLite installation without the completed-transfer stamp refuses. Changing a connection variable is not a migration.
- The UI-managed provider file is mode
0600and as sensitive as a database backup. Encrypt the volume that holds it. - Environment-managed PostgreSQL (
LUMAFT_POSTGRES_URL*) stays operator-owned and cannot be changed through the console.
Enterprise replicas
Multiple serving replicas require Enterprise, PostgreSQL, and identical configuration on every replica: same release, database, backend definitions, authentication, and licensing settings.
| Variable | Default | Meaning |
|---|---|---|
LUMAFT_REPLICAS_MAX |
1 |
Honest ceiling, including rollout surge |
LUMAFT_POSTGRES_POOL_MAX |
1 |
Pooled connections per replica |
LUMAFT_POSTGRES_CONNECTION_HEADROOM |
10 |
Connections reserved for your other clients |
LUMAFT_MIGRATION_WAIT_SECONDS |
300 |
How long a waiter allows the migrating replica |
Startup checks the server's real budget when the ceiling exceeds one:
replicas × (pool maximum + 2) + headroom ≤ max_connections − reserved connections
Three replicas with a pool of two and ten spare connections need 22 non-reserved connections. An insufficient budget refuses startup. SQLite always refuses a ceiling above one.
Behavior worth knowing:
- One replica holds installation-primary ownership and serves at any license level. Additional
replicas serve only while the Enterprise entitlement verifies; on lapse they return
503readiness while staying alive, and the primary continues. - Periodic jobs (observation, retention, purge, notifications, license renewal) each have one database-backed owner. Failover promotes an existing standby.
- Coordination uses PostgreSQL notifications with a 15-second poll fallback.
- Login throttling is process-local; at N replicas the combined allowance is N times one replica's. Enforce the installation limit at your ingress.
- Only
startup-safemigrations permit a rolling deployment. Gated migrations require one replica. - The topology provides coordination and failover, not linear write throughput.
Route traffic on readiness and restart processes on liveness. A lapsed installation should run one replica; a rolling deployment that waits for unentitled standbys to become ready cannot complete.
Migrations and upgrades
Lumaft applies checked-in migrations before accepting traffic and fails startup when one cannot complete. Three guardrails make that safe.
Compatibility manifest
Each release declares the oldest schema revision it upgrades from directly. An image started against an older database refuses before any migration runs and names the intermediate releases to run first. Jumping versions cannot silently attempt an unrehearsed path.
Content pinning
Every applied migration is recorded with the content hash it was applied from. A later image whose migration content differs for an already-applied revision refuses the volume. An edited migration can never replay as "already applied".
Gated migrations
Migrations ship classified. startup-safe changes apply on an ordinary restart. A migration
classified major, destructive, or long-running does not: Lumaft refuses to start, and the
refusal is the preflight. The log line names the gated revisions, their classes, and the exact
authorization value. Nothing has been migrated.
To apply one:
- Take and verify a cold backup.
- Read the refusal and confirm the revision is the one the release notes describe.
- Start the image once with
LUMAFT_SCHEMA_UPGRADE_APPLY=<revision named in the refusal>. - Confirm
/api/v1/readinessreports ready, then remove the variable before the next restart.
Before migrating, Lumaft writes a consistent copy of the database to
<database>.recovery-<current revision> beside the file (or in
LUMAFT_SCHEMA_UPGRADE_RECOVERY_DIRECTORY), opens it read-only, and verifies its structure,
foreign keys, and applied-revision record against the live database. A recovery point that cannot
be written or verified blocks the apply. An existing recovery point is never overwritten. The copy
roughly doubles the database's footprint on the receiving volume, so an apply on a nearly full
volume fails at that step rather than mid-migration.
The authorization names a specific revision, not a boolean. A value left in a deployment file cannot authorize the next release's gated migration.
Upgrade procedure (SQLite)
- Stop the replica cleanly:
docker stop -t 30 lumaft. A clean stop checkpoints the WAL and leaves no-walor-shmsidecar. Exit code143is expected. - Take and verify a cold backup. Do not proceed without it.
- Start the new image against the same volume.
- Confirm readiness and that the console loads.
Rollback is a restore
Migrations are forward-only. An older image started against a database a newer image migrated refuses to start, naming both revisions and touching nothing. Rolling back a committed upgrade means restoring the pre-upgrade backup and starting the older image against the restored file.
Reading which release last migrated a stopped volume
Every successful startup stamps the application version, schema revision, and time into the database. With the replica stopped:
docker run --rm --entrypoint node \
--mount type=volume,source=lumaft-data,target=/data \
"$LUMAFT_IMAGE" \
-e "console.log(JSON.stringify(require('better-sqlite3')('/data/lumaft.db', { readonly: true, fileMustExist: true }).prepare('select application_version, schema_revision, stamped_at from deployment_meta').get()))"
Startup failure messages
Every startup failure ends with one sanitized line naming the failure class. It never contains SQL, paths, or raw exception text.
| Log line contains | Meaning | Recovery |
|---|---|---|
is newer than this image supports |
A newer release migrated this volume | Start the previous image, or restore the pre-upgrade backup |
a schema migration failed and was rolled back |
Nothing partial was recorded | Restart the previous image; restore the backup if it refuses |
of them does not run on an ordinary restart |
A gated migration needs explicit apply | Follow Gated migrations |
could not establish a verified recovery point |
No space, or an unverifiable earlier recovery point | Free space or set the recovery directory, then retry |
this image does not upgrade a database at schema revision |
Volume is older than the manifest's floor | Run the intermediate releases the message names |
applied from different migration content than this image carries |
Pinned content mismatch | Do not retry; start the image that migrated it and report the mismatch |
storage is unusable |
Missing mount, wrong ownership or mode, bad path | Fix the mount and permissions, then restart |
another Lumaft replica already owns this database |
Two replicas on one volume | Stop the other replica or give this one its own volume |
Backup and restore (SQLite)
The procedures below are the mechanics. Backup and disaster recovery covers what to back up beyond the database, how often, and how to rebuild an installation.
Backup
Backups are cold. Stop the replica, copy lumaft.db, restart. While stopped, the database is a
single self-contained file; the .owner sidecar is runtime state and does not need backing up.
A copy taken while the process is running is not a valid backup unless produced by SQLite's own
online backup mechanism.
From a bind-mounted host directory, copy the file directly. From a named Docker volume, use the image's own runtime:
docker stop -t 30 lumaft
docker run --rm --entrypoint node \
--mount type=volume,source=lumaft-data,target=/data \
--mount type=bind,source="$PWD/backups",target=/backup \
"$LUMAFT_IMAGE" \
-e "require('fs').copyFileSync('/data/lumaft.db', '/backup/lumaft-backup.db');"
docker start lumaft
Verify every copy: the byte size matches the source and PRAGMA integrity_check reports ok.
Protect backups like the live volume, and keep transcript and state encryption keys in a
separate, independently protected backup — a database backup without its original key cannot
decrypt what it holds.
Restore
- Stop and remove the running replica.
- Replace
/data/lumaft.dbwith the backup and delete any leftoverlumaft.db-wal,lumaft.db-shm, andlumaft.db.ownerfiles. - Start the replica and confirm readiness.
Restoring rewinds Lumaft's own record to the backup point. Backends are re-read on the normal cadence, so current projections converge forward; durable history earlier than the backup is whatever the backup retained.
Compacting
Retention expiry and purge free bytes for reuse inside the file; they never shrink it. To return
space to the volume, run an offline VACUUM against a stopped replica after a verified cold
backup and after confirming the volume can hold a second full copy:
docker run --rm --entrypoint node \
--env SQLITE_TMPDIR=/data \
--mount type=volume,source=lumaft-data,target=/data \
"$LUMAFT_IMAGE" \
-e "const Database = require('better-sqlite3'); const db = new Database('/data/lumaft.db'); db.exec('VACUUM'); db.close();"
Backup and restore (PostgreSQL)
You own it. Use your server's native tooling (pg_dump, physical base backups, point-in-time
recovery). After restoring PostgreSQL, restart every Lumaft replica. Never copy a database into a
second live installation and run both: connected licensing binds to the installation identity
stored inside it.
Storage health
Readiness is a pure serving gate; a full disk does not flap the container, because a restart
cannot fix it. Storage pressure is reported instead by the authenticated
GET /api/v1/storage-health endpoint (requires storage:health:read):
| Field | Meaning | Alert when |
|---|---|---|
writeProbe |
A rate-limited single-row probe write (at most one per 15 s) | outcome is failed |
dataDirectory |
freeBytes and totalBytes of the data filesystem |
freeBytes drops below your growth headroom |
databaseFiles |
Database and WAL sizes in bytes | Growth outpaces your plan |
lastDurableWrite |
When this replica last committed an application write | committedAt stops advancing with backends configured |
A persistent unavailable outcome on a file-backed deployment means the host is refusing
filesystem statistics; investigate rather than suppress.
Retention and deletion
Retention shapes how large the database grows. Two installation-wide windows are configured under Administration → Data & Retention, both defaulting to 90 days on new installations; existing policies are preserved:
- Operation summaries — how long the operation record is kept.
- Resource changes — how long per-kind change counts are kept.
Community selects 1–365 days; paid editions up to 3,650. A policy change is prospective and appends a revision; applying a shorter window to already-retained evidence is a separate, confirmed action. Exact-stack purge and source forget are previewed, confirmed with a backup attestation, and recorded in Deletion history. None of them free disk; see Compacting.