Skip to content

A SQLite connection is a path on the server

There is no network protocol, so the form collapses to one input and a remote user of a hosted deployment cannot open a file from their own machine.

Most people looking for a browser SQLite database viewer arrive with a file in mind and one unstated assumption: that the browser will open it. It will not. Most of the engines LibreDB Studio speaks to answer on a socket. SQLite answers on a filesystem, and the filesystem in question belongs to the process serving the page. That difference decides the connection form, the deployment shape, and where read-only access has to come from.

Why the connection form has one field

SQLite’s connectionFields entry in src/lib/db-ui-config.ts is ["database"]. isFileBased() reads that and collapses the modal to one input labelled Database File Path - no host, no port, no user, no password. The provider’s getCapabilities() reports defaultPort: null, because there is no listener to give a number to.

It also reports supportsConnectionString: false, which is narrower than it sounds. The flag means there is no network DSN for this engine; it does not mean the field is ignored. getDatabasePath() still accepts a file: string or a bare path there, strips the prefix, and treats it as a path like any other.

The missing fields are the interesting part. On a networked engine, the credentials in the form are the access control. Here there are none, so the access control is whatever the operating system says about the file. The connection is not authenticated; it is permitted.

What the process opens, and which pragmas it sets

getDatabasePath() resolves the target in a fixed order: connectionString with any file: prefix removed, else database, else :memory:. A non-memory path is run through path.resolve() to an absolute path, and the only validation applied is rejection of a NUL byte. ../ segments are legal and simply resolve. Parent directories are created on connect.

connect() then opens the file with { create: true, readwrite: true } and issues three pragmas:

PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous  = NORMAL;

Foreign key enforcement on, WAL for concurrency, NORMAL sync for a speed-against-durability balance. Three consequences follow from those open flags and those pragmas.

create: true means a mistyped path does not fail. It produces a new, empty database at the typed location, and the object browser shows you an empty schema, which reads exactly like a broken connection. If the tree is empty, check the path before you check anything else.

journal_mode = WAL writes. It creates -wal and -shm files beside the database, which is why the storage panel runs fs.statSync on all three, and why the directory holding the database has to be writable, not just the file.

And the whole thing is an embedded handle. The provider exposes no beginTransaction/commit/rollback and no cancelQuery, so supportsTransactions: false withholds the BEGIN/COMMIT/ROLLBACK controls and the auto-rolled-back sandbox toggle from the editor toolbar rather than showing them and failing the call.

Which driver does the opening depends on the runtime. The sqlite-driver adapter selects bun:sqlite under Bun and node:sqlite under Node; every packaged channel - the Docker image, npx @libredb/studio, the Homebrew tap, the .deb and .rpm packages, the standalone tarballs - runs the built app with node server.js, so in a deployment you are on node:sqlite. LIBREDB_SQLITE_DRIVER=bun|node forces one. On a runtime with neither, connect() throws a DatabaseConfigError. This matters for one published number: per-table size is read from the dbstat virtual table, which node:sqlite is compiled with and bun:sqlite is not, so the same file reports sizes under one driver and N/A under the other.

What a hosted deployment cannot do for a remote user

State it plainly, because it is the boundary of the feature. SQLite has no network protocol. A remote user of a hosted deployment cannot point Studio at a file on their own machine - there is nothing on that machine to connect to, and the path they type is resolved in the Studio process on the server. /data/app.db means that path on the machine running Studio, always.

So SQLite as a target fits self-hosted installs, Docker, local development, edge deployments and zero-configuration trials, where the file already sits beside the app. It is not a multi-tenant SaaS target, and the engine list is where to look if the database you need to reach is somewhere else.

There is a second consequence for shared instances, and it is not a corner case. Path resolution has no base-directory sandbox: any authenticated user of that instance can open any SQLite file the Studio process can read. The trust model is deliberate - pointing Studio at a server-side file is the capability, not attacker-controlled input - but it is an assumption to check against your threat model rather than one to inherit. An optional allowlist is tracked as issue #125. Until it lands, the controls are the ones below.

Which volume you mount, and as which user

Because the path is resolved in the process and nowhere else, the deployment question is not a connection string. It is which volume you mount and which user the process runs as. Mount the directory, not the file, so the -wal and -shm sidecars have somewhere to live:

services:
  libredb-studio:
    image: ghcr.io/libredb/libredb-studio:latest
    ports:
      - "3000:3000"
    volumes:
      - ./databases:/data

The path you then type into the form is /data/app.db - the container-side path, not the host path you can see in your editor. Mount only the directories that hold databases this instance should serve; the reachable set of files is exactly the set the process user can read, so a narrow read scope is the control that actually holds.

The connection form is not where you ask for read-only. connect() opens the file readwrite and sets journal_mode = WAL, which is itself a write. The read-only handle belongs to agent mode instead. SQLite is one of the three engines - with PostgreSQL and DuckDB - where an agent run executes statements at all, and its execution profile opens a second, physically separate handle to the same file with the driver’s read-only flag, then sets and verifies PRAGMA query_only at open and before every statement, because a read-only open alone reads query_only back as 0. Plan mode opens on every connection, is toolless, and drafts statements for you to run yourself. Each packaged channel puts its data directory in a different place, so check the deployment channels for the one you use.

In-memory databases, and what they are for

:memory: is accepted in the same field, which makes the modal a zero-setup way to get a scratch database and try the editor. It is discarded on disconnect. Nothing is written anywhere, nothing survives a restart, and there is no file to back up. That is the intended use, not a caveat attached to a real one.

It also cannot be an agent target. Under the read-only execution profile an in-memory database is refused with ExecutionProfileError reason code PROFILE_UNSUPPORTED_TARGET, because a read-only open of an anonymous database can only yield an empty one or fail outright.

For a first look that persists, standalone startup already seeds one: the vendored employees database is copied to <data dir>/sample-employees.db and advertised as an editable, dismissable Sample (Employees) connection. The copy is asynchronous and fail-open, so a failure logs a warning and the sample is simply absent. SQLITE_EMBEDDED_SAMPLE=false disables it.