Seed Connections let administrators pre-configure database connections via a YAML or JSON file. Users see these connections immediately after login — no manual setup required.
Use cases:
- Platform/SaaS: provision databases for all users on signup
- Enterprise: give teams access to staging/production databases
- On-prem: DevOps pre-loads connections via Helm values or Docker volumes
1. Create seed-connections.yaml:
version: "1"
connections:
- id: "prod-db"
name: "Production Database"
type: postgres
host: "${DB_HOST}"
port: 5432
database: "${DB_NAME}"
user: "${DB_USER}"
password: "${DB_PASSWORD}"
roles: ["*"]2. Mount and set env vars:
docker run \
-v ./seed-connections.yaml:/app/config/seed-connections.yaml:ro \
-e SEED_CONFIG_PATH=/app/config/seed-connections.yaml \
-e DB_HOST=mydb.internal -e DB_NAME=mydb \
-e DB_USER=reader -e DB_PASSWORD=secret \
ghcr.io/libredb/libredb-studio:latest3. Login — the connection appears in the sidebar with a lock icon.
The config file is YAML (.yaml, .yml) or JSON (.json). Format is auto-detected by file extension.
version: "1"
defaults: # Optional — merges managed/environment/ssl only
managed: true
environment: production
ssl:
mode: require
rejectUnauthorized: true
connections:
- id: "analytics-pg" # Required, unique, lowercase slug [a-z0-9-]
name: "Analytics DB" # Required, display name in UI
type: postgres # Required: postgres|mysql|sqlite|libsql|duckdb|mongodb|redis|oracle|db2|mssql|libredb|couchbase|clickhouse|druid|elasticsearch|opensearch|trino|cassandra|prometheus|kafka|etcd|neo4j|milvus|qdrant|influxdb|influxdb3|oxia
host: "${PG_HOST}"
port: 5432
database: analytics
user: "${PG_USER}"
password: "${PG_PASSWORD}"
environment: production # production|staging|development|local|other
group: "Data Team" # Group label in sidebar
color: "#10B981" # Hex color for environment badge
roles: ["admin"] # Who can see this connection
managed: true # Admin-controlled: not editable in the UI (default from `defaults`)
ssl:
mode: require
rejectUnauthorized: true
# serviceName: "ORCL" # Oracle only
# instanceName: "MSSQL$" # SQL Server only
# localDataCenter: "datacenter1" # Cassandra only - REQUIRED there
# authSource: "admin" # MongoDB only - the database the user was created in
# saslMechanism: SCRAM-SHA-512 # Kafka only - PLAIN|SCRAM-SHA-256|SCRAM-SHA-512, a literal name
- id: "dev-mysql"
name: "Dev MySQL"
type: mysql
host: "${MYSQL_HOST}"
port: 3306
database: devdb
user: "${MYSQL_USER}"
password: "${MYSQL_PASSWORD}"
roles: ["*"] # Everyone can see this
managed: false # User gets an editable copy
environment: development
- id: "events-druid"
name: "Druid Events"
type: druid
host: "${DRUID_HOST}"
port: 8888 # Router. The Broker's 8082 serves the same endpoint
roles: ["*"]
environment: production
# No `database`: Druid reports exactly one catalog, always `druid`, so there is
# nothing to select. No `connectionString` either - its HTTP SQL API has no URI
# convention, so host and port are the whole address.
# user/password are optional and only reach a cluster running druid-basic-security.
- id: "lake-trino"
name: "Trino Lakehouse"
type: trino
host: "${TRINO_HOST}"
port: 8080 # The client protocol and the web UI share this port
database: hive # The CATALOG, not a database. Pins what the tree shows;
# a fully qualified name still reaches any other catalog.
schema: default # The session schema for unqualified table names. Without it,
# qualify names as schema.table in every statement.
user: "${TRINO_USER}"
roles: ["*"]
environment: production
# A `password` here would need `ssl.mode` set as well: the coordinator answers
# 401 "Password not allowed for insecure authentication" over plain HTTP, even
# with authentication switched off, so a password without TLS breaks a
# connection that works without one.
# No `connectionString`: jdbc:trino:// is not a form this build parses.
- id: "events-ring"
name: "Cassandra Ring"
type: cassandra
host: "${CASSANDRA_HOST}"
port: 9042 # The native protocol
database: events # The KEYSPACE, pinned for the session. Without it an
# unqualified table name resolves to nothing.
localDataCenter: datacenter1 # REQUIRED: the driver refuses to connect without it,
# and a stock single-node install reports datacenter1.
user: "${CASSANDRA_USER}"
roles: ["*"]
environment: production
# No `connectionString`: no URI convention carries localDataCenter, so a pasted
# one would produce a connection that cannot open.
- id: "metrics-prom"
name: "Prometheus Metrics"
type: prometheus
host: "${PROMETHEUS_HOST}"
port: 9090 # The HTTP API and the web UI share this port
roles: ["*"]
environment: production
# No `database`: one Prometheus server is one TSDB, so there is nothing to select.
# No `connectionString` either: http:// and https:// already parse as ClickHouse.
# user/password are optional. Both set send Basic auth (Grafana Cloud's scheme,
# though its query API sits under a path prefix this version cannot reach); a
# password alone is sent as a bearer token, for a token-guarded proxy. Over plain
# HTTP either one is readable on the wire, so set `ssl` for a server across a
# network you do not control.
- id: "metrics-influx"
name: "InfluxDB Metrics"
type: influxdb
host: "${INFLUX_HOST}"
port: 8086 # The v1 /query API of 1.x and 2.x; a 3.x server answers it on 8181
database: telegraf # Optional: a statement that names its database reads that one
user: "reader" # A 1.x user granted READ on the database
password: "${INFLUX_READER_PASSWORD}"
ssl:
mode: verify-full
roles: ["*"]
environment: production
managed: true
readOnly: true
# On 2.x leave `user` out and put a read token for the bucket in `password`:
# a password with no user is sent as a token. Without TLS to a host that is not
# loopback the connection is refused unless it sets `allowInsecureAuth: true`.
# No `connectionString`: http:// and https:// already parse as ClickHouse.
- id: "metrics-influx3"
name: "InfluxDB 3 Metrics"
type: influxdb3
host: "${INFLUXDB3_HOST}"
port: 8181
database: telegraf # One connection reads one database
password: "${INFLUXDB3_TOKEN}" # Sent as a bearer token; this type takes no `user`
ssl:
mode: verify-full
roles: ["*"]
environment: production
managed: true
readOnly: true
# On InfluxDB 3 Core every token is an admin token, so read-only is a property
# of what Studio sends, never of the token.
- id: "events-kafka"
name: "Kafka Events"
type: kafka
host: "${KAFKA_HOST}" # One bootstrap broker; the client learns the rest from it
port: 9093
saslMechanism: SCRAM-SHA-512 # A literal: PLAIN, SCRAM-SHA-256 or SCRAM-SHA-512
user: "${KAFKA_USER}"
password: "${KAFKA_PASSWORD}"
roles: ["*"]
environment: production
ssl:
mode: verify-full # SASL over plaintext is refused, and verify-full keeps the
# credentials to brokers the CA vouches for
# No `database`: one connection is one cluster, so there is nothing to select.
# No `connectionString` and no `sshTunnel`: a Kafka client reaches every broker at
# the address the broker advertises, which a tunnel to one address does not carry.
- id: "edge-libsql"
name: "Edge libSQL"
type: libsql
host: "${LIBSQL_HOST}"
port: 8080 # sqld's HTTP port
user: "${LIBSQL_USER}"
password: "${LIBSQL_PASSWORD}"
roles: ["*"]
environment: production
# A `user` sends `user` and `password` as HTTP Basic, the pair a self-hosted sqld
# started with SQLD_HTTP_AUTH="basic:<base64(user:password)>" checks. Leave `user`
# out to send `password` as a bearer token, which Turso Cloud and a sqld checking
# JWTs read. No `database`: the database is the host. No `connectionString`: a
# libsql:// URL means TLS and a token, and a `user` beside it is ignored.| Field | Required | Default | Description |
|---|---|---|---|
version |
Yes | — | Must be "1" |
defaults |
No | - | Supplies managed, environment and ssl where a connection omits them. No other field is merged, and mcp and readOnly are refused here (see MCP opt-in) |
defaults.managed |
No | true |
Default managed state |
defaults.environment |
No | — | Default environment label |
defaults.ssl |
No | — | Default SSL config |
connections |
Yes | — | Array of connection definitions (min 1) |
connections[].id |
Yes | — | Unique slug: [a-z0-9-]+, max 64 chars |
connections[].name |
Yes | — | Display name, max 128 chars |
connections[].type |
Yes | - | Database type: postgres, mysql, sqlite, libsql, duckdb, mongodb, redis, oracle, db2, mssql, libredb, couchbase, clickhouse, druid, elasticsearch, opensearch, trino, cassandra, prometheus, kafka, etcd, neo4j, milvus, qdrant, influxdb, influxdb3, oxia |
connections[].host |
No | — | Hostname or IP |
connections[].port |
No | — | Port number (1-65535) |
connections[].database |
No | — | Database name (Couchbase: the bucket. Druid has one catalog and ignores it. Trino: the catalog) |
connections[].schema |
No | — | Trino session schema, used to resolve unqualified table names inside the configured catalog |
connections[].user |
No | — | Username |
connections[].password |
No | — | Password (use ${ENV_VAR} syntax) |
connections[].apiKeyId |
No | - | Elasticsearch only (#708): the API key's id, paired with apiKeySecret and preferred over user and password when both are set; every other engine refuses the pair when the file loads. Resolved like password |
connections[].apiKeySecret |
No | - | Elasticsearch only (#708): the API key's secret, paired with apiKeyId; use ${ENV_VAR} syntax |
connections[].ssl.caCert |
No | absent | The CA certificate as PEM, or a ${ENV_VAR} or ${vault:...} reference that resolves to it, so a Kubernetes Secret can carry it into the environment (providers/etcd.md, section 12) |
connections[].ssl.clientCert |
No | absent | The client certificate as PEM, or a reference, resolved like password |
connections[].ssl.clientKey |
No | absent | The client key as PEM, or a reference; an unset reference skips the connection naming ssl.clientKey. Never inline a private key in a ConfigMap |
connections[].connectionString |
No | — | Full connection string (use ${ENV_VAR}). Druid and Trino have no URI form this build parses — those connections need host and are addressed by host and port only |
connections[].roles |
Yes | — | Access control: ["*"], ["admin"], ["user"], ["admin", "user"] |
connections[].managed |
No | from defaults | true = admin-controlled: not editable in the UI, its secrets stay on the server; false = an editable copy for the user |
connections[].readOnly |
No | absent | true refuses every write, value edit and maintenance operation on the connection, on an engine whose provider enforces it (etcd, Neo4j, Milvus, Qdrant, InfluxDB (InfluxQL), InfluxDB 3 (SQL) and Oxia); every other engine refuses readOnly: true when the file loads, naming the type and the field. Refused with managed false, on the connection or through defaults.managed, because an editable copy carries the credentials into the browser. A literal boolean: a ${ENV} reference is refused |
connections[].environment |
No | from defaults | Environment badge |
connections[].group |
No | — | Group label |
connections[].color |
No | — | Hex color for badge (e.g., #10B981) |
connections[].ssl |
No | from defaults | SSL configuration |
connections[].serviceName |
No | — | Oracle service name |
connections[].instanceName |
No | — | SQL Server instance name |
connections[].localDataCenter |
No¹ | — | Cassandra local data centre (datacenter1). ¹Optional in the schema because no other engine has it, and required by the Cassandra provider: the driver refuses to connect without one |
connections[].saslMechanism |
No | - | Kafka: PLAIN, SCRAM-SHA-256 or SCRAM-SHA-512, absent meaning none; user and password are sent only with a mechanism, and only over TLS. It takes a literal name: it is neither a credential nor an address, so a ${ENV} or ${vault:...} reference in it is refused when the file loads, naming the field, because the file is validated before any reference is resolved |
connections[].allowInsecureAuth |
No | absent | Db2, both InfluxDB types and Oxia (#786): true accepts that a connection with no TLS sends its password to the server in cleartext, which the Db2 provider otherwise refuses when the connection opens (providers/db2.md), and that an InfluxDB connection sends its password or token without TLS to a host that is not loopback, which both InfluxDB providers otherwise refuse before any socket (providers/influxdb.md, providers/influxdb3.md), and that an Oxia connection sends its token in cleartext, which the Oxia provider otherwise refuses when the connection opens (providers/oxia.md, section 4.6, where a token to this machine or through an SSH tunnel needs no tick). Set ssl instead wherever the server offers TLS. Every other engine ignores it. A literal boolean: a ${ENV} reference fails the whole file |
connections[].dataServers |
No | absent | Oxia only: the public addresses of a cluster's data servers, as host:port entries separated by commas or whitespace, at most 64. Exact entries only, never a pattern. A ${ENV} or ${vault:...} reference is resolved, as in host. Not a secret: it is listed with the connection, and the token it receives is not. Refused together with an SSH tunnel |
connections[].authSource |
No | — | MongoDB: the database its credentials live in (admin in the ordinary deployment). Without it the driver checks the user against the database being opened, which reports a credentials error |
connections[].mcp |
No | absent | true makes the connection visible to MCP clients whose token's role the connection's roles admit (docs/MCP.md). Anything but a boolean fails the whole file. An etcd or Oxia connection refuses mcp: true when the file loads: MCP is not offered for it |
An MCP client reaches a connection only when its entry says mcp: true, and only when the connection's roles admit the role the client's token carries.
The opt-in is per connection: defaults.mcp is refused, because a default would opt in every connection the file later gains.
The built-in sample connections never carry it, so they are never visible to an MCP client.
A value that is not a boolean fails the whole file, as any invalid field does: GET /api/connections/managed then answers 500 with its named reason, and every MCP tool answers that the connection configuration could not be read.
With no seed file, or with no entry that opts in for the token's role, list_connections answers an empty list.
readOnly: true makes a connection refuse every write, value edit and maintenance operation before any request, on an engine whose provider keeps the mode.
etcd's, Neo4j's, Milvus's, Qdrant's, both InfluxDB types' and Oxia's do today (providers/etcd.md, section 3.4, the Neo4j recipe below, providers/milvus.md and providers/qdrant.md, section 3.4 of each, the InfluxDB recipes above, and providers/oxia.md, section 3.5), and on every other engine the file is refused at load, with a sentence naming the type and the field.
The recipe is two seeds of one cluster: one every role reaches, read-only, and one for the people who may write.
version: "1"
connections:
- id: "cluster-read"
name: "Cluster"
type: etcd
host: etcd.internal
port: 2379
user: "reader"
password: "${ETCD_READER_PASSWORD}"
ssl:
mode: verify-full
caCert: "${ETCD_CA}"
roles: ["*"]
managed: true
readOnly: true
- id: "cluster-write"
name: "Cluster (write)"
type: etcd
host: etcd.internal
port: 2379
user: "writer"
password: "${ETCD_WRITER_PASSWORD}"
ssl:
mode: verify-full
caCert: "${ETCD_CA}"
roles: ["admin"]
managed: truemanaged: true is written out on both, although it is the default, because a connection's own value overrides a file-wide defaults.managed: false.
The load refuses readOnly: true on a connection that is not managed: an unmanaged seed is copied into the browser of every user its roles admit, with its password and TLS client key, and Duplicate turns that copy into a connection of the user's own whose readOnly can be cleared.
readOnly is set per connection and never in defaults, which the load refuses, so a later connection in the file never inherits it.
The mode is a boundary only where etcd authenticates the client with a secret only the seeds hold, a password or a client certificate: on an etcd that authenticates nobody, a user who knows the address can reach it with a connection of their own.
A read-only connection shows a Read-only marker beside its name in the sidebar and in the editor header.
A connection type can declare credentials that would make a read-only seed a promise nobody keeps: a published default user and password, or, where the engine accepts a connection with no secret, no password at all.
Milvus declares both: its documented default root pair, and no password (providers/milvus.md, section 4.2).
A Milvus seed's read-only mode is a boundary only when the server has authorization enabled, which is not Milvus's default.
Qdrant declares the second: a read-only Qdrant seed with no key is refused, because a Qdrant server without a key accepts any key or none (providers/qdrant.md, section 4.2).
Give a read-only Qdrant seed the server's read-only key or a read-scoped JWT, through a reference such as password: "${QDRANT_READ_ONLY_KEY}".
Both InfluxDB types declare the second too: an InfluxDB 1.x server with authentication off, its default, and an InfluxDB 3 server started with --without-auth accept any credential or none, so a read-only InfluxDB seed with no password or token is refused.
Give a read-only influxdb seed a 1.x user granted READ, or a 2.x read token for the bucket, through a reference such as password: "${INFLUX_READER_PASSWORD}", and a read-only influxdb3 seed a token through a reference such as password: "${INFLUXDB3_TOKEN}".
On a type that does, the seed loader refuses a readOnly: true connection whose credential matches, in two stages: load refuses what the file shows; resolution refuses the rest.
At load, a literal user and password that match, or an absent or empty password, fail the whole file with an error that names the connection and the password field and never repeats the value.
An absent or empty password fails the file whatever the user holds, a reference included.
A ${ENV} or ${vault:...} reference cannot be read at load, so a pair with a reference in either field passes there; once it resolves, the type's provider refuses the connection before anything is dialled if the resolved credential matches or is empty.
A value shaped like ${...} that the resolver does not resolve, such as ${lower}, is a literal and is checked as one.
An empty user with a password of the form user:password is read as that pair, so a token that carries a default credential is refused too.
A Neo4j connection is read-only whatever readOnly says, because its provider refuses every write before it is sent (providers/neo4j.md, section 3.1), so readOnly: true loads on a managed Neo4j seed and states what the connection does.
One seed every role reaches is enough, and mcp: true lets an MCP client list the connection and inspect its schema; run_read_query does not serve Neo4j.
version: "1"
connections:
- id: "graph"
name: "Graph"
type: neo4j
host: neo4j.internal
port: 7687
database: neo4j
user: "reader"
password: "${NEO4J_READER_PASSWORD}"
ssl:
mode: verify-full
caCert: "${NEO4J_CA}"
roles: ["*"]
managed: true
readOnly: true
mcp: trueLeave database out to read the user's home database; another database is another connection.
verify-full and verify-ca both check the host name, and through an SSH tunnel a verifying mode is refused, so use require with verification off there (providers/neo4j.md, section 4.5).
Keep the credentials of a config file you write by hand out of the file.
Use ${ENV_VAR} syntax to reference environment variables:
connections:
- id: "prod-db"
password: "${PROD_DB_PASSWORD}" # Resolved from process.env at runtime
connectionString: "${MONGO_URI}" # Also works for connection strings
user: "${DB_USER}" # Any resolvable field below can use ${} syntaxHow it works:
- Config file is read from disk (YAML/JSON)
${VARIABLE_NAME}patterns are resolved fromprocess.env- If an env var is undefined, that connection is skipped (others continue working)
- Plaintext passwords trigger a warning log (but still work)
- With
SEED_LITERAL_VALUES=true, steps 2 to 4 do not happen: every value is used as written (see Literal values written by a platform)
Resolvable fields: password, connectionString, user, host, database, apiKeyId, apiKeySecret, and the TLS material under ssl: ssl.caCert, ssl.clientCert and ssl.clientKey.
SEED_LITERAL_VALUES=true makes every value of the seed file a literal.
Studio then resolves no ${ENV_VAR} and no ${vault:...} reference in the file, neither when it lists connections nor when it opens one.
A value that looks like a reference is used as written: user: "${DB_USER}" connects as a user literally named ${DB_USER}, an unset variable skips no connection, and Vault is never asked for a seed value.
The plaintext-password warning is not logged either, because every value in such a file is a literal on purpose.
At the first load in literal mode, one info line says so: Seed config read in literal mode (SEED_LITERAL_VALUES): no ${ENV} or ${vault:...} reference is resolved.
true, 1, on and yes turn the mode on, trimmed and in any case.
false, 0, off, no and an empty value leave it off.
Any other value leaves it off as well and logs one warning per process that names the value, so a typo shows in the log instead of passing silently.
The mode exists for a seed file that a platform writes from data its users control, such as the database names, user names and passwords they choose when they create a database.
Resolving a reference in such a file would hand Studio's own environment to whoever controls the field.
A platform user who names a database user ${JWT_SECRET} would have Studio send its session signing secret, as that user name, to a database the platform user runs, and read it back from that database's log.
With the secret, that user could sign a Studio admin session.
Literal mode removes the resolution itself, so no value a platform user controls can make Studio read its environment or its Vault, including a value the platform failed to filter out.
Turn the mode on only for a file in which every value is meant literally: a file written by hand that relies on ${ENV_VAR} or ${vault:...} references stops resolving them.
Keep such a file managed: true, the default, so its passwords stay on the server, and mount it read-only from a directory other users on the host cannot enter.
A field can take its value from HashiCorp Vault instead of an environment variable:
connections:
- id: "prod-db"
password: "${vault:secret/data/prod/postgres#password}"
connectionString: "${vault:secret/data/prod/postgres#dsn}" # Same path, one requestThe part before # is the KV v2 path (<mount>/data/<name>) and the part after it is the key inside the returned data object. Only KV v2 is supported: the server reads GET <VAULT_ADDR>/v1/<path> and takes data.data.<key>, so a v1-shaped path (secret/prod/postgres) is refused with a message naming the shape it expects rather than read as an empty secret.
Quote the value. YAML reads an unquoted # as the start of a comment, so password: ${vault:secret/data/prod/postgres#password} sets the password to the literal text ${vault:secret/data/prod/postgres and drops the key. The quotes above are not optional.
Whole-value match only, exactly like ${ENV_VAR}: no partial interpolation, no concatenation, and the same resolvable fields (password, connectionString, user, host, database, apiKeyId, apiKeySecret, ssl.caCert, ssl.clientCert, ssl.clientKey).
A reference with no #key fails when the connection is opened.
A Vault reference is read lazily, one connection at a time:
- Listing connections (
GET /api/connections/managed) makes zero Vault requests. The reference is handed back unresolved, and for amanaged: falseconnection it is the reference — not a secret — that reaches the browser. - Opening a connection reads the referenced path once, then caches the result per path for
VAULT_CACHE_TTL_MS(default 60000). A second connection to the same path within that TTL makes no request. - A failure — Vault unreachable, the path or key missing, the token refused — fails that one connection with an explicit error. There is no fallback to an empty password, no retry loop and no fallback to an environment variable, and the failure never affects the boot or another connection.
- The resolved value is never logged, never written to an error message and never reaches the audit log. The reference string may be logged; it is not a secret.
${ENV_VAR} resolution is unchanged: synchronous on the list path, and a dictionary lookup. Only ${vault:...} is deferred.
Vault credentials come from the environment, never from the seed file — a seed file that could carry a token would make the product hold a new secret, which is the thing this feature exists to avoid.
| Variable | Default | Description |
|---|---|---|
VAULT_ADDR |
— | Vault address with scheme and port (http://vault.vault.svc:8200). Required for the scheme to work |
VAULT_TOKEN |
— | Static token. Takes precedence over VAULT_ROLE when both are set |
VAULT_ROLE |
— | Kubernetes auth role, used when VAULT_TOKEN is unset. The login token's lease is tracked and refreshed before it lapses |
VAULT_K8S_TOKEN_PATH |
/var/run/secrets/kubernetes.io/serviceaccount/token |
Projected service account token presented to the Kubernetes login |
VAULT_K8S_AUTH_PATH |
kubernetes |
Mount path of the Kubernetes auth method; the login goes to <VAULT_ADDR>/v1/auth/<path>/login. Set it when the method is mounted elsewhere, such as one mount per cluster on a shared Vault. Leading and trailing slashes are ignored |
VAULT_NAMESPACE |
— | Vault Enterprise namespace, sent as X-Vault-Namespace when set |
VAULT_CACHE_TTL_MS |
60000 |
How long a read secret is cached |
Every one of these is optional. With none of them set, and no ${vault:...} reference in the seed file, the application boots and behaves exactly as it does without this feature: no startup probe, no reachability check and no warning about Vault. A reference used without VAULT_ADDR fails with a message naming the missing variable.
A rotated secret becomes visible within VAULT_CACHE_TTL_MS (the secret cache) plus SEED_CACHE_TTL_MS (the parsed seed file cache) — 120 seconds with both defaults. No restart and no seed file change: the reference stays the same and only the value behind it moves.
docker-compose.vault-demo.yml at the repository root starts Studio, PostgreSQL and a dev-mode Vault, writes the database password into Vault and the seed file into a volume Studio mounts. It pulls the published image, so it needs no source checkout:
docker compose -f docker-compose.vault-demo.yml upThat Vault is dev mode: in memory, unsealed, root token, no TLS, no policies and no audit device. It is for development and demonstration only and is not a production configuration — a real deployment should follow HashiCorp's production hardening guide.
To see the payoff rather than a connection that merely works, rotate the secret and watch the connection pick it up:
# 1. Change the password in Vault and in PostgreSQL.
docker compose -f docker-compose.vault-demo.yml exec -T vault \
vault kv put secret/prod/postgres password=rotated
docker compose -f docker-compose.vault-demo.yml exec -T postgres \
psql -U demo -d demo -c "ALTER USER demo WITH PASSWORD 'rotated';"
# 2. Wait out VAULT_CACHE_TTL_MS (10s in that file), then open the
# "Postgres (password from Vault)" connection again. It authenticates with
# the new password, and no container was restarted.In a real deployment the policy is one read on one path — path "secret/data/prod/postgres" { capabilities = ["read"] } — and the Kubernetes auth role binds that policy to the pod's service account. Prefer VAULT_ROLE to VAULT_TOKEN, so the credential is a lease rather than something long-lived.
| Deployment | How to provide credentials |
|---|---|
| Docker | -e DB_PASSWORD=secret |
| Docker Compose | environment: block or .env file |
| Kubernetes | Secret → extraEnvFrom in Helm values |
| Vault | ${vault:...} references resolved at connect time, or External Secrets Operator → K8s Secret → extraEnvFrom |
| AWS SSM and other secret managers | External Secrets Operator → K8s Secret → extraEnvFrom (no direct resolver) |
# Create a K8s Secret with credentials
apiVersion: v1
kind: Secret
metadata:
name: seed-db-credentials
type: Opaque
stringData:
PG_PASSWORD: "my-secret-password"
MYSQL_PASSWORD: "another-secret"
---
# Reference in Helm values
extraEnvFrom:
- secretRef:
name: seed-db-credentialsEach connection has a roles field that controls which users can see it:
| Config | Who sees it |
|---|---|
roles: ["*"] |
All authenticated users |
roles: ["admin"] |
Admin users only |
roles: ["user"] |
Regular users only |
roles: ["admin", "user"] |
Both (same as ["*"]) |
Roles are matched against the JWT session's role field. The role is extracted server-side from the JWT token — never from client input.
Current limitation: The system supports admin and user roles only (matching the JWT role claim). Custom roles (e.g., data-team, backend) are planned for a future release with expanded OIDC role claim support.
User logs in → JWT contains { role: "user" }
↓
GET /api/connections/managed
↓
Server reads config → filters by role
↓
User sees only connections where roles includes "user" or "*"
- Connection appears with a lock icon in the sidebar
- Users cannot edit or delete it
- Credentials are never sent to the client — server resolves them at query time
- If admin updates the config (e.g., password rotation), all users get the new credentials automatically
- Best for: production databases, shared resources
- On first load, the connection is copied to the user's local storage with credentials
- User can edit or delete their copy
- Once copied, the connection belongs to the user — admin changes to the seed config won't affect existing copies
- If the user deletes their copy, the seed ID is recorded in a local "dismissed" list and the connection is not re-imported on subsequent loads (see Dismissed Seeds below)
- Best for: development databases, sandbox environments
| Behavior | managed: true |
managed: false |
|---|---|---|
| UI edit/delete | Locked | Allowed |
| Credentials on client | Never | Copied once |
| Password rotation | Automatic | User must re-import |
| Admin removes from config | Disappears for all | User copy remains |
| Server-side credential resolution | Yes | No (user has local copy) |
| User deletes their copy | N/A (locked) | Dismissed permanently — will not reappear |
Deleting a managed: false connection from the sidebar does not simply remove it — the client records the seed's id in a local dismissed_seeds list (synced through the same write-through storage as connections). On every subsequent load, dismissed_seeds is checked before re-importing managed: false connections from /api/connections/managed, so a deleted seed connection stays gone even after the admin's config is untouched. There is currently no UI to un-dismiss a seed; the only way to bring it back is to clear the dismissed_seeds entry from local storage (see Troubleshooting).
ALLOW_CUSTOM_CONNECTIONS decides whether a signed-in user may open a connection of their own, one that is not in this file.
It is on when unset.
false, 0, off or no, trimmed and in any letter case, switch it off; true, 1, on and yes keep it on.
One pair of matching surrounding quotes, single or double, is stripped first, since an env file can keep them, so "false" switches it off.
Any other value fails closed: it switches custom connections off and logs one error naming the value and the accepted values, so a typo never leaves them open.
Switch it off where Studio shares a network with services its users must not reach, such as a platform's overlay network or a cluster namespace: with custom connections on, any account that can sign in can connect to any host and port that network reaches.
The switch limits the connections Studio itself opens, not what a seeded engine can reach on its own, for example through Postgres dblink or postgres_fdw, ClickHouse remote() or url(), MySQL FEDERATED tables or the brokers a Kafka cluster advertises, so grant a seed's account only what its users may reach from that engine.
Switched off, every route that builds a database provider refuses a connection the request supplies, before any provider is built, with 403 and { "error": "Custom connections are disabled on this server", "code": "CUSTOM_CONNECTIONS_DISABLED", "statusCode": 403 }.
The code tells this refusal apart from the role check's, which is also a 403 and carries AUTH_ERROR.
The refusal is made in resolveConnection() (src/lib/seed/resolve-connection.ts), which every database route resolves its connection through, and POST /api/admin/fleet-health resolves each item through it too and reports a refused one as that item's error.
The agent runtime and the MCP endpoint only ever open a seed by its id, so nothing changes there.
| Connection | With custom connections off |
|---|---|
managed: true seed, a connection Platform discovery (CapRover) found included |
Listed, and opened by its id as before |
managed: false seed |
Listed and opened. Its editable copy keeps the id seed:<id>, and the server resolves that id from this file and ignores the copy's own fields, exactly as it does with the switch on |
| Built-in samples | Unmanaged seeds, so the row above applies |
| A connection the user created, or a duplicate of any connection | Hidden in the editor and refused by the server |
The editor reads the switch once per page load from GET /api/connections/policy and withholds every control that creates or repoints a connection of the user's own: New connection, Add Connection, the command palette's New Connection, Edit and Duplicate.
Delete stays; on an unmanaged seed's copy it dismisses the seed, as it always does, and deleting the open connection selects the first connection listed.
An open tab's refresh of the managed list (see In an open tab) follows the same rule: when it withdraws the open connection, or none is open, the connection it makes active is the first one listed, never a hidden one.
The admin pages, the monitoring page and the schema diff list connections under the same rule.
The user's own connections stay in their storage while hidden, and reappear when the switch is turned back on, each in the place it held in the saved order, even if the seeds around it were reordered meanwhile.
The config file is cached in memory with a TTL (default 60 seconds). When the file changes:
- Next API request after TTL expires triggers a re-read
- New connections appear, removed connections disappear
- Updated credentials take effect immediately (for
managed: true) - No restart required
On a DuckDB seed, a roles change that moves the seed between admins only and a non-admin role changes the file-access posture its one handle opens under (section 3.16 of docs/providers/duckdb.md).
The first request after the re-read then closes the open handle and opens a new one: a statement still running on the old handle does not complete, and its session state (temporary tables, SET values) is lost.
It happens once per such edit.
# Default: 60 seconds
SEED_CACHE_TTL_MS=60000
# Faster refresh (5 seconds) — useful during development
SEED_CACHE_TTL_MS=5000
# Slower refresh (5 minutes) — production with infrequent changes
SEED_CACHE_TTL_MS=300000In Kubernetes, ConfigMap updates propagate in ~60-120s (kubelet sync period). Combined with the cache TTL, expect ~2-3 minutes for changes to take effect.
The same TTL governs the Platform discovery (CapRover) export: Studio re-reads that file at most once per SEED_CACHE_TTL_MS while its copy is fresh, and at most every 5 seconds (or once per SEED_CACHE_TTL_MS when that is shorter) once that copy has turned stale; the CapRover template sets the TTL to 5 seconds.
An open tab refetches the managed list every max(SEED_CACHE_TTL_MS, 5000) milliseconds, at most 60 seconds, while it is visible, and on focus, so a change to the seed file or to the export reaches it without a reload.
With the default of 60000 an open tab refreshes once a minute; the auto-connect template sets 5000, so its tabs refresh every 5 seconds.
A change to the seed file therefore reaches an open tab after at most SEED_CACHE_TTL_MS plus one refresh interval, about two minutes with the defaults; with a TTL above 60000 the tab still asks every 60 seconds, and the server answers most of those reads from its cache.
A refresh merges the answer exactly as a page load does: a managed: false seed keeps the editable copy the browser already holds, an unmanaged seed the user deleted stays deleted, and an answer that lists no connections withdraws every managed one; In an open tab says which connection stays open.
A refresh that fails changes nothing and shows nothing, and the next one tries again; the one exception is a session that has ended, which the server answers with 401 and the code AUTH_REQUIRED, and which sends the tab to the sign-in page, as any other request of the editor does.
A link can open the editor on one connection: /?connection=<id>, under BASE_PATH when one is set, where <id> is the connection's full id in the browser, seed:<seed id> for a seed connection.
The colon may be URL-encoded, as in /?connection=seed%3Aprod-db.
The editor reads the parameter once its connection list has loaded, opens that connection instead of the one it would open by default, and removes the parameter from the address bar, so a reload or a copied address does not repeat it.
Opened without a session, the link goes to the sign-in page, which returns to it after the user signs in.
When the list the user sees does not hold that id yet, the editor keeps its default selection and opens the connection at the first refresh of the managed list that lists it (see Hot Reload), because a seed the server has not re-read yet is listed a little later.
A connection the user chooses while the link is pending cancels it, so a later refresh never switches away from it.
If the first refresh one seed-cache lifetime (SEED_CACHE_TTL_MS) after the load does not list it either, the editor shows a notice that reads the same whatever the cause: an id that does not exist, a seed whose roles leave the user out, or a connection of the user's own while Custom Connections are switched off.
The notice therefore does not reveal which seed ids exist for other roles.
When the managed list cannot be loaded at all, the notice says so instead, and the link opens only a connection the browser holds itself.
The parameter belongs to the standalone editor page: the embeddable StudioWorkspace component takes its connections and its selection from its host and ignores the address bar.
docker run \
-v ./seed-connections.yaml:/app/config/seed-connections.yaml:ro \
-e SEED_CONFIG_PATH=/app/config/seed-connections.yaml \
-e PG_PASSWORD=secret \
-e JWT_SECRET=your-32-char-jwt-secret-here!! \
-p 3000:3000 \
ghcr.io/libredb/libredb-studio:latestservices:
libredb:
image: ghcr.io/libredb/libredb-studio:latest
ports:
- "3000:3000"
volumes:
- ./seed-connections.yaml:/app/config/seed-connections.yaml:ro
environment:
SEED_CONFIG_PATH: /app/config/seed-connections.yaml
JWT_SECRET: your-32-char-jwt-secret-here!!
PG_PASSWORD: ${PG_PASSWORD}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
env_file:
- .env # Store credentials hereOption A — Inline config in values.yaml:
seedConnections:
enabled: true
config:
version: "1"
defaults:
managed: true
environment: production
connections:
- id: "prod-analytics"
name: "Production Analytics"
type: postgres
host: analytics-db.internal
port: 5432
database: analytics
user: readonly
password: "${ANALYTICS_DB_PASSWORD}"
roles: ["admin"]
color: "#10B981"
- id: "staging-api"
name: "Staging API DB"
type: mysql
host: staging-mysql.internal
password: "${STAGING_DB_PASSWORD}"
roles: ["*"]
managed: false
environment: staging
extraEnvFrom:
- secretRef:
name: seed-db-credentialsOption B — External ConfigMap:
seedConnections:
enabled: true
existingConfigMap: "my-seed-connections" # Pre-created ConfigMap
configMapKey: "connections.yaml" # Key within the ConfigMap
extraEnvFrom:
- secretRef:
name: seed-db-credentialsStudio can connect itself to the databases a CapRover server runs, with no seed file and no typed password.
A companion process, the exporter, reads the Docker socket and writes a file, and Studio turns every database it recognises in that file into a managed connection for the admin role.
It is off unless SEED_DISCOVERY_PATH is set.
The CapRover template that sets it up is deploy/caprover/libredb-studio-autoconnect.yml, needs Studio 0.18.0 or later, and is described in deploy/caprover/README.md, which also covers adding discovery to an existing install.
What the Docker socket costs is recorded in docs/SECURITY.md.
docker/discover.mjs ships in every image as /usr/local/lib/libredb-studio/discover.mjs, owned by root, so the web process cannot replace it.
Run it from the same image version as Studio, and when you upgrade, upgrade Studio first: a Studio older than the exporter can refuse its export with invalid_export, and then it withdraws every discovered connection.
It has no dependency and no listening port, and it sends GET requests only, to two Docker Engine API paths pinned to v1.44: the network list filtered by name, and /v1.44/services?status=true.
It must run as root with the socket mounted, so it has to replace the image entrypoint, because docker-entrypoint.sh drops every command it starts to uid 1001.
A CapRover one-click command replaces the entrypoint.
With Docker Compose or docker run, set the entrypoint instead, and give Studio the same volume and SEED_DISCOVERY_PATH (the fragment shows only the discovery settings):
services:
libredb:
image: ghcr.io/libredb/libredb-studio:0.18.0
environment:
SEED_DISCOVERY_PATH: /app/discovery/services.json
volumes:
- discovered:/app/discovery
networks:
- default
- captain-overlay-network
discovery:
image: ghcr.io/libredb/libredb-studio:0.18.0
entrypoint: ["node", "/usr/local/lib/libredb-studio/discover.mjs"]
environment:
# Must name the overlay network the database services are on.
DISCOVERY_NETWORK: captain-overlay-network
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- discovered:/app/discovery
volumes:
discovered:
networks:
captain-overlay-network:
external: trueStudio must also join the network DISCOVERY_NETWORK names, because the discovered host names resolve only there: in Compose as an external network, as above, and with docker run through --network; CapRover creates its network attachable.
It lists Swarm services, so it needs a swarm manager: a node that sees the network but is not a manager reports swarm_unavailable, and an engine with no network of that name reports network_not_found first, because it asks for the network before the services.
Before its first scan it checks that the directory of DISCOVERY_OUTPUT is owned by its own uid and is not writable by group or others, and it exits non-zero otherwise.
A directory the web process could write would let it plant a link for the root process to follow.
For that reason the images never create /app/discovery: a named volume mounted there starts as root-owned with mode 0755.
Every DISCOVERY_INTERVAL_MS (10 seconds by default) it:
- picks the network whose name equals
DISCOVERY_NETWORKexactly, because Docker's name filter matches substrings; - keeps the services attached to that network, except CapRover's own (
captain-prefix), Studio's own image, and the app names listed inDISCOVERY_EXCLUDE; - records for each the service id and name, the app name (the name without
srv-captain--), the host alias on that network (srv-captain--<app>, or the service name for legacy and alias-less apps), the image, the task counts and the allow-listed environment keys below; - records the app names it left out because of
DISCOVERY_EXCLUDEin the export'sexcludedlist, sorted and at most 500, with nothing else about those apps; - writes the export atomically: a temporary file opened with
O_CREAT | O_EXCL | O_NOFOLLOWand mode 0600, given toDISCOVERY_FILE_UID:DISCOVERY_FILE_GID(1001:1001 by default), synced, then renamed overDISCOVERY_OUTPUT.
The temporary file always has the same name, .<file name>.tmp in the directory of DISCOVERY_OUTPUT (.services.json.tmp by default), so two exporters writing to the same volume would race.
Run one exporter per shared volume.
A service whose image or app name is empty, or whose name, host or image is longer than its bound, is left out of the export and counted in the scan's dropped values.
On a Docker error it keeps the services, the excluded list and the generatedAt of its last good scan, and updates only the status and checkedAt.
When no network is named exactly DISCOVERY_NETWORK, it also writes network as null.
After a restart it starts from empty lists, with generatedAt null until its first good scan.
It never logs a value from a service's environment.
Its variables are listed in .env.example under "Platform Discovery Exporter".
DISCOVERY_INTERVAL_MS takes an integer from 2000 to 2147483647, the longest delay Node's timers honour, and DISCOVERY_FILE_UID and DISCOVERY_FILE_GID take an integer from 0 to 4294967294.
A value outside its range, or one that is not a whole number, stops the exporter at start, before its first scan, with <NAME> must be an integer of at least <minimum>, got "<value>" or <NAME> must be an integer of at most <maximum>, got "<value>" in its log.
Every service on the network that is not excluded is listed with its name, host, image and task counts, database or not; only the environment is filtered.
The environment allow-list is exactly these ten keys, case-sensitive: POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, MYSQL_ROOT_PASSWORD, MONGO_INITDB_ROOT_USERNAME, MONGO_INITDB_ROOT_PASSWORD, REDIS_PASSWORD, VALKEY_EXTRA_FLAGS, KEYDB_PASSWORD, DFLY_requirepass.
DFLY_requirepass is mixed case because that is Dragonfly's own spelling.
The list lives once, as ENV_ALLOW_LIST in docker/discover.mjs, and a unit test fails when a key Studio reads is missing from it.
Every service in the export also carries requirepassEnv, which only the Redis detection and mapping read.
It is the name of the allow-listed variable that a --requirepass $NAME in the service's command refers to, or null when there is none, and never the command itself.
Studio parses the image repository (digest, tag, registry host and a leading library/ removed) and matches it:
| Repository | Studio type | Label |
|---|---|---|
postgres, postgis/postgis, timescale/timescaledb, timescale/timescaledb-ha, pgvector/pgvector |
postgres |
PostgreSQL |
mysql |
mysql |
MySQL |
mariadb |
mysql |
MariaDB |
percona, percona/percona-server |
mysql |
Percona |
mongo |
mongodb |
MongoDB |
redis |
redis |
Redis |
valkey/valkey |
redis |
Valkey |
eqalpha/keydb |
redis |
KeyDB |
dragonflydb/dragonfly |
redis |
Dragonfly |
An image CapRover built itself (its last path segment starts with img-captain-, as the mariadb and keydb templates produce) is matched by its environment instead:
| Key present | Studio type | Label |
|---|---|---|
MYSQL_ROOT_PASSWORD |
mysql |
MySQL-compatible |
KEYDB_PASSWORD |
redis |
KeyDB |
POSTGRES_PASSWORD |
postgres |
PostgreSQL |
MONGO_INITDB_ROOT_PASSWORD |
mongodb |
MongoDB |
REDIS_PASSWORD, with a requirepassEnv |
redis |
Redis |
Many apps CapRover builds carry these keys without being a database, so an environment match is listed only when a TCP connection to its port opens within 1 second. A successful probe is cached for 30 seconds, a failed one is not cached, and at most 16 probes run at once. A repository match is never probed: a stopped database stays listed and shows its connection error when opened. Any other image is ignored and is not reported as skipped.
| Type | Port | User | Password | Database | Extra |
|---|---|---|---|---|---|
postgres |
5432 | POSTGRES_USER, else postgres |
POSTGRES_PASSWORD, required |
POSTGRES_DB, else the user |
none |
mysql |
3306 | root |
MYSQL_ROOT_PASSWORD, required |
mysql |
none |
mongodb |
27017 | MONGO_INITDB_ROOT_USERNAME, required |
MONGO_INITDB_ROOT_PASSWORD, required |
none | authSource: admin |
redis (Redis) |
6379 | none | the variable requirepassEnv names, else none |
0 |
none |
redis (Valkey) |
6379 | none | the token after --requirepass in VALKEY_EXTRA_FLAGS, as written, quotes included, else none |
0 |
none |
redis (KeyDB) |
6379 | none | KEYDB_PASSWORD, else none |
0 |
none |
redis (Dragonfly) |
6379 | none | DFLY_requirepass, else none |
0 |
none |
The host is always the exporter's host for that service and must match ^[a-z0-9]([a-z0-9-]{0,251}[a-z0-9])?$; a service whose host does not is skipped with a reason.
A missing required field skips the service with a reason.
The official redis image ignores REDIS_PASSWORD on its own, and the redis one-click template makes it effective through --requirepass $REDIS_PASSWORD in its command, which is why the password comes through requirepassEnv.
The official valkey image passes VALKEY_EXTRA_FLAGS to the server unquoted, so quote characters around the --requirepass value are part of the password it enforces, and a value with a blank in it cannot be set at all; Studio therefore takes the token as written.
Credentials are a snapshot of the environment CapRover set: a password changed later inside the database makes the connection fail with an authentication error.
- id
caprover-<app name>, name<app name> (<label>), groupCapRover; ssl: { mode: "disable" }, so an app whose name contains a cloud provider's name is not mistaken for a managed cloud host;managed: true,roles: ["admin"], nomcp, noreadOnlyand noconnectionString, all set in code and never read from the export file.
The values are used as literal text.
A discovered value that looks like a reference, ${NAME} or ${vault:...}, is sent to the database as written, and Studio never resolves it from its own environment or from Vault, neither when listing connections nor when one is opened.
Any app on the CapRover network can carry these keys, and resolving a reference in them would hand Studio's own secrets to that app as a password.
The marker that does this is set by the discovery source, not derived from the id, so a seed-file connection whose id starts with caprover- keeps the usual resolution, unless SEED_LITERAL_VALUES makes every seed-file value a literal too (Literal values written by a platform).
GET /api/connections/managed strips the marker, so its response shape is unchanged.
- The managed list holds the seed-file connections first, then the discovered ones, then the built-in samples.
- A discovered id that equals a seed-file id is dropped and reported as skipped, with the reason "id taken by the seed file".
- Two services can map to the same id, for example
srv-captain--fooand a hand-made service namedfoo, which both becomecaprover-foo; the first one Studio accepts, in the order of the export, is listed, and every later one is reported as skipped, with the reason "id taken by another discovered service". - An app named in the export's
excludedlist (the "Apps to skip" field,DISCOVERY_EXCLUDE) is never listed and, while Studio serves fresh data, is reported as skipped, with the reason "listed in Apps to skip". - Each discovered connection is validated on its own with the seed schema; an invalid one is skipped with its reason and the others are listed.
- Discovered connections go through the same role filter as file seeds, so a standard user receives none of them, and naming a discovered id answers the same 404 as an unknown id.
A standard user cannot read the export file through DuckDB either: a non-admin DuckDB handle opens with statement-level file access closed (section 3.16 of
docs/providers/duckdb.md, control 3.17 indocs/SECURITY.md), and the export is JSON, which DuckDB refuses to open as a database. An admin's own DuckDB connection can still read it (section 14.3 ofdocs/providers/duckdb.md), so the admin login holds every discovered database's password. - No discovery failure reaches the managed list: every error is caught inside the source, so file seeds and samples are listed as before.
Studio re-reads the export at most once per SEED_CACHE_TTL_MS while its copy is fresh, and concurrent requests share one read.
The age of generatedAt is checked on every request against the cached export.
A cached copy that has turned stale is re-read, at most every 5 seconds (or once per SEED_CACHE_TTL_MS when that is shorter), so the discovered connections are withdrawn once the file itself is older than SEED_DISCOVERY_MAX_AGE_MS, without waiting for the TTL, and a long SEED_CACHE_TTL_MS never withdraws the connections of an exporter that keeps writing.
For that, the exporter's DISCOVERY_INTERVAL_MS must stay well below SEED_DISCOVERY_MAX_AGE_MS; the defaults are 10 and 60 seconds.
A generatedAt ahead of Studio's own clock counts as fresh.
| Condition | State | Discovered connections |
|---|---|---|
SEED_DISCOVERY_PATH unset |
off (discovery: null in the admin status) |
none |
| Export file missing | waiting |
none |
| File unreadable, over 2 MiB, not JSON or not the expected shape | error, code invalid_export |
none |
generatedAt null and the exporter reports an error |
error, with the exporter's code and message |
none |
generatedAt older than SEED_DISCOVERY_MAX_AGE_MS |
stale, with the exporter's error if any |
none, withdrawn |
generatedAt fresh and the exporter reports an error |
error, with the exporter's code and message |
the last good scan |
generatedAt fresh and status ok |
ok |
listed |
The exporter's codes are socket_unavailable, swarm_unavailable, api_version, network_not_found, docker_error and limit_exceeded.
When the file is still missing twice SEED_DISCOVERY_MAX_AGE_MS after Studio first looked, the waiting message says that the discovery app may not be running or may run on another node than Studio.
Studio's own messages about an unreadable or invalid file never quote the file's content.
The skipped list holds one entry per discovered service Studio refused, with its reason: a missing required variable, a host name Studio refuses, "the connection is not valid" followed by each field the seed schema refused and its issue code, such as id (too_big) for a hand-made service whose name is over 55 characters, "id taken by the seed file", "id taken by another discovered service", or "did not answer on port" followed by the port number, for an environment-matched candidate.
While Studio serves discovered data, that is in the state ok and in the state error with a fresh last good scan, the list also holds one entry per app name in the export's excluded list, after the others, with the reason "listed in Apps to skip".
An image that matches no engine is not a skipped entry, and neither is an app that is not on the CapRover network.
GET /api/admin/discovery (admin only, docs/API_DOCS.md) and a card on the admin Overview page (/admin/overview) report the state and its message, the last successful scan, the exporter's error, the connected databases and the skipped apps with their reasons.
Neither carries a host name beyond app names, nor any environment value.
GET /api/connections/managed carries no status, because every role can read it.
While the request reached Studio over plain HTTP, or AUTH_COOKIE_SECURE is false, off or 0 in any letter case, and at least one database is connected, the card warns:
The Studio session cookie can travel over plain HTTP, and it unlocks every discovered database. Enable HTTPS and Force HTTPS for this app in CapRover, then set AUTH_COOKIE_SECURE to true and restart.
After its first successful load, an open tab refetches the managed list every max(SEED_CACHE_TTL_MS, 5000) milliseconds, at most 60 seconds, while the tab is visible, and at once when the window regains focus or the tab becomes visible again.
The 5000 is the floor NEXT_PUBLIC_MANAGED_REFRESH_FLOOR_MS, which is inlined at build time (see Environment Variables).
An install that keeps the default SEED_CACHE_TTL_MS of 60000 therefore refreshes an open tab once a minute, while the auto-connect template sets 5000, so its tabs refresh every 5 seconds.
With the template's values a database appears or disappears in an open tab within about 20 seconds of the change in CapRover: the exporter scans every 10 seconds, Studio re-reads the export at most every 5, and the tab refreshes every 5.
The template's end text says about 30 seconds, which also covers a database that is still starting: an image CapRover built itself, as the MariaDB and KeyDB templates produce, is listed only once its database accepts connections.
The active connection stays open while its id is still listed; when it is withdrawn, the first remaining connection the sidebar lists becomes active and Studio says so once.
With ALLOW_CUSTOM_CONNECTIONS off that is never one of the user's own connections, which stay hidden (see Custom Connections).
Pages that use the lighter connection list (the admin Overview and Operations tabs, Schema Diff and Monitoring) load it once and need a reload.
| Scenario | Behavior |
|---|---|
| Config file not found | App runs normally, no seed connections. The warning is logged once for that path, and again only after the file has appeared and gone, so a short SEED_CACHE_TTL_MS does not repeat it on every re-read. |
| Invalid YAML/JSON | Endpoint returns 500. Error logged with details. |
| Invalid config (Zod validation fails) | Endpoint returns a generic 500. Validation errors are logged server-side, not returned in the response body. |
mcp that is not a boolean, or mcp in defaults |
The whole file fails like any invalid config; every MCP tool answers that the connection configuration could not be read |
readOnly: true on a connection whose type does not enforce it, on a connection whose effective managed is false, or readOnly in defaults |
The whole file fails like any invalid config, and the error names the connection, the field and the reason |
readOnly: true on a connection whose literal credential matches a default its type declares, or with no password where its type declares it accepts none |
The whole file fails like any invalid config, and the error names the connection and password, never the value; a ${ENV} or ${vault:...} reference is checked once it resolves, and the connection is refused before anything is dialled |
mcp: true on an etcd or Oxia connection |
The whole file fails like any invalid config, and the error names mcp and the type, etcd or oxia |
Unrecognized version |
Endpoint returns 500. Future versions require code update. |
${ENV_VAR} not defined |
That connection is skipped. Others work normally. Error logged. |
${vault:...} reference, Vault unreachable / path or key missing / token refused |
The connection fails with an explicit error when it is opened. Listing connections is unaffected, and so is every other connection. |
${vault:...} reference with no #key, or a v1-shaped path |
Fails with an error naming the expected KV v2 shape. The value is never treated as a literal. |
${vault:...} reference with VAULT_ADDR unset |
Fails with a message naming the missing variable. |
A value written as ${ENV_VAR} or ${vault:...} while SEED_LITERAL_VALUES=true |
Used as written: no variable is read, the connection is not skipped and Vault is not asked, so a value that is not the real credential fails like any wrong credential when the connection is opened. |
SEED_LITERAL_VALUES set to a value it does not recognize |
References stay resolved, as with the mode off, and one warning per process names the value. |
| User role doesn't match any connection | Empty list returned. Normal behavior. |
| Seed connection not found at query time | 404 response. |
| User doesn't have access to seed connection | 403 response. |
SEED_DISCOVERY_PATH set and the export file missing |
No discovered connections and discovery state waiting, while file seeds and samples are unaffected. |
| Export file unreadable, over 2 MiB, not JSON or not the expected shape | No discovered connections and state error with code invalid_export, whose message never quotes the file. |
Export older than SEED_DISCOVERY_MAX_AGE_MS (the exporter stopped or was deleted) |
Discovered connections withdrawn; state stale. |
| The exporter reports a Docker error (socket, swarm, network, API) | The last good scan stays listed while it is fresh; state error with the exporter's code. |
| One discovered service maps to an invalid connection, or its id is taken by the seed file or by another discovered service | That service is skipped with a reason in the admin status; the others are listed. |
| An environment-matched candidate does not accept a TCP connection | Not listed and reported as skipped; listed on a later read once it accepts connections. |
An app is named in "Apps to skip" (DISCOVERY_EXCLUDE of the exporter) |
Never listed, and reported as skipped with the reason "listed in Apps to skip" while the export is fresh. |
| An unexpected exception inside the discovery source | No discovered connections and state error with code discovery_failed; file seeds and samples are unaffected. |
Design principle: One broken connection never breaks the others. Each connection is resolved independently.
managed: trueconnections: credentials never reach the client. The API strips every fieldsrc/lib/storage/connection-secrets.tsclassifies as secret, which on a seed meanspassword,connectionString, the ElasticsearchapiKeyIdandapiKeySecretpair, andssl.clientKey. Certificates (ssl.caCert,ssl.clientCert) are public and still reach it. Server resolves credentials at query execution time.- That covers what the API returns, not what an engine answers a statement with. A managed Redis seed that authenticates with
requirepassanswersCONFIG GET requirepasswith the password, so give a managed seed a least-privilege credential, for Redis an ACL user without+config. - Config file should be mounted read-only (
:roin Docker,readOnly: truein Kubernetes). - Use
${ENV_VAR}for the secrets of a file you write by hand. Plaintext passwords trigger a warning log. - Read a file that a platform writes from data its users control with
SEED_LITERAL_VALUES=true, so that no value in it is resolved from Studio's environment or from Vault (Literal values written by a platform).
- User role is extracted from the JWT session server-side — never from client headers or request params.
- Every database operation (query, schema, health check, etc.) goes through
resolveConnection()which verifies role access before returning credentials. - Role check failures return 403 with no credential information.
- While
ALLOW_CUSTOM_CONNECTIONSis off,resolveConnection()also refuses a connection supplied in the request with 403, so only seeds reach a provider (see Custom Connections).
resolveConnection() (src/lib/seed/resolve-connection.ts) logs every seed-connection lookup through the structured logger:
- A successful resolution logs at
debuglevel withroute,connectionId, anduser. - A denied lookup (connection exists but the caller's role isn't in
roles) logs atwarnlevel withroute,connectionId,user, androle, before the 403 is returned.
This is the standard application logger (src/lib/logger.ts), not a persisted audit-log entry — there is currently no dedicated managed_connection audit-ring-buffer event wired up for seed connections, despite that event type existing in src/lib/audit.ts's AuditEventType union.
| Variable | Default | Description |
|---|---|---|
SEED_CONFIG_PATH |
/app/config/seed-connections.yaml |
Path to config file |
SEED_CACHE_TTL_MS |
60000 |
Cache TTL in milliseconds |
ALLOW_CUSTOM_CONNECTIONS |
true |
false, 0, off or no refuses every connection that is not a seed, and so does any unrecognised value; see Custom Connections |
SEED_LITERAL_VALUES |
unset | true, 1, on or yes (trimmed, any case) reads every seed value as written: no ${ENV_VAR} or ${vault:...} reference is resolved and the plaintext-password warning is not logged. false, 0, off, no or empty keep references resolved, and so does any other value, with one warning (Literal values written by a platform) |
SEED_DISCOVERY_PATH |
unset (off) | Path of the discovery export file inside the Studio container; see Platform discovery (CapRover) |
SEED_DISCOVERY_MAX_AGE_MS |
60000 |
Age of the export's generatedAt after which discovered connections are withdrawn; keep it well above the exporter's DISCOVERY_INTERVAL_MS (10000 by default) |
NEXT_PUBLIC_MANAGED_REFRESH_FLOOR_MS |
5000 |
Shortest interval of an open tab's managed-list refresh, inlined at build time, so it only affects source builds and tests, not packaged artifacts |
The VAULT_* variables that back ${vault:...} references are listed under Vault Environment Variables.
The exporter's own variables (DISCOVERY_* and DOCKER_SOCKET) are read by docker/discover.mjs only, never by the Studio server, and are listed in .env.example.
NEXT_PUBLIC_MANAGED_REFRESH_FLOOR_MS is inlined at build time like NEXT_PUBLIC_MANAGED_POLL_MS (see Built-in Sample Connections), so it is not in .env.example and setting it on a running container has no effect.
These are unrelated to the embedded sample connection described below, which uses its own LIBREDB_EMBEDDED_SAMPLE / LIBREDB_EMBEDDED_SAMPLE_PATH variables.
Standalone deployments also get automatic, code-defined seed connections (none of this has any effect when embedded in libredb-platform — it is not part of the published @libredb/studio package surface):
- Sample (LibreDB) — on first startup,
src/lib/seed/libredb-sample.tscreates an embedded LibreDB file (default<data dir>/sample.libredb, alongside the SQLite storage DB) and seeds it with example data — auserstable, anarticlesdocument collection, and a couple of KV entries — one per LibreDB lens. Seeded synchronously during boot. - Sample (Employees) —
src/lib/seed/sqlite-sample.tscopies the vendored employees SQLite database (seed-assets/sqlite/employee.db, from bytebase/employee-sample-databasedataset_small, originally datacharmer/test_db; seeseed-assets/sqlite/ATTRIBUTION.md) to<data dir>/sample-employees.db. Seeded asynchronously and fail-open: boot never waits for the copy; while it is in flightGET /api/connections/managedlists the seed id inpendingSeedsand the client polls (1s, max 30 attempts; the interval constant is inlined at build time —NEXT_PUBLIC_MANAGED_POLL_MSonly affects source builds and tests, not packaged artifacts) so the connection appears without a page refresh.
getManagedConnections() appends each sample to the managed-connections list once its file exists (managed: false). LibreDB is offered to all roles (roles: ["*"]); SQLite is offered only to administrators (roles: ["admin"]) because its adapters cannot confine statement-level file access. Both behave like other unmanaged seeds: editable, and if deleted they go to the dismissed list rather than reappearing.
Neither sample is ever visible to an MCP client, because neither carries mcp: true.
This is separate from the SEED_CONFIG_PATH file and needs no config of its own:
| Variable | Default | Description |
|---|---|---|
LIBREDB_EMBEDDED_SAMPLE |
true |
Set to false (exact match) to disable the LibreDB sample |
LIBREDB_EMBEDDED_SAMPLE_PATH |
<data dir>/sample.libredb |
Override the LibreDB sample file's location |
SQLITE_EMBEDDED_SAMPLE |
true |
Set to false (exact match) to disable the SQLite sample |
SQLITE_EMBEDDED_SAMPLE_PATH |
<data dir>/sample-employees.db |
Override the SQLite sample file's location |
SQLITE_EMBEDDED_SAMPLE_TEMPLATE |
<cwd>/seed-assets/sqlite/employee.db |
Override the vendored template's location |
The sample files are only created if they don't already exist — the seeding is idempotent and never overwrites a user's edits.
- Check if the config file exists at
SEED_CONFIG_PATH - Check server logs for
Seed config file not foundwarning. Studio logs it once per path per process, at the first look, and again only after the file has appeared and gone, so a long-running instance may not show it among its recent log lines. - Verify the YAML is valid:
cat seed-connections.yaml | python3 -c "import yaml,sys; yaml.safe_load(sys.stdin)" - Check if
${ENV_VAR}values are set: connections with unresolvable vars are skipped ${vault:...}references do not stop a connection appearing, but opening it fails with the Vault error. CheckVAULT_ADDR, that the path is the KV v2 shape (<mount>/data/<name>) and that the token has a read policy on it. See Secret Rotation for how long a cached value can outlive a change in Vault.
The user's role doesn't match the connection's roles array. Check:
- User JWT role: login as admin vs user
- Connection
rolesfield in config
managed: true: Wait for TTL to expire (default 60s), or restart the appmanaged: false: The user has a local copy that never re-syncs from the config. Deleting it from the sidebar does not bring back the updated version either — it only marks the seed as dismissed (see Dismissed Seeds). To pick up new credentials, the user must delete their local copy from thelibredb_connectionsentry in localStorage and remove the matching seed ID fromlibredb_dismissed_seeds, then reload.
Clear browser localStorage (libredb_connections key) and refresh. This can happen if a connection was persisted before being marked as managed.
This is expected: deleting a managed: false connection adds its seed ID to libredb_dismissed_seeds in localStorage, and it is intentionally excluded from re-import on every subsequent load (see Dismissed Seeds). Remove the ID from that key (or clear it) to let the connection be re-imported.
- Sign in as the admin: discovered connections are never listed for the standard user.
- Open the admin Overview page and read the discovery status.
There is no card at all while
SEED_DISCOVERY_PATHis unset or for a user who is not an admin, so an install where discovery was added by hand and the variable was forgotten has no status to read: set it on the Studio app first. The card's badge reads Running forok, Waiting forwaiting, Stale forstaleand Failed forerror, and the steps below use the state names. waiting: the export file does not exist yet. Check that the-discoveryapp is running, and on a cluster that it runs on the same node as Studio. If the exporter's log saysrefusing to start: /app/discovery is owned by uid 0, not by this process (uid 1001), it was started through the image entrypoint, which a Composecommand:alone does: setentrypoint:as in the fragment under The exporter.errorwithsocket_unavailable: the exporter cannot open the Docker socket. It must run as root with/var/run/docker.sockmounted.errorwithswarm_unavailable: the exporter runs on a worker node, and so does Studio, because it reads that node's volume. Pin both apps to the same manager node under App Configs.errorwithnetwork_not_found: no Docker network has exactly the nameDISCOVERY_NETWORKgives, or the exporter runs where that network does not exist, for example outside a swarm. Check the variable againstdocker network lson the manager; CapRover's own network iscaptain-overlay-network.errorwith one of these codes:invalid_export: Studio refused the file atSEED_DISCOVERY_PATH, and the message says why: over 2 MiB, unreadable (the reason follows in parentheses), not JSON, or the first field that does not match the export's shape. Check thatSEED_DISCOVERY_PATHis the exporter'sDISCOVERY_OUTPUT, and forEACCESthatDISCOVERY_FILE_UIDis the user Studio runs as, because the file is mode 0600. A field that does not match can also mean that the exporter runs a newer image than Studio: deploy the same version to both. Studio also logs the reason as aDiscovery source errorwarning when it appears or changes.docker_error: a Docker failure no other code covers, such as an HTTP status other than 400 and 503, and the message is the daemon's own text when it sent one. The-discoveryapp's log repeats it.api_version: Docker answered HTTP 400, which the exporter reads as an Engine API version the daemon does not serve. The exporter asks for v1.44, as CapRover does, so check that the Docker on the manager serves it.limit_exceeded: the exporter hit one of its bounds, and its message says which: more than 500 services on the network, an export over 2 MiB, or a Docker answer over 16 MiB. For the first two it still exports the services that fit, in name order, so the databases after the cut are missing; an app named inDISCOVERY_EXCLUDEis not counted among the 500.
stale: the exporter stopped writing. Check its logs and restart it. The state also becomesstalewhile the exporter still runs, once its scans have kept failing for longer thanSEED_DISCOVERY_MAX_AGE_MSsince the last good one. The card then shows the exporter's error, so the cause is in steps 4 to 7.- A database in the skipped list carries its reason: a missing required variable, a host name Studio refuses, a connection the seed schema refuses ("the connection is not valid" followed by the field and its issue code, for example id (too_big) when a hand-made service's name is over 55 characters, too long for the 64-character id), an id the seed file already uses ("id taken by the seed file"), an id another discovered service already took ("id taken by another discovered service"), or no answer to the probe for an image CapRover built.
- An app named in "Apps to skip" (
DISCOVERY_EXCLUDEof the-discoveryapp) is in the skipped list with the reason "listed in Apps to skip", and the exporter writes nothing about it but its name. To connect it after all, remove it fromDISCOVERY_EXCLUDEunder the-discoveryapp's App Configs and save. - A database whose image is neither in the detection table nor built by CapRover is not recognised: add it as a seed connection or by hand.
seed-connections.yaml (volume mount)
│
┌─────▼──────────┐
│ ConfigLoader │ Read + YAML/JSON parse + Zod validate + TTL cache
└─────┬──────────┘
│
┌─────▼──────────────┐
│ CredentialResolver │ ${ENV_VAR} → process.env + plaintext warning
└─────┬──────────────┘
│
┌─────▼──────────────┐
│ ConnectionFilter │ Role filter + defaults merge → ManagedConnection[]
└─────┬──────────────┘
│ ┌───────────────────────────────────────┐
├─────────┤ Platform discovery (discovery-*.ts, │ Appended after the file seeds when
│ │ reads SEED_DISCOVERY_PATH) │ it is set; admin role only, literal
│ └───────────────────────────────────────┘
│ ┌───────────────────────────────────────┐
├─────────┤ Embedded samples (libredb-sample.ts, │ Appended if enabled and the
│ │ sqlite-sample.ts) │ sample file exists
│ └───────────────────────────────────────┘
┌─────▼───────────────────────┐
│ GET /api/connections/managed │ Auth + strip credentials for managed:true
└─────┬───────────────────────┘ ${vault:...} stays unresolved here
│
┌─────▼────────────────────┐
│ useConnectionManager │ Merge managed + user connections
└─────┬────────────────────┘
│
┌─────▼────────────────────────────┐
│ resolveConnection() (all routes) │ seed: prefix → server-side credential resolution
└─────┬────────────────────────────┘ a literal connection (discovered, or a file seed with SEED_LITERAL_VALUES on) skips Vault
│
┌─────▼────────────────────┐
│ VaultClient (lazy) │ ${vault:...} → KV v2 read + per-path TTL cache
└──────────────────────────┘
Module: src/lib/seed/ (13 files)
| File | Responsibility |
|---|---|
types.ts |
Zod schemas + TypeScript types |
config-loader.ts |
File read + parse + validate + cache |
credential-resolver.ts |
${ENV_VAR} resolution (eager) + ${vault:...} resolution (lazy, per connection) |
vault-client.ts |
HashiCorp Vault KV v2 reads: env config, Kubernetes auth, per-path TTL cache |
connection-filter.ts |
Role filter + defaults merge |
resolve-connection.ts |
Shared utility for all API routes |
libredb-sample.ts |
Built-in "Sample (LibreDB)" connection: file seeding + descriptor |
sqlite-sample.ts |
Built-in "Sample (Employees)" connection: vendored template copy + descriptor |
index.ts |
Public API: getManagedConnections() |
discovery-export.ts |
Zod schema and parser of the platform discovery export file, with its 2 MiB cap |
discovery-fingerprint.ts |
Image repository parsing, engine detection and credential mapping of discovered services |
discovery-probe.ts |
TCP probe for environment-matched candidates: 1 s timeout, 30 s positive cache, 16 at a time |
discovery-loader.ts |
Platform discovery source: cached read, freshness state, validation, admin status |
The exporter, docker/discover.mjs, lives outside src/: it ships in the container images only, and Studio never imports it.