Databases
The Databases page covers the engines Conduit runs for itself, a browser and editor over the two
SQL ones, a read-only view of the Valkey, a SQL console with stated limits, and managed databases
you can hand to a website, a bot or an API. Read from lib/system-services.ts,
lib/user-databases.ts, lib/db-viewer.ts, lib/db-dialect.ts, lib/db-route.ts,
lib/db-stats.ts, app/api/databases/, app/api/db/ and app/databases/.
The four system engines#
Conduit needs a few pieces of infrastructure of its own. Each is a normal service in the
Conduit group, which carries the system flag: the panel names it as Conduit's own and only an
admin may change it. Which service fills which role is a designation in
network.systemServices (role to task id). A role with no designation adopts every service of
the matching software kind, which is how older installs behaved; a designated role claims exactly
one service, so deploying a second Valkey for your own app no longer enlists it into the
player-sync ring.
| Role | Software | What it holds | Port | Database and login |
|---|---|---|---|---|
| Player sync | Valkey | inventory, health and XP handed between servers, and LuckPerms messaging. Mostly pub/sub: the luckperms:update channel stores no keys, and inventory-share writes transient conduit:inv:<group>:<uuid> keys with a 300 s TTL that exist only during a player's server switch | 6379 | password derived from the forwarding secret |
| Permissions | PostgreSQL | the LuckPerms database. Managed PostgreSQL databases are created on this instance too | 5432 | luckperms, login conduit |
| Gamestats | MariaDB | bans and mutes (checked on login and every chat message), friends, parties, player and staff action logs (a rolling 90 days), chat filter patterns, the map library mirror the older game systems read, Blossoms balances, and raw minigame stats mirrored to PostgreSQL on a schedule. Managed MariaDB databases live on this instance too | 3306 | gamestats, login conduit |
| Object storage | MinIO | schematics, worlds and large assets | not on this page; see the storage pages |
The passwords are derived from the forwarding secret and re-asserted by the reconcile loop (security.md); the page never shows them. Copying one is the credentials vault's job, behind a password re-check.
The page and who may use it#
/databases has four tabs, each reachable by a hash a palette entry can deep-link to:
#managed, #pg (PostgreSQL, LuckPerms), #maria (MariaDB, Gamestats) and #redis (Valkey,
messaging).
- The page needs
databases.view; changing managed databases needsdatabases.manage. - The editor routes
/api/db/pg,/api/db/mysqland/api/db/redisneeddatabases.query, which the built-in roles give to admins only: raw rows of the platform's own databases include LuckPerms nodes and player data. /api/databasesand/api/databases/{id}answer a browser session or a personal token only. The machine token is refused there.- The page is per cluster. It has no cluster switch, and
/api/dbis on the relay's deny list, so a joined cluster cannot browse this cluster's databases through the relay.
The System engines band on the managed tab shows connection usage per SQL engine, used/max
from pg_stat_activity and max_connections or Threads_connected and @@max_connections,
cached ten seconds. A failure is classified rather than reported as "unreachable" whatever
happened: out of connection slots (a full engine, which is what the LuckPerms pool squeeze
looks like), authentication failed, or unreachable.
The browsers#
The PostgreSQL and MariaDB tabs, and the editor on a managed database's page, are one component
over one connection handle. What differs per engine (quoting, placeholders, catalogue queries) is
data in lib/db-dialect.ts; the editor is written once.
- The rail lists base tables with exact row counts. The count is a
count(*)per table, so a very large table costs a scan on every load of the tab. - Rows are paged, 50 at a time and at most 200, inside a read-only transaction, so a page is consistent with itself.
- Editing a cell writes an
UPDATEkeyed on the table's primary key, or on every column when it has none (the grid shows a "no pk" chip). The update is refused unless exactly one row matched: a key that names two rows is not a key. An empty cell writesNULL. - Insert and delete work the same way; columns left out of an insert take their defaults.
- Identifiers a person types are checked against a narrow shape (letters, digits and underscore, up to 63 characters) and against the live catalogue before they reach a statement; values are always parameters. Every write is logged with the engine, database and table.
- Schema changes (add or drop a column, create or drop a table) are offered on a managed
database only. On the system tabs the buttons are absent, and the API answers
409: the schema is shared with the servers that read it, and the sentence says to create a managed database for one you may reshape.
The gamestats tab starts with a band naming the eight features that read the database, in the words the features go by. Every table there backs something live, and an edited row is seen by the game at once.
The Valkey tab is read-only by construction: a small RESP client that only issues commands from
an allow list (INFO, SCAN, TYPE, TTL, GET, HGETALL, LRANGE, SMEMBERS, ZRANGE,
XRANGE and the like). It shows the overview (version, memory, clients, replication role, keys
per logical database, and the live pub/sub channels, because an empty key list does not mean an
idle Valkey), lets you pick an instance (the primary or a replica) and a logical database 0 to 15,
scans keys by pattern in pages of 100 with type and TTL, and reads one value by its type. Streams
show their length and the newest 100 entries; other collections are cut at 200 entries. There is
no write of any kind.
The SQL console#
The terminal button on either SQL tab, and on a managed database's editor, opens a console
against that connection. Its limits, from lib/db-viewer.ts:
- One statement per run, inside a transaction. PostgreSQL:
BEGIN,SET LOCAL statement_timeout = 8000, the statement,COMMIT; MariaDB:SET SESSION max_execution_time = 8000,START TRANSACTION, the statement,COMMIT. A failure rolls back. On PostgreSQL several statements in one run are accepted and the last result is shown, the waypsqlshows it; the MariaDB connection is opened with multiple statements off. - Eight seconds per statement. On MariaDB
max_execution_timeboundsSELECTs, which is where a runaway console query comes from. - 500 rows returned at most. The grid is for looking, not exporting.
- Every run is written to the operator audit log: who ran it, on which engine and database, and the first 200 characters of the SQL. A run that failed is recorded too, with status 409, so the two read differently in the log.
Two things it does not do. It does not apply the schema gate: the gate refuses the editor's DDL
buttons on a system engine, and the console runs whatever the connected login can, ALTER TABLE
on the LuckPerms schema included. On the system tabs that login is conduit, which is the panel's
own; treat the console there as a root shell for that database. And it does not stream: a
statement that returns more than 500 rows is cut, not paged.
Managed databases#
A managed database is an isolated database plus a login granted to that database and nothing else, provisioned on the running system MariaDB or PostgreSQL. The instance is a normal Conduit service, so its vzdump and logical dumps cover the new database; there is no per-database backup or restore here.
Create takes a name and an engine (POST /api/databases {name, engine}, admin). The name is
slugified (lower case, letters, digits and underscore, at most 40 characters, starting with a
letter) into the database app_<slug> and the login u_<slug>, so a managed database can never
collide with luckperms, gamestats or minigames. The password is 24 characters and URL-safe.
The name must be unique per engine, and the engine must have a running system instance
("deploy a MariaDB service first" otherwise). What is run:
CREATE DATABASE `app_myapp` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'u_myapp'@'10.%' IDENTIFIED BY ...;
CREATE USER 'u_myapp'@'localhost' IDENTIFIED BY ...;
GRANT ALL PRIVILEGES ON `app_myapp`.* TO 'u_myapp'@'10.%';
GRANT ALL PRIVILEGES ON `app_myapp`.* TO 'u_myapp'@'localhost';CREATE ROLE "u_myapp" LOGIN PASSWORD ...;
GRANT "u_myapp" TO "conduit"; -- PG15+: the creator must be a member to set the owner
CREATE DATABASE "app_myapp" OWNER "u_myapp";
REVOKE ALL ON DATABASE "app_myapp" FROM PUBLIC;
GRANT ALL PRIVILEGES ON DATABASE "app_myapp" TO "u_myapp";The record (userDatabases[]) keeps the database, the login, the password, and the engine
primary's address and port as they were at provisioning. The password is in the store so the page
can show a connection string; it is never printed on a page load.
The list shows each database with its engine, app_ name, host:port, who created it, and a
"public" chip when its engine port is exposed. Under each row are its connection strings, masked
(*** in place of the password) as they arrive from the server. Reveal asks for your password,
sets the five-minute sudo cookie, and re-fetches the list; the same cookie reveals every row and
the managed database pages, so it is one round trip and one flag (revealed).
The page per database (/databases/<id>) has four parts:
- Editor: the same browser, opened as the tenant login, so it reaches exactly this database and nothing else on the engine. Schema changes are offered here.
- Credentials: the internal URI and JDBC form, plus the external forms when the database is
exposed, and Rotate password. Rotation needs the sudo password (
POST /api/databases/{id} {rotate:true}), applies a new password on the engine (ALTER USERfor both MariaDB hosts,ALTER ROLEon PostgreSQL) and then in the store, and answers with the new strings revealed: masking the only copy of a secret just set would be worse than showing it. Anything using the old string fails until it is updated. - Usage: size on disk (
pg_database_size, or data plus index length frominformation_schema), table count and open connections (frompg_stat_activity, or the processlist filtered to the login), read as the tenant login and polled every 30 seconds. - Danger zone: Expose publicly opens the engine's port on its node through the firewall's
DNAT (
PATCH /api/databases {id, exposePublic}), which exposes the shared engine port, not this database alone; the login stays scoped, and the firewall access list is the real gate, so set it before switching this on. Delete asks you to type the database name, then drops the database and its login (DROP DATABASE IF EXISTS; PostgreSQL terminates open sessions first) and forgets the record. There is no undo short of a restore of the instance.
Connection string shapes#
The internal forms use the engine primary's cluster address, which only the cluster can reach:
mysql://u_myapp:PASSWORD@10.42.0.12:3306/app_myapp
postgresql://u_myapp:PASSWORD@10.42.0.11:5432/app_myapp
jdbc:mysql://10.42.0.12:3306/app_myapp?user=u_myapp&password=PASSWORD
jdbc:postgresql://10.42.0.11:5432/app_myapp?user=u_myapp&password=PASSWORDThe external forms exist only while the database is exposed and the panel can resolve a
publicHost: the address of the node the engine instance runs on, where the DNAT listens. They
are the same strings with that address in place of the cluster one. Masked strings replace the
password with *** in both the user:password@ and the ?password= positions. Passwords are
generated without +, / or =, so no string needs escaping.