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 managed database environment into function fn-...
# ✓ Wrote 2 vars to .env (DATABASE_HTTP_URL, DATABASE_HTTP_TOKEN)If you run this from a project directory with a .ccp/config.json, the database credentials for the HTTP proxy (DATABASE_HTTP_URL and DATABASE_HTTP_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_HTTP_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 the linked function is a networked serverless App bound to this database, ccp db create also injects a native PostgreSQL DATABASE_URL (a full postgres:// connection string) server-side. That value is server-managed and is not mirrored into your local .env — only the HTTP compatibility values are — so a code-only ccp deploy from this project never overwrites it. Use DATABASE_URL with a Postgres client library (Postgres.js, Drizzle, etc.) inside the App; use DATABASE_HTTP_URL/DATABASE_HTTP_TOKEN for the SQL-over-HTTP proxy as described in Querying from Handlers.
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.
Native PostgreSQL Access
By default, a database only accepts the SQL-over-HTTP proxy — there's no way to connect a normal PostgreSQL client (psql, an ORM driver, etc.) directly over the wire protocol. Native access opens a direct PostgreSQL login on port 5432 for the application role, alongside the HTTP proxy, so tools that speak the wire protocol can connect over your private overlay network.
There's no ccp db subcommand for this yet — manage it by calling the infra API directly with your CLI auth token:
curl -X POST https://api.clusterbase.dev/api/v1/databases/db-c1cf9a9be667/native-access/enable \
-H "Authorization: Bearer $(ccp auth print-access-token)"
# {"credential_generation":1,"password":"9f2c...redacted...a71b"}The password field is the plaintext PostgreSQL password for the application role and is returned only by the operation that issues it — it is never retrievable again afterward. Connect with any PostgreSQL client at <database-name>.<network>.internal:5432, database default, user application.
- The database must already have a ready network attachment (
--network <NAME>at create time); otherwise these operations fail with409 native_access_requires_network. - A concurrent conflicting change to native-access state fails with
409 native_access_conflict. - If credential reconciliation for a previous operation hasn't finished yet, the next one fails with
503 native_access_pending— retry after a moment.
Connection Limits
Native PostgreSQL connections — whether opened directly against <database-name>.<network>.internal:5432 or via the DATABASE_URL injected into a networked Serverless App — are bounded, not unlimited:
- Each Serverless isolate permits at most 4 active and 2 pending connections to a database, with a 10-second connect timeout. Excess connections from the same isolate are rejected locally —
managed_database_active_limit,managed_database_pending_limit, ormanaged_database_connect_timeout— rather than queued indefinitely. - The database's own gateway permits at most 8 active and 4 pending native connections per deployment across all runtime instances, with a 10-second backend connect timeout and a 10-minute idle timeout on established connections. A connection that arrives once those ceilings are reached is rejected with
429 Too Many Requests. - Keep your pool small and reuse warm connections (e.g. a small Postgres.js/Drizzle pool) rather than opening one connection per request.
Rotate the Credential
curl -X POST https://api.clusterbase.dev/api/v1/databases/db-c1cf9a9be667/native-access/rotate \
-H "Authorization: Bearer $(ccp auth print-access-token)"
# {"credential_generation":2,"password":"...new plaintext password..."}Rotation issues a fresh password, invalidates the old one, and terminates any already-authenticated application sessions using it.
Disable Native Access
curl -X POST https://api.clusterbase.dev/api/v1/databases/db-c1cf9a9be667/native-access/disable \
-H "Authorization: Bearer $(ccp auth print-access-token)"
# {"credential_generation":3}Disabling revokes the application login and terminates any authenticated sessions; the HTTP proxy master token keeps working throughout. Calling enable again afterward issues a new password.
Reading the Current Generation
ccp db info and the underlying database read don't expose the password, but every database read includes a non-secret native_credential_generation counter that increments on each enable, rotate, or disable — use it to detect that a rotation happened without needing the plaintext.
List Databases
ccp db list
# • my-app-db ready db-c1cf9a9be667
# db-c1cf9a9be667.clusterbase.devccp db ls (alias for ccp db list) shows every database in the resolved
organization, sorts the project-linked database first, and marks its row
linked. Pass --json to emit the complete, unstyled list — including the
organization_id and linked_database_id — instead of the styled table.
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 clusterbaseDB_ID is now optional: run ccp db info with no argument to inspect the
database linked in .ccp/config.json or the current .env (the same
identity ccp db exec/migrate/connect use). Outside a linked project,
pass the ID explicitly.
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_HTTP_URL and DATABASE_HTTP_TOKEN (and any legacy DATABASE_URL/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 (if omitted, reads .ccp/config.json, falling back to DATABASE_HTTP_URL in .env) |
--token <TOKEN> | Database token (if omitted, reads .ccp/config.json, falling back to DATABASE_HTTP_TOKEN in .env) |
--status | Show migration status without applying |
If you provide --db-id, you must also provide --token (and vice versa).
This same config-first, .env-fallback resolution applies to --db-id/--token on ccp db exec, ccp db connect, ccp db client-access, and ccp db backup. A database ID derived from DATABASE_HTTP_URL must be a host in the environment's database domain — a foreign host is rejected before any request. Native DATABASE_URL is never read as an HTTP credential.
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_HTTP_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_HTTP_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 commits the desired mode and reconciles db-proxy to match it, in place. In-flight queries can fail during reconciliation and should be retried; the master path resumes once convergence completes. If convergence can't finish within the request, the API returns client_access_pending (503) — retry the toggle; background recovery also resumes the same operation on its own. Don't destroy and recreate the database to work around a pending toggle. 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.
A toggle already in progress for the same DB returns 409 (status_conflict); retry once the prior toggle settles.
Anon Queries
Once enabled, requests with no Authorization header run as the anon role:
const res = await fetch(`https://${process.env.DATABASE_HTTP_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_HTTP_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_HTTP_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.
- If reconciliation can't finish within the request, the toggle returns 503 (
client_access_pending); retry it, or wait — background recovery resumes the same operation automatically. - 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 |