Skip to content

One Command From Local Database to Cloud

6 min readCLIDatabasesDeveloper Tools

The last two posts I wrote both end in the same place. A PGlite prototype gets dumped and restored into hosted Postgres. A SQLite file gets copied cleanly and uploaded. Different engines, same shape: you have a local database with real rows in it, and you want it reachable from something other than the process holding it open.

Writing those two made the gap obvious. The hard part was never the concept. It was the five-step ritual around it, where every step is easy and the whole sequence is annoying enough that people put it off for a week.

Here is the ritual, with real commands:

bash
lbase backup notes                      # dump the local database to a file
lbase cloud create notes --engine postgresql
lbase cloud connection-string notes     # copy it out of the terminal
lbase import ./notes.dump --target notes --yes
# then paste that connection string into .env by hand

Version 1.4.0 of the Layerbase CLI collapses that into one verb:

bash
lbase promote notes --write-env --yes

That is the whole post, really. The rest is what it does per source, and where it deliberately refuses.

Install and auth

bash
npm i -g layerbase
export LAYERBASE_API_KEY=sk_...

promote creates a cloud database, so it needs a Layerbase API key: it calls the cloud API directly, and a browser session alone will not authorize it. A personal key from your dashboard settings (LAYERBASE_API_KEY, or --api-key) is the headless path. lbase login (browser, GitHub or Google) also works, because it caches your account's key on the machine after signing you in. Local commands never need either.

What it does, in order

text
Promote ./app.db (sqlite) to a new cloud sqlite database named "app".
Creating cloud database "app" (sqlite)...
Uploading dump...
Restoring...

Promoted ./app.db (sqlite) into "app" (2.4 MB).

postgresql://layerbase:<password>@your-host.cloud.layerbase.dev:5432/app?sslmode=require

Dashboard: https://layerbase.com/cloud/<id>
Free tier: it sleeps after an hour idle and wakes on your next connection.

Five steps behind that output. It detects what the source is. It maps that to a cloud engine, and refuses right there if it cannot. It creates the database and waits for it to actually come up, because some engines provision asynchronously and an import against a database that is still provisioning gets rejected. It uploads and restores through the same path lbase import uses, not a second one. Then it prints the connection string and the dashboard link.

The name comes from the source: a file's basename without its extension, or the local database's name, lowercased and slugified. --name overrides it.

The source is detected, not guessed

What you point it atWhere it lands
.db, .sqlite, .sqlite3the cloud SQLite engine, SQLite storage behind the Postgres wire
.duckdbDuckDB
.sql, including a PGlite dumpPostgreSQL
a local database by namethe same engine in the cloud

Binary formats are identified by their header rather than their extension, so an app.db that is really a DuckDB file is treated as DuckDB, and a .db that is not a SQLite database at all fails with that as the reason instead of uploading garbage. A path-shaped argument is always a file; a bare word is looked up against your local databases. It never guesses between the two, and --from forces the kind when something is genuinely ambiguous.

SQLite files land on hosted SQLite by default, which means a Postgres connection string and any Postgres driver, which is the same trip the SQLite post walks through by hand. DuckDB files land on DuckDB. A .sql file is assumed to be Postgres dialect and goes to PostgreSQL, so if yours came out of a MySQL dump, create the database explicitly instead. Local Postgres, MySQL, MariaDB, Redis, Valkey, SQLite, and DuckDB databases promote to the same engine in the cloud.

--write-env is the part I use most. It rewrites DATABASE_URL in ./.env, creates the file if it is missing, leaves every other line byte for byte, skips commented-out assignments, and tells you whether it created, updated, or appended. It is opt-in because silently editing a file in someone's project directory is not a thing a CLI should do on its own.

Where it refuses

This is the part worth reading, because a tool that quietly does the wrong thing on an unsupported input is worse than one that stops.

Every refusal happens before anything is created. An unsupported source never leaves you with an empty cloud database to clean up.

PGlite data directories. Pointing at ./pgdata fails and prints the three-line pgDump recipe instead. Shipping the WASM needed to read a PGlite directory would put several megabytes into every install of a CLI most people use for other things. Dump it first, promote the .sql, and you are on the path the PGlite post describes.

--target libsql. Accepted as a flag value and then refused, with the reason: cloud libSQL restores from an archive of a live data directory, not from a SQLite file, so there is no honest path from your local .db into it yet. The default (--target pgsqlite) is the one that works. An already-hosted libSQL database is a different job, and lbase migrate --source turso does that one.

Desktop-only engines. MongoDB, CockroachDB, and SurrealDB run locally but cannot be offered as managed services here for licensing reasons, so promote says so by name rather than failing generically. MongoDB points at FerretDB, which speaks the MongoDB wire protocol on Postgres. CockroachDB points at Postgres, since its dumps are Postgres-dialect SQL. SurrealDB has no cloud equivalent to promote into, and the message says that too.

Local engines whose backup format the cloud restore path does not accept. Those refuse with a pointer at lbase migrate instead of uploading a snapshot that would be rejected, or half-applied.

Non-interactive runs without --yes. No TTY, or --json, and no --yes means it refuses before any network call. Creating a billable resource unconfirmed from a script is not a default worth having.

And if the import fails after the database exists, promote says exactly that: the database was created, it is empty, here is the command to retry the load, here is the command to delete it. It never deletes anything on your behalf. --json prints one result object with the database, the connection string, the dashboard URL, and the bytes uploaded, which is what makes the whole thing usable from CI or an agent.

One caveat on that object: connectionString contains the database password, and there is no redaction flag. In CI, read the single field you need and mask it rather than letting the object reach a log line or a build artifact:

bash
URL=$(lbase promote ./app.db --yes --json | jq -r '.connectionString')
echo "::add-mask::$URL"                 # GitHub Actions: keep it out of later log lines
echo "DATABASE_URL=$URL" >> "$GITHUB_ENV"

Piping the raw output to tee promote.json, or uploading it as an artifact, writes a live credential into the run's permanent record.

After it lands

What you get is a normal cloud database, with nothing special about having arrived this way. On the free tier it sleeps after an hour idle and wakes on your next connection in a few seconds, backups exist on every tier, and the query console is there when you want to look at the data without finding a client.

The reason I wanted this to be one command is that the decision to graduate a prototype should cost about as much as the decision to start one. If it turns out you did not need it yet, delete it and keep working locally. That is a much easier thing to try than a five-step ritual you have to look up.

Install the CLI and run lbase promote against whatever is sitting in your project directory, or create a database in the dashboard first if you would rather see it before you fill it.