Managed SQLite as a binding on Via

A SQLite file is the simplest persistence story in the world, until the app has to run on a host the developer does not own. Then a chain of small problems shows up. Where does the file live? Who owns the directory? How does the path get into the app, an environment variable, a config file, a build flag? How is it kept out of source control? How is it backed up? Who is allowed to read it? What happens during a deploy when the path is supposed to stay the same but the runtime container is brand new?

Note: We are still exploring what managed SQLite access should look like in Via. The binding model below describes the direction, not a settled production interface.

Via's answer is to take the path out of the app entirely. A Vary app running on Via refers to its database by a binding name. The host owns the file, the config daemon answers queries on the workload's behalf, and the operator manages the lifecycle. The app never sees a path.

The binding model

A Vary app declares the shape of its database the same way it declares any other persistence:

database TrackerDatabase version 1 binding "DATABASE" {
    table PersistedUser
    table PersistedSession
    table PersistedIssue
}

The binding "DATABASE" clause is what makes this database managed. At runtime the app asks Vary for a connection to that name:

let db = Sql.connect_managed("DATABASE", tracker_schema())

Sql.connect_managed looks at the running environment. On a developer workstation, with no Via runtime in the picture, it falls back to a local file. On a Via host it routes the connection through the config daemon, which holds the workload's identity and the per-app authorization policy. The same Vary code works in both places.

That property is the point of the binding. Tests open a real database file, production opens a managed binding, and the application code does not change shape between them.

What the operator does

The operator side is a small set of via addon commands. The add-on is created once and bound to an app:

via addon create sqlite app-db --app my-api --bind DATABASE

create sqlite provisions the add-on. --bind records the binding name for the next deploy: existing runtimes keep their current bindings, and the new binding takes effect when a fresh release rolls out. The bind is part of the release manifest the runner verifies before launch, not a live mutation of a running app.

After that, the lifecycle commands look like any other piece of platform state:

via addon list --app my-api
via addon status app-db
via addon snapshot app-db
via addon restore app-db --snapshot snap_2026_05_16_01 --drain
via addon rotate-credentials app-db --app my-api --binding DATABASE
via addon unbind my-api DATABASE
via addon destroy app-db

Snapshots are operator state, not application state. Restore is explicit and gated: the operator picks a snapshot id, and the command can drain bound apps before swapping the file in. Credential rotation is a single command because the app never had the credential. The same is true of moving the underlying storage, changing the file name, or switching backup policy. None of that surfaces to the app.

Why the config daemon answers queries

Apps do not open the SQLite file themselves on a Via host. Their queries go through the config daemon, which authorizes each one against the running workload's signed identity before any bytes reach disk. The checks are layered:

GateWhat it confirms
Identity tokenThe bearer is signed by Via and has not expired.
Workload bindingThe token's app, deploy, and runtime instance match the running release.
Release manifestThe binding name appears in the manifest that was signed when the artifact was built.
Add-on stateThe add-on exists, is healthy, belongs to this app, and is in scope for the active deploy.

A query that fails any of these is rejected and audited. The audit record names the workload, the binding, and the reason. It does not name the file or the credential, because neither one ever crossed the boundary.

This is the same shape as the rest of Via's identity model. Secrets are not env variables. Database files are not paths. Bindings are not credentials. Every fetch is a request the platform can answer, log, and refuse.

What it removes

It is worth being concrete about which categories of incident this closes.

Class of mistakeWhy it cannot happen
Database path committed to the repoThe app's only reference is a binding name.
Connection string in an env fileThere is no connection string to leak.
Backup script that ran the wrong pathSnapshots are an operator command keyed by add-on id.
Drift between staging and prod pathsIdentical binding names; different add-ons on each target.
Forgotten file when destroying an appvia addon destroy is part of the same lifecycle.
Stolen credential surviving a redeployEach workload identity is short-lived and tied to the running release.

None of these are exotic failures. They are the everyday cost of running an application against a SQLite file by hand, multiplied by however many environments it has to run in. Naming the database instead of pathing to it makes every one of them an operator command rather than an application concern.

Where to use it

A managed SQLite binding is the right default for any Vary app on Via that wants persistence and does not need a remote database. The issue tracker we ship as a working example uses one. Tests run against a temporary file. Production runs against a binding. The application code looks the same.

The longer story on how the platform owns build, runtime, identity, and routing is in the Via Server article. The end-to-end walkthrough of installing Via and deploying a small app is in Golden Path: Install Via and the Echo Server. If you want to see the binding declared and used in a real codebase, the Via docs link to the worked example.

A database file is not a contract. A binding is.