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. A database can optionally be attached to a Project or left standalone.
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.
If a database with the given name failed to finish creating (or the create was canceled) and the platform is still cleaning up the old attempt, retrying ccp db create with the same name returns 503 provision_pending until cleanup finishes. Retry after a moment — once cleanup completes, the name is free again and the create proceeds normally.
Private Networking
Pass --network <NAME> to put the database's backing VM on the private overlay network <NAME>, alongside any compute service that declares the same [network].name, so the two reach each other without going through the public host:
ccp db create --name my-app-db --network backendThe network name is a lowercase DNS label (1-63 characters), matching the [network].name rule for ccp compute deploy. A few things to know:
- Overlay networking is disabled by default; if it isn't enabled for your environment, the request fails with
503 overlay_disabled. - An invalid network name fails with
400 invalid_network_name. - A name collision with another service already on that network fails with
409 network_service_name_conflict. - The database only publishes onto the network once it's durably ready; a failed or in-progress provision never appears on the network.
Compute services on that network reach the database at <database-name>.<network>.internal, wherever each one is placed.
List Databases
ccp db list
# • my-app-db ready db-c1cf9a9be667
# db-c1cf9a9be667.clusterbase.devShow Database Details
ccp db info db-c1cf9a9be667
# Database my-app-db
#
# ID db-c1cf9a9be667
# Status ready
# Host db-c1cf9a9be667.clusterbase.dev
# Database default
# User clusterbaseDelete 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.
Deletion is durable and idempotent: once requested it survives a crash or retry. If you re-run ccp db destroy (or call the delete endpoint again) while an earlier request is still tearing down the backing VM, backups, and linked env vars, the API returns 503 delete_pending — wait a few seconds and retry rather than treating it as a failure.
Interactive SQL Shell
Open an interactive SQL session with ccp db connect:
ccp db connectThis opens a terminal UI with a SQL prompt. Type queries ending with ; to execute them:
ccp db connect
● connected · database default · db-c1cf9a9be667.clusterbase.dev
› SELECT * FROM users;
id │ name │ email
───┼───────┼──────────────
1 │ Alice │ alice@co.com
(1 row)
✓ 43ms
╭──────────────────────────────────────────────╮
│› Enter SQL, ending with ; │
╰──────────────────────────────────────────────╯
↑↓ history · \dt tables · ctrl+d exitMeta-commands
| Command | Description |
|---|---|
\dt | List all tables |
\d <table> | Describe a table (columns, types) |
\q | Quit |
Navigation
- 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)
#
# 38msWorks for any SQL — SELECT, INSERT, UPDATE, DELETE:
ccp db exec "INSERT INTO users (name, email) VALUES ('Bob', 'bob@co.com');"
#
# 1 row affected · 45msStatements 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.sqlEach 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 appliedRunning again is safe — only pending migrations are applied:
ccp db migrate
# ✓ All 3 migrations already appliedCheck 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 migrationOptions
| Flag | Description |
|---|---|
--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) |
--status | Show 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.
If the database's backing VM is paused when a backup runs (scheduled or manual), the backup automatically wakes it first, then lets it return to idle afterward — you don't need to wake the database yourself before backing it up.
Create a Backup
ccp db backup create
# ✓ Backup created
# ID bkp-8f3e2a1c
# Created 2026-04-12 14:22 UTCUse --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 UTCls 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-8f3e2a1crm is an alias for delete.
If a restore fails, the platform retains the exact backup it restored from so recovery can be retried; deleting that specific backup while it's retained fails with 409 restore_backup_in_use. Delete another backup, or wait for the database to leave restore_failed, and retry.
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
| Endpoint | Method | Description |
|---|---|---|
/query | POST | Execute a SELECT query, returns { rows, fields } |
/execute | POST | Execute INSERT/UPDATE/DELETE, returns { rows_affected } |
/transaction | POST | Execute 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
Authorizationheader are allowed and run as the read-onlyanonPostgres role. - JWT — requests with a
Bearer <JWT>header are validated against your OIDC issuer; the JWT'ssubis exposed inside Postgres viaauth.uid(), and the query runs as theauthenticatedrole.
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-c1cf9a9be667This 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-c1cf9a9be667Closes 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
| Role | Used when | Defaults |
|---|---|---|
clusterbase | Master token (DATABASE_TOKEN) | Owner of all objects; bypasses RLS |
authenticated | Valid JWT | Subject to RLS; grant tables explicitly |
anon | No Authorization header | Subject 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
| Command | Alias |
|---|---|
ccp db list | ccp db ls |
ccp db destroy | ccp db rm |