Scripting your databases with the Layerbase CLI
Short version: the Layerbase CLI is built to be called by things that are not people. Every command that answers a question takes --json, every failure exits nonzero (and the cloud commands class-code the exit so a script can tell a retryable capacity error from a dead API key), and nothing blocks on a keyboard in a non-interactive shell. Local commands need no account, no login, and no daemon, which is why they work on a bare CI runner with no Docker socket. Two non-interactive defaults to know: lbase create does not start the database unless you pass --start, and lbase delete needs -f -y.
The Layerbase CLI usually gets introduced as a way to run databases without Docker: npm i -g layerbase, then lbase create mydb -e postgresql --start, and there is a real Postgres on a real port. That is the interactive half. This post is about the other half, which is easy to miss if you only ever type the commands yourself: the CLI is built to be called by things that are not people.
Three properties make that work, and everything else in this post is a consequence of them:
- Every command that answers a question takes
--json.create,url,list,query, thebranchsubcommands, and all of thecloudcommands print machine-readable output on stdout. Even errors come back as JSON in that mode, so a parser never has to guess. - Every failure exits nonzero. Locally that is a plain exit 1 with
{"error": "..."}on stdout in JSON mode. The cloud commands go further and class-code their exits, which we will get to, because it means a script can decide whether to retry without parsing an error string. - Nothing blocks on a keyboard in CI. A bare
lbasein a non-interactive shell prints help and exits instead of opening the menu, and destructive commands refuse rather than hang when there is no TTY to confirm on.
Local commands need no account, no login, and no daemon. The engines are native binaries the CLI downloads on first use, which is exactly why this works on a bare CI runner: there is no Docker socket to require.
Two non-interactive defaults are worth knowing before you write your first script, because they are the opposite of what the interactive flow does. First, lbase create in a non-TTY context does not start the database unless you pass --start; interactively it asks, and a script has no way to answer. Second, lbase delete wants a confirmation, so scripts pass -f -y (-f stops the container if it is running, -y skips the prompt). Bake both into anything automated and the behavior is fully deterministic.
A throwaway database per test run
The most immediately useful script is the one your test runner calls. Here is a Vitest global setup that gives every test run a fresh Postgres and tears it down afterward:
// tests/global-setup.ts
import { execSync } from 'node:child_process'
const NAME = 'app-test'
export async function setup() {
const created = JSON.parse(
execSync(`lbase create ${NAME} -e postgresql --start --force --json`, {
encoding: 'utf8',
}),
)
process.env.DATABASE_URL = created.connectionString
execSync(`lbase run ${NAME} ./tests/schema.sql`)
}
export async function teardown() {
execSync(`lbase delete ${NAME} -f -y`)
}A few deliberate choices in there. The --json output of create includes the full connectionString, so the setup never assembles a URL by hand; it hands the exact string to the app under test. --force makes the script self-healing: if a previous run crashed before teardown, the leftover container is overwritten instead of failing the whole suite with a name collision. (--force deletes the existing container's data, which is precisely what you want for a test database and precisely what you do not want anywhere else, so keep it out of scripts that touch databases you care about.) And lbase run <name> <file> executes a SQL file against the running instance, so the schema load is one line rather than a driver dance before the driver is even configured.
run also takes inline commands with -c, and it is not SQL-specific: each engine interprets the input in its own language, so lbase run cache -c "SET greeting hello" works against a Valkey container the same way a .sql file works against Postgres.
When a script needs data back, use query instead of run:
lbase query app-test "SELECT count(*) FROM users" --jsonThat prints rows as JSON, which turns smoke checks into one-liners: run the migration, query the row count, compare in the script.
Seed once, reset in milliseconds
Reloading a seed file before every test run gets slow as the fixture grows. Branching replaces reseeding: keep one seeded parent, branch it, and reset the branch instead of rebuilding it.
lbase create app-base -e postgresql --start
lbase run app-base ./db/schema.sql
lbase run app-base ./db/seed.sql
lbase branch app-base app-scratch # copy-on-write branch of the seeded stateNow the reset story is:
lbase branch reset app-scratchwhich throws away everything the tests did to app-scratch and re-forks it from app-base. No seed file re-run, no schema replay. 16 of the 21 engines the CLI runs support branching, including Postgres, MySQL, and the document and vector engines, so this is not a Postgres-only trick.
Wiring it into the repo
Once the commands are deterministic, they belong in the repo like any other tooling. A bootstrap script that makes pnpm dev work on a machine that has never seen the project:
#!/usr/bin/env bash
set -euo pipefail
NAME="myapp-dev"
# `url` works even when the database is stopped, so it doubles as an
# existence check: it only fails if the container does not exist at all.
if ! lbase url "$NAME" --json > /dev/null 2>&1; then
lbase create "$NAME" -e postgresql --start
lbase run "$NAME" ./db/schema.sql
else
STATUS=$(lbase list --json | jq -r ".[] | select(.name == \"$NAME\") | .status")
if [ "$STATUS" != "running" ]; then
lbase start "$NAME" -f
fi
fi
echo "DATABASE_URL=$(lbase url "$NAME")" > .env.localThere is no create-if-missing flag, on purpose: the only overwrite mode is the destructive --force, so "ensure this exists" is an explicit check in your script rather than a flag that silently decides whether your data survives. The check is cheap because lbase url reads stored config rather than probing a server, and lbase list --json reports a status field (created, running, or stopped) for the start decision. The -f on start skips any confirmation prompt, such as a binary download on a fresh machine, which is what makes this safe to run headless.
With that script checked in, the package.json wiring is ordinary:
{
"scripts": {
"predev": "./scripts/ensure-db.sh",
"db:seed": "lbase run myapp-dev ./db/seed.sql",
"db:reset": "lbase branch reset app-scratch"
}
}The same commands drop into a Makefile, a justfile, or a git hook without modification. That is the point of scripting against a CLI rather than a GUI: the database setup lives in the repo, reviewed like code, identical on every machine.
CI: a database that deletes itself
Everything above runs unchanged on a CI runner, because the local engines are just binaries. But for CI there is a better-fitting tool: transient cloud databases with a TTL, which we covered in depth in ephemeral databases for CI and AI agents. The scripting-relevant shape:
- name: Create test database
env:
LAYERBASE_API_KEY: ${{ secrets.LAYERBASE_API_KEY }}
run: |
DB=$(lbase cloud create ci-${{ github.run_id }} --engine postgresql --ttl 2h --json)
echo "DATABASE_URL=$(echo "$DB" | jq -r .connectionString)" >> "$GITHUB_ENV"The detail that matters for automation is what is missing: there is no teardown step. The TTL deletes the database on the server side, so a cancelled job, a crashed runner, or a workflow you deleted last month cannot leak instances. Cleanup that does not depend on your script reaching its last line is the only kind that survives CI.
Cloud commands authenticate headlessly through the LAYERBASE_API_KEY environment variable (create a key under settings), and lbase whoami --json makes a good preflight step if you want the job to fail early with a clear message instead of at first use.
For preview environments, branch instead of create. lbase cloud branch staging-db pr-123 is idempotent by design: if the branch already exists it returns the existing one with exit 0, so a re-run of the workflow does not fail or double-provision. lbase cloud branch reset staging-db pr-123 re-forks it from the parent when you want each push to start clean, and a small workflow on the PR-close event runs lbase cloud branch delete. Branches do not count toward your plan's database limit, which is what makes one-per-open-PR reasonable. One boundary to know: TTL databases cannot be branched, so branch from a durable database and let the TTL databases stay flat.
Exit codes you can branch on
The cloud commands class-code their exit status instead of collapsing everything to 1:
0 success
1 generic or usage error
3 authentication (bad or expired API key)
4 billing (subscription suspended)
5 capacity (temporarily cannot place the database)
6 quota or rate limitThat split exists for scripts. A 5 or a 6 is retryable with backoff; a 3 or a 4 will fail identically every time, so the right move is to stop and surface it:
lbase cloud create ci-db --engine postgresql --ttl 1h --json > db.json
case $? in
0) ;;
5|6) sleep 30 && retry ;;
*) echo "unrecoverable, check credentials and billing" >&2; exit 1 ;;
esacIn --json mode the failure body is structured too ({ "ok": false, "error": ..., "code": ... } on stdout), so even the error path is parseable. No grepping stderr for substrings that change between versions.
FAQ
Can I use the Layerbase CLI in CI without Docker?
Yes, and that is the reason it works on a bare runner. The engines are native binaries the CLI downloads on first use, so there is no Docker socket to require, no daemon to start, and no account or login for the local commands.
Why did lbase create not start my database in a script?
Because in a non-TTY context it does not start unless you pass --start. Interactively it asks, and a script has no way to answer, so the non-interactive default is to create without starting. Pair it with --force and --json and the behavior is fully deterministic.
How do I delete a database from a script?
lbase delete <name> -f -y. The -f stops the container if it is running and the -y skips the confirmation. Without them the command refuses rather than hanging, which is the right failure but not the one you want in a pipeline.
Which exit codes should a script retry on?
5 (capacity) and 6 (quota or rate limit) are worth retrying with backoff. 3 (authentication) and 4 (billing) will fail identically every time, so the right move is to stop and surface them. 0 is success and 1 is a generic or usage error.
How do I stop CI databases leaking when a job is cancelled?
Give them a TTL instead of a teardown step. lbase cloud create ... --ttl 2h deletes the database server-side, so a cancelled job, a crashed runner, or a workflow you deleted last month cannot leave instances behind. Cleanup that depends on your script reaching its last line is the kind that does not survive CI.
Can I run a branch per pull request?
Yes, and lbase cloud branch is idempotent on purpose: if the branch exists it returns the existing one with exit 0, so re-running the workflow neither fails nor double-provisions. Branches do not count toward your plan's database limit. The one boundary is that TTL databases cannot be branched, so branch from a durable parent.
How do the cloud commands authenticate headlessly?
Through the LAYERBASE_API_KEY environment variable, with the key created in your dashboard settings. lbase whoami --json makes a good preflight step if you would rather the job fail early with a clear message than at first use.
Graduating a local database
The last script worth knowing closes the loop between the two halves. When a local database stops being a scratch pad and needs to be reachable by a deployed app:
lbase promote myapp-dev --write-envpromote moves the local database to Layerbase Cloud with its data, and --write-env rewrites DATABASE_URL in your .env to the new cloud connection string. The command that ends local development is also one line in a script.
None of this required an SDK, a client library, or an API wrapper. The CLI is the API: JSON on stdout, exit codes on failure, no prompts where no human is. Install it with npm i -g layerbase, and when the scripts outgrow your laptop, the same binary talks to Layerbase Cloud with an API key and nothing else changed.
Keep reading
- A Testcontainers Alternative for the Database HalfTestcontainers is excellent, and its one hard requirement is a Docker-API compatible runtime. If the only thing your tests need is a real database, you can get the same throwaway instance without a container runtime at all.
- One Command From Local Database to CloudGraduating a local database used to be five steps and a copy-pasted connection string. The promote command in the Layerbase CLI does the whole thing: create, import, connection string, and an optional .env rewrite.
- The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full': what this warning meansIf your Node app just started printing a SECURITY WARNING about SSL modes being treated as aliases for verify-full, nothing is broken and nothing has changed yet. Here is what the warning actually means, why it appeared out of nowhere, and the one-line connection string fix.
- From PGlite to Production PostgresPGlite is a real Postgres compiled to WASM, so graduating a prototype to a hosted database is a dump and a restore, not a rewrite. Here is the whole path, start to finish.