Skip to content
Lumaft documentation contents
Lumaft documentation

Database integration and management

Configure SQLite or PostgreSQL, adopt PostgreSQL from SQLite, and run migrations, backups, and restores.

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=require at 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.

  1. Take and verify a cold SQLite backup. Keep it after the cutover; a SQLite file is not a PostgreSQL backup format.
  2. Open Administration → Database.
  3. Enter the PostgreSQL connection string in the write-only form, then confirm your username and current password.
  4. Review the preflight before anything moves.
  5. Confirm adoption. Lumaft pauses application writes, transfers and verifies the data, and saves the provider configuration atomically in the data directory.
  6. Restart the container when prompted. On restart, PostgreSQL is authoritative.
ScreenshotAdministration → Database page showing the current provider as SQLite, the write-only PostgreSQL connection string field, the reauthentication fields, and the preflight result before confirmation

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 0600 and 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 503 readiness 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-safe migrations 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:

  1. Take and verify a cold backup.
  2. Read the refusal and confirm the revision is the one the release notes describe.
  3. Start the image once with LUMAFT_SCHEMA_UPGRADE_APPLY=<revision named in the refusal>.
  4. Confirm /api/v1/readiness reports 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)

  1. Stop the replica cleanly: docker stop -t 30 lumaft. A clean stop checkpoints the WAL and leaves no -wal or -shm sidecar. Exit code 143 is expected.
  2. Take and verify a cold backup. Do not proceed without it.
  3. Start the new image against the same volume.
  4. 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

  1. Stop and remove the running replica.
  2. Replace /data/lumaft.db with the backup and delete any leftover lumaft.db-wal, lumaft.db-shm, and lumaft.db.owner files.
  3. 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.

ScreenshotAdministration → Data & Retention page showing the two retention windows, the policy revision history, and the Expiry worker status card