Database

Create and manage Postgres databases with ccp db.

Clusterbase provides managed Postgres databases, each running on its own isolated machine with a SQL-over-HTTP proxy for secure access.

Create a Database

ccp db create --name my-app-db
# ✓ Created database my-app-db
#
#   ID    db-c1cf9a9be667
#   Host  db-c1cf9a9be667.clusterbase.dev
#   Token a9db7b185e40...
#
# ✓ Token saved to .ccp/config.json
# ✓ Injected DATABASE_URL + DATABASE_TOKEN into function fn-...
# ✓ Wrote 2 vars to .env (DATABASE_URL, DATABASE_TOKEN)

If you run this from a project directory with a .ccp/config.json, the database credentials (DATABASE_URL and DATABASE_TOKEN) are automatically injected as environment variables into your linked function and written to your local .env file, so ccp dev and your deployed function see the same values. A redeploy merges env rather than replacing it, so these injected values are preserved server-side even when they're absent from your local .env.

DATABASE_URL is written as a bare host (db-<id>.clusterbase.dev) with no scheme. Prepend https:// in your handler code, which matches the pattern shown in the Querying from Handlers examples below.

List Databases

ccp db list
# • my-app-db  ready  db-c1cf9a9be667
#   db-c1cf9a9be667.clusterbase.dev

Show Database Details

ccp db info db-c1cf9a9be667
# Database my-app-db
#
#   ID       db-c1cf9a9be667
#   Status   ready
#   Host     db-c1cf9a9be667.clusterbase.dev
#   Database default
#   User     clusterbase

Delete a Database

ccp db destroy db-c1cf9a9be667
# Delete database db-c1cf9a9be667? This will destroy all data. (y/N)

Use -y to skip the confirmation prompt.

Destroying a database also removes DATABASE_URL and DATABASE_TOKEN from your local .env file, mirroring the sync that ccp db create performs. This keeps the next ccp deploy from re-uploading the stale credentials and silently re-pointing the function at the destroyed database. The local cleanup runs only when the current project is linked to the database being destroyed (or has no database linked); destroying a different database by ID from a project linked to another one leaves that project's .env untouched.

ccp db destroy is the only supported way to delete a database. Each managed database runs on its own dedicated VM, and deleting that VM directly through the VM API (DELETE /api/v1/vms/{vm_id}) is refused with 409 vm_backs_managed_database, naming the database and pointing back here. The check fails closed: if the platform cannot determine whether a VM backs a database, it refuses the delete rather than risk it. Only the database surface tears down backups, routes, and env vars in the right order.

Interactive SQL Shell

Open an interactive SQL session with ccp db connect:

ccp db connect

This opens a terminal UI with a SQL prompt. Type queries ending with ; to execute them:

  ╭──────────────────────────────────────────────────╮
  │ >_ ccp db connect (db-c1cf9a9be667)              │
  │                                                   │
  │ database:  default                                │
  │ host:      db-c1cf9a9be667.clusterbase.dev        │
  ╰──────────────────────────────────────────────────╯

  › SELECT * FROM users;

  id │ name  │ email
  ───┼───────┼──────────────
  1  │ Alice │ alice@co.com

  (1 row)

  43ms

  › Enter SQL (end with ;)

Meta-commands

CommandDescription
\dtList all tables
\d <table>Describe a table (columns, types)
\qQuit
  • Up/Down arrows — cycle through query history
  • Ctrl+D or Ctrl+C — exit (clears input if non-empty)

One-Shot SQL

Execute a single SQL statement without opening the interactive shell:

ccp db exec "SELECT count(*) FROM users;"
#
#   count
#   ─────
#   42
#
#   (1 row)
#
#   38ms

Works for any SQL — SELECT, INSERT, UPDATE, DELETE:

ccp db exec "INSERT INTO users (name, email) VALUES ('Bob', 'bob@co.com');"
#
#   1 row affected · 45ms

Statements with RETURNING clauses display the returned rows:

ccp db exec "DELETE FROM users WHERE id = 1 RETURNING *;"

Migrations

Run SQL migration files against your database with ccp db migrate. Migrations are tracked in a _ccp_migrations table so they only run once.

Setup

Create a migrations/ directory in your project with numbered .sql files:

migrations/
  001_create_users.sql
  002_add_posts.sql
  003_add_indexes.sql

Each file can contain multiple SQL statements. PL/pgSQL functions with $$ dollar-quoting are fully supported.

Run Migrations

ccp db migrate
# › Running 3 pending migrations...
#
# ✓ 001_create_users.sql  48ms
# ✓ 002_add_posts.sql     37ms
# ✓ 003_add_indexes.sql   42ms
#
# ✓ 3 migrations applied

Running again is safe — only pending migrations are applied:

ccp db migrate
# ✓ All 3 migrations already applied

Check Status

See which migrations have been applied without running anything:

ccp db migrate --status
# › Migration status:
#
# ✓ 001_create_users.sql
# ✓ 002_add_posts.sql
# ○ 003_add_indexes.sql
#
# 1 pending migration

Options

FlagDescription
--dir <PATH>Migrations directory (default: ./migrations)
--db-id <ID>Database ID (reads from .ccp/config.json if omitted)
--token <TOKEN>Database token (reads from .ccp/config.json if omitted)
--statusShow migration status without applying

If you provide --db-id, you must also provide --token (and vice versa).

How It Works

Each migration runs in a database transaction. The migration SQL and the tracking insert are atomic — if the migration fails, nothing is committed. Migrations are applied in filename order, so use a numeric prefix (001_, 002_, etc.) to control the sequence.

Backups

Databases are backed up automatically once per day and backups are retained for 7 days. You can also create manual backups on demand.

Create a Backup

ccp db backup create
# ✓ Backup created
#   ID         bkp-8f3e2a1c
#   Created   2026-04-12 14:22 UTC

Use --db-id <ID> to target a database other than the one in your .ccp/config.json.

List Backups

ccp db backup list
# • bkp-8f3e2a1c  manual     2026-04-12 14:22 UTC
# • bkp-7d1f9e4b  scheduled  2026-04-11 00:00 UTC
# • bkp-6c9a2d7e  scheduled  2026-04-10 00:00 UTC

ls is an alias for list.

Restore from a Backup

ccp db backup restore bkp-8f3e2a1c
# Restore database db-c1cf9a9be667 from bkp-8f3e2a1c? This will overwrite current data. (y/N)

Use -y / --yes to skip the confirmation prompt.

Delete a Backup

ccp db backup delete bkp-8f3e2a1c

rm is an alias for delete.

Querying from Handlers

Access your database from serverless functions using the SQL-over-HTTP proxy:

const res = await fetch(`https://${process.env.DATABASE_ID}.clusterbase.dev/query`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.DATABASE_TOKEN}`,
  },
  body: JSON.stringify({
    sql: "SELECT * FROM users WHERE id = $1",
    params: [42],
  }),
});

const { rows } = await res.json();

Endpoints

EndpointMethodDescription
/queryPOSTExecute a SELECT query, returns { rows, fields }
/executePOSTExecute INSERT/UPDATE/DELETE, returns { rows_affected }
/transactionPOSTExecute multiple statements atomically

Request Format

{
  "sql": "SELECT * FROM users WHERE id = $1",
  "params": [42]
}

Parameters use $1, $2, etc. for positional binding.

Transaction Format

{
  "statements": [
    { "sql": "INSERT INTO users (name) VALUES ($1)", "params": ["Alice"] },
    { "sql": "INSERT INTO logs (action) VALUES ($1)", "params": ["user_created"] }
  ]
}

Client-Mode Authentication

By default, the database accepts only the master token (DATABASE_TOKEN) and runs every query as the privileged clusterbase role. That's fine for server-side handlers, but it means you can't safely expose the db-proxy host directly to browsers or scope queries per user without manual WHERE user_id = $N filters.

Client-mode opens two additional auth paths on the same database:

  • Anon — requests with no Authorization header are allowed and run as the read-only anon Postgres role.
  • JWT — requests with a Bearer <JWT> header are validated against your OIDC issuer; the JWT's sub is exposed inside Postgres via auth.uid(), and the query runs as the authenticated role.

The master token continues to work alongside both. Row-Level Security policies then scope rows per user via auth.uid().

Client-mode is off at create time and can be toggled on or off at any point without redeploying or recreating the database.

Enable Client-Mode

ccp db client-access enable
# ✓ client-access enabled on db-c1cf9a9be667

This restarts the db-proxy in place (~3 seconds). In-flight queries fail and should be retried; the master path resumes immediately after restart. Run with no arguments to target the DB linked in .ccp/config.json, or pass ccp db client-access enable <db-id> to target another DB.

Disable Client-Mode

ccp db client-access disable
# ✓ client-access disabled on db-c1cf9a9be667

Closes the anon and JWT paths. The master token continues to work.

Both commands are idempotent — calling enable on an already-enabled DB is a no-op success and does not restart the proxy.

Anon Queries

Once enabled, requests with no Authorization header run as the anon role:

const res = await fetch(`https://${process.env.DATABASE_URL}/query`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ sql: "SELECT * FROM public_posts", params: [] }),
});

By default anon has no permissions — grant only what you want exposed publicly:

GRANT SELECT ON public_posts TO anon;

JWT Queries

Forward a user's Cluster OIDC access token as the bearer (see Sign in with Cluster for how to obtain one):

const res = await fetch(`https://${process.env.DATABASE_URL}/query`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${userJWT}`,
  },
  body: JSON.stringify({ sql: "SELECT * FROM todos", params: [] }),
});

The query runs as the authenticated role with auth.uid() populated from the JWT's sub claim. The JWT is validated against the OIDC issuer's JWKS — invalid, expired, or wrong-issuer tokens return 401.

Row-Level Security with auth.uid()

The typical pattern: enable RLS on user-owned tables and scope rows by the authenticated user's sub.

CREATE TABLE todos (
  id      TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  title   TEXT NOT NULL
);

ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

CREATE POLICY todos_owner ON todos
  FOR ALL TO authenticated
  USING      (user_id = auth.uid())
  WITH CHECK (user_id = auth.uid());

GRANT SELECT, INSERT, UPDATE, DELETE ON todos TO authenticated;

With this policy, SELECT * FROM todos returns only the calling user's rows even with no WHERE clause, and inserts or updates referencing other users' rows are rejected.

Roles

RoleUsed whenDefaults
clusterbaseMaster token (DATABASE_TOKEN)Owner of all objects; bypasses RLS
authenticatedValid JWTSubject to RLS; grant tables explicitly
anonNo Authorization headerSubject to RLS; grant tables explicitly

Requirements

  • The DB must be running. Paused DBs return 409; wake the DB with any query first, then retry the toggle.
  • The toggled state persists across pause/resume and across db-proxy restarts — you don't need to re-enable after a wake.

Aliases

CommandAlias
ccp db listccp db ls
ccp db destroyccp db rm

On this page