That’s it. When STORAGE_SQLITE_PATH is not provided, the default path is ./data/dbportal-storage.db (an existing ./data/libredb-storage.db from the snapshot is still used when the new file does not exist yet`.
Recommended for production, teams, and high-availability deployments.
Important: Unlike SQLite, STORAGE_POSTGRES_URL is required. There is no default value. If you set STORAGE_PROVIDER=postgres without providing a connection string, the app will throw an error on the first storage request:
Error: STORAGE_POSTGRES_URL is required when STORAGE_PROVIDER=postgres
On the first API request, the PostgreSQL provider:
Creates a connection pool — max 5 connections, 30s idle timeout
Handles idle-client failures — the pool gets an error listener immediately (see below)
Creates the table — user_storage table with the schema below via CREATE TABLE IF NOT EXISTS
The database itself must already exist. The table is auto-created, but the database is not.
This pool is long-lived: once created it serves every storage request for the life of the process. A
pooled client that fails while idle — the server dropped it, the network went away, the DBA
restarted the database — has no query to reject, so pg destroys it and emits error on the pool.
Node treats an error event with no listener as an uncaught exception, so the provider attaches one
that logs through the storage logger (provider=postgres) and lets the pool open a fresh client on
the next request. A dropped idle connection therefore costs one log line, never the server process.
The PostgreSQL database provider carries the same guard for the same reason (see
providers/postgres.md).
sslmode is read as libpq reads it (docs/CONTEXT.md §4.47): require (and prefer,
allow, no-verify) encrypts without checking the chain - the mode for a managed database
whose certificate is signed by its own CA, such as Cloud SQL - and verify-ca, verify-full
and verify-system check it against the runtime’s trust store, which is what a
publicly-signed certificate needs. This pool has no channel for a CA PEM. The URL reaches
the pg driver without its sslmode: the driver reads the URL after the explicit setting
and lets the URL win, and its own reading of require verifies - which is how a Cloud SQL
store on the documented URL once failed with “unable to verify the first certificate”.
verify-system is the connection form’s own mode name (SSLMode in
src/lib/types.ts), kept for URLs written in its vocabulary.
The table is auto-created on first request. However, if you prefer to create it yourself — for example, in environments where the app user doesn’t have CREATE TABLE privileges, or you want to track schema changes in version control:
-- PostgreSQL
CREATETABLEIFNOTEXISTS user_storage (
user_id TEXTNOT NULL,
collectionTEXTNOT NULL,
dataTEXTNOT NULL,
updated_at TIMESTAMPTZNOT NULLDEFAULTNOW(),
PRIMARY KEY (user_id, collection)
);
-- Optional: index for faster lookups by user
CREATEINDEXIFNOTEXISTS idx_user_storage_user_id ON user_storage (user_id);
With STORAGE_PROVIDER=sqlite or postgres, every audit event the server emits (logins,
denials, maintenance, every statement a person runs — see docs/CONTEXT.md §4.2) is also
appended to an audit_events table next to user_storage:
Column
Type
Meaning
id
text, primary key
the event’s own id, minted once
ts
timestamp
when it happened
type
text
the event type, for filtering
data
text
the sanitized event as JSON — never a credential, never a raw error
On PostgreSQL the table is partitioned by range on ts (docs/CONTEXT.md §4.43): one
partition per calendar month (audit_events_p2026_09) or, with AUDIT_PARTITION=week, per
week from Monday (audit_events_w2026_09_14). The primary key is (ts, id), as a
partitioned unique index must carry the partition key. The current period’s partition and
the next two exist after every boot, a daily audit-partitions job (handed to the queue by
the scheduler leader) keeps them ahead, and an append whose instant no partition holds makes
one on the spot. Retention (AUDIT_RETENTION_DAYS) drops a partition whole once it is
wholly past, instead of deleting rows. An install from before this has its plain table
attached, with no copy, as the audit_events_legacy partition holding everything up to the
end of the current period; that one partition is the only one whose old rows are deleted
until it can go whole. Without a filter the admin page’s total is the planner’s estimate
once the table holds more than a hundred thousand rows. SQLite keeps one table and prunes
by row.
The table is append-only by contract: no code path updates or deletes a row, and it is
not reachable through the per-user /api/storage routes. The admin Audit page reads it
(GET /api/admin/audit answers source: "store"), so the record survives restarts and is
the same across replicas. The stdout JSON line remains the authoritative channel; the write
here is fire-and-forget, and a store that is down fails the event (logged) — never the
request that produced it. On STORAGE_PROVIDER=local there is no table and the admin page
reads the per-process ring buffer.
When you switch from local mode to SQLite or PostgreSQL, existing browser data is automatically migrated on first login:
User opens the app in server mode
The sync hook detects it’s the first time (no dbportal_server_migrated flag)
All existing localStorage data is sent to the server via POST /api/storage/migrate (a fresh browser with no dbportal_* data simply sets the flag and skips the upload)
Server upserts each collection as a whole JSON blob — one row per user per collection, replacing any existing row
A flag is set in localStorage to prevent re-migration
From this point on, the server is the source of truth
No manual steps required. Just change the env var and restart.
If multiple users were sharing a browser in local mode, only the data from the user who migrates first will be sent. Each user’s server storage is isolated by their login email.
For the full migration lifecycle and the underlying merge semantics, see Migration Flow in Part 2.
The optional least-privilege agent-profile password (#328)
ssl.clientKey
The TLS client private key
sshTunnel.password
The SSH password
sshTunnel.privateKey
The SSH private key
sshTunnel.passphrase
The passphrase that unlocks the key above
Everything else stays readable, deliberately: host, port, user, agentUser, database,
name and the TLS certificates (ssl.caCert, ssl.clientCert — certificates are public by
construction). An
operator holding a dump has to be able to answer “which of my databases is in here”; that is
incident response, not a leak. No other collection is touched — history and saved_queries hold
SQL text, which is the product’s data rather than its secrets.
Each value is stored as v1:<iv>:<ciphertext> using AES-256-GCM with a fresh random IV, so two
identical passwords do not look identical in the store, and a tampered value is detected rather
than silently decrypting to something else.
Deriving from JWT_SECRET is what keeps the zero-config promise: no new required variable, and if
you never set JWT_SECRET either, the first-run bootstrap generates and persists one
(<data dir>/auth-bootstrap.json), so the key is stable across restarts.
Set a dedicated STORAGE_ENCRYPTION_KEY where key separation matters — most usefully so that
rotating your session-signing secret does not also invalidate every saved connection password.
This is the trade-off, stated plainly: rotating whichever key is in use makes every stored
credential unreadable. That applies to rotating JWT_SECRET when STORAGE_ENCRYPTION_KEY is
unset, and to rotating STORAGE_ENCRYPTION_KEY when it is set. It also applies if you lose the
bootstrap file — a container without a persistent volume for its data directory regenerates
JWT_SECRET on every start.
What happens is bounded and recoverable:
The connection survives. Its name, host, port, user and database are still there.
The unreadable field is omitted, not replaced with garbage. The password box is empty.
A warning is written to the server log once per read: Stored connection secrets could not be decrypted: N field(s) were omitted.
Nothing is deleted from the database by the read itself.
To recover, restore the previous key and restart, before using the app. Reads happen on page
load; the first time the app writes that collection back, the omitted values are gone for good,
because the browser copy is the source the server is updated from. If you cannot restore the key,
re-enter the affected passwords once and they are re-encrypted under the current key.
Reads accept both plaintext and encrypted values, and writes always produce encrypted ones. An
existing store therefore migrates as it is used, with no migration command, no downtime and no
version column. A row that is never written again stays plaintext — which is why the
STORAGE_ENCRYPTION_KEY upgrade is safe to roll back.
Browser localStorage is not encrypted. It is the rendering source and it holds the same
credentials in the clear. That is a deliberate product decision — it is what lets dbportal work
without a master password — and it is why cross-site scripting is treated as a top-severity
issue in this project rather than a session-theft issue.
Anyone who can read the server’s environment can read the credentials. The key lives there.
This protects a stolen database file or dump on its own; it is not a vault.
For STORAGE_PROVIDER=sqlite with no STORAGE_ENCRYPTION_KEY set, a backup or volume snapshot
is NOT protected. The fallback key derives from JWT_SECRET, persisted in
<data dir>/auth-bootstrap.json — the same directory STORAGE_SQLITE_PATH puts the database
file in, and the one directory the Helm chart mounts as a single /app/data volume. A snapshot
of that volume carries the ciphertext and the key that opens it side by side. Set
STORAGE_ENCRYPTION_KEY from outside that volume — a Kubernetes Secret, an environment variable
your orchestrator supplies — to close this: once the key is not itself part of the backup, a
snapshot is genuinely useless without it. STORAGE_PROVIDER=postgres does not share this
exposure by default, because the key material lives in the app’s own filesystem, a volume
separate from the database that gets backed up.
GET /api/storage returns credentials in plaintext to their authenticated owner. It has to:
the app must be able to redisplay a saved password for editing.
Path to SQLite file. Directory and file are auto-created.
STORAGE_POSTGRES_URL
Yes (postgres mode)
—
PostgreSQL connection string. No default — app will error without it.
These are server-side only variables (no NEXT_PUBLIC_ prefix). The client discovers the mode at runtime via GET /api/storage/config. This means one Docker image works for all modes. See Why Not NEXT_PUBLIC_*? for the rationale.
Stored connection secrets could not be decrypted: 3 field(s) were omitted.
The encryption key changed. Either JWT_SECRET was rotated (and STORAGE_ENCRYPTION_KEY is not
set), or STORAGE_ENCRYPTION_KEY itself changed, or the data directory holding
auth-bootstrap.json was not persisted so a fresh JWT_SECRET was generated on start.
Restore the previous key and restart before using the app — see
Rotating a key invalidates stored credentials.
If the key is gone, re-enter the affected passwords; everything else about the connection is intact.
Both SQLite and PostgreSQL use the same single-table design. The table is auto-created on first request, but the full DDL is provided here for reference.
This part describes the internals of the storage abstraction layer: design goals, module structure, the storage facade API, provider interface, the write-through cache, the useStorageSync hook lifecycle, migration flows, and the security model.
Key insight:localStorage is always the rendering source (L1 cache). The server database is the persistent source of truth (L2). The sync hook keeps them in sync via a write-through cache pattern.
All application state is organized into 10 collections, each stored as a JSON blob:
Collection
Type
Description
Max Items
connections
DatabaseConnection[]
Saved database connections
—
history
QueryHistoryItem[]
Query execution history
500
saved_queries
SavedQuery[]
User-saved SQL/JSON queries
—
schema_snapshots
SchemaSnapshot[]
Schema diff snapshots
50
saved_charts
SavedChartConfig[]
Saved chart configurations
—
active_connection_id
string | null
Currently active connection
—
audit_log
AuditEvent[]
Audit trail events
1000
masking_config
MaskingConfig
Data masking rules and RBAC
—
ssh_profiles
SshProfileRecord[]
Shared SSH profiles (docs/CONTEXT.md §4.9); password, key and passphrase sealed like a tunnel’s
—
threshold_config
ThresholdConfig[]
Monitoring alert thresholds
—
A snapshot taken before the object model has no kind and no path.schema_snapshots holds what
the schema list held when the snapshot was taken, and a live reading now always carries an object’s
kind and its full address; a record written by an older version carries the name and the columns
only. Those records are kept and stay comparable rather than being migrated or discarded, so the
diff matches an old snapshot to a new reading BY NAME, and a diff against one cannot tell a view
from a table or two same-named objects in different schemas apart. Take a fresh snapshot to get a
baseline that carries both. Nothing is lost either way: a stored snapshot is the user’s own data and
this product does not rewrite it in place.
OIDC users (Auth0, Keycloak, Okta, Azure AD) have their preferred_username or email claim mapped to the same username field used as user_id. See OIDC.md for SSO configuration — it pairs well with server storage for team deployments.