# Scripting your databases with the Layerbase CLI

> The Layerbase CLI is usually introduced as a way to run databases without Docker. This is about the other half of the surface: every command speaks --json, every failure sets an exit code, and nothing hangs waiting for a keyboard. Test runners, CI jobs, and plain shell scripts can drive it like an API.

Published: 2026-08-25
Canonical: https://layerbase.com/blog/scripting-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](https://layerbase.com/docs/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:

1. **Every command that answers a question takes `--json`.** `create`, `url`, `list`, `query`, the `branch` subcommands, and all of the `cloud` commands print machine-readable output on stdout. Even errors come back as JSON in that mode, so a parser never has to guess.
2. **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.
3. **Nothing blocks on a keyboard in CI.** A bare `lbase` in 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:

```ts
// 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`:

```bash
lbase query app-test "SELECT count(*) FROM users" --json
```

That 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.

```bash
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 state
```

Now the reset story is:

```bash
lbase branch reset app-scratch
```

which throws away everything the tests did to `app-scratch` and re-forks it from `app-base`. No seed file re-run, no schema replay. The CLI supports branching on qualified engines, 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:

```bash
#!/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.local
```

There 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:

```json
{
  "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](https://layerbase.com/blog/databases-for-ci-and-ai-agents). The scripting-relevant shape:

```yaml
- 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 --show-secrets)
    echo "DATABASE_URL=$(echo "$DB" | jq -r .connectionString)" >> "$GITHUB_ENV"
```

`--show-secrets` is doing real work there: since CLI 2.0.0 every connection string the CLI prints, `--json` included, has its password masked as `****`, so a script that needs a connectable URL has to ask for it. 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](https://cloud.layerbase.com/cloud/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:

```text
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 limit
```

That 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:

```bash
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 ;;
esac
```

In `--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:

```bash
lbase promote myapp-dev --write-env
```

`promote` 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](https://cloud.layerbase.com/) with an API key and nothing else changed.
