zBase

Reference

Three ways in: the dashboard, the zbase CLI, or the HTTP API directly. All three are the same thing underneath.

Quickstart

Install the CLI once:

cd zbase/cli && sudo npm link

Prefer not to touch the global bin? Run it in place instead — alias zbase='node /path/to/zbase/cli/index.js' in your ~/.zshrc does the same job.

Then, from nothing to a working database URL:

zbase login
zbase create acme-corp --env

# .env.local now contains:
# DATABASE_URL=postgres://…@zbase.zavecoder.com:6432/…?sslmode=require

Writing is idempotent — an existing DATABASE_URL is replaced in place and every other line in the file is preserved. For a project that already exists, use zbase env <name> --write — create would fail on the duplicate name.

Prefer clicking? The dashboard does the same thing and needs nothing installed.

CLI

CommandWhat it does
zbase loginBrowser sign-in; the token is handed back over a loopback port
zbase login --token <jwt>Sign in non-interactively
zbase whoamiShow the signed-in account and org
zbase listProjects in your org
zbase create <name>Provision a project
zbase create <name> --env [file]…and write DATABASE_URL (default .env.local)
zbase env <name> [--write [file]]Print or write the connection string
zbase delete <name> [--yes]Drop the database and role

Credentials live in ~/.zbase/config.json (mode 0600). --host targets a different zBase instance.

zbase login starts a throwaway listener on a random loopback port and opens the normal browser sign-in; the dashboard then asks you to confirm before sending the token back, so there is nothing to copy and paste. The hand-off is bound to that one attempt by a random nonce and an origin check, and requires an explicit click — so neither a page you happen to have open nor a crafted link can push your token to a local port you didn't intend.

Connecting

Connection strings point at PgBouncer on port 6432, which is publicly reachable and requires TLS — plaintext connections are refused outright. It presents the same publicly-trusted certificate as this site, so full verification works with no custom CA bundle:

psql "$DATABASE_URL"                        # sslmode=require, as issued
psql "…?sslmode=verify-full&sslrootcert=system"   # full chain verification
sslmodeResult
disableRefused — FATAL: SSL required
requireWorks (what the CLI issues)
verify-fullWorks with sslrootcert=system

Pooling is transaction mode, so session-level features (LISTEN/NOTIFY, session SET, advisory locks held across statements) won't behave as they would on a direct connection.

Reuse connections in your app

Opening a connection is expensive, and it gets worse the further away you are. Measured against this deployment from a client ~30ms away:

OperationTime
New connection + one query~300ms
Query on an already-open connection~29ms
New connection, measured on the server itself~18ms

The server does its part in 18ms — the other ~280ms is network round trips. The TLS handshake and SCRAM authentication are chatty, costing roughly ten round trips before your first query runs, and every one of those is charged at your latency to Singapore. Reusing a connection skips all of it, which is why the same query drops to a single round trip.

So if zBase feels slower than a hosted provider, this is almost always why, and the fix is in your app rather than on the server.

Create the pool once at module scope and reuse it. Never inside a request handler — that opens and discards a connection per request, paying the full handshake every time.

// db.js — evaluated once per process
import pg from 'pg';

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,                       // small on purpose — see below
  idleTimeoutMillis: 30_000,
});

// routes.js
import { pool } from './db.js';
app.get('/items', async (req, res) => {
  const { rows } = await pool.query('select * from items where id = $1', [req.query.id]);
  res.json(rows);
});

Keep max small. PgBouncer is already multiplexing — it holds the real server connections and hands them out per transaction. A large client pool doesn't buy throughput, it just parks idle TLS sockets. Each project gets at most 12 server connections, so oversized client pools across several instances will queue and eventually hit query_wait_timeout (20s).

Where your code runsWhat to do
Long-running server (Express, Nest, Fastify)Module-scope pool, max: 5
Next.js route handlersModule-scope pool, cached on globalThis to survive dev hot-reload
Scripts, migrations, cronNothing to change — one connection for the whole run
Lambda / Vercel functionsModule scope still helps on warm invocations; max: 1
Cloudflare Workers / edgeUse SQL over HTTP — no pool needed

In Next.js dev, hot-reload re-evaluates modules and leaks a new pool each time. Cache it: globalThis._pool ??= new pg.Pool(…).

Cloudflare Workers

pg runs fine on Workers — v8.16.3 or later, with the nodejs_compat flag and a compatibility date of 2024-09-23 or later. The problem isn't compatibility, it's geography plus connection reuse.

This deployment is in Singapore. Workers run at the edge nearest the user, so a visitor in London hits a London isolate that must then reach Singapore — roughly 180ms each way, and the TLS handshake plus SCRAM authentication takes several round trips before a single query runs. Worker isolates are also short-lived and numerous, so the module-scope trick above recovers far less than it does on a long-running server: many requests pay the full handshake.

Use Hyperdrive. It keeps warm connections pooled regionally, so the handshake is already paid before your query arrives, and it can cache read queries at the edge. Point it at zbase.zavecoder.com:6432 like any other client — the pooler is publicly reachable and presents a publicly-trusted certificate.

Organizations

Your organization is derived from your email domain — everyone at the same company lands in the same org, and the first person in owns it. Projects belong to an org, and every lookup is org-scoped: a project in another org is indistinguishable from one that doesn't exist, so cross-org access returns 404 rather than 403.

Project names only need to be unique within your org. The underlying Postgres role and database names carry a random suffix, since those are cluster-wide objects — two orgs can both have a project called api.

Roles and limits

ActionWho
Create a project, list, read a connection stringAny member
Delete a projectowner or admin only

Deleting drops a database irreversibly, which is why it isn't something every member can do by accident. Orgs also carry a project cap, and provisioning is rate limited — each project is a real database on disk.

Creates, deletes, and credential reads are written to an append-only audit log. Reading a connection string decrypts a live password, so it belongs in the trail for the same reason the other two do.

Authentication

Google sign-in issues a 7-day zBase JWT whose subject is your user ID. Every API call other than /health and the OAuth routes requires it as a Bearer token.

Sessions are revocable despite the token being stateless: each one carries the token version it was minted with, and every request checks that against the database. An administrator bumping that version signs the user out everywhere immediately — subsequent requests get 401 TOKEN_REVOKED.

GET/api/auth/google/url

Returns the Google consent URL to redirect to.

POST/api/auth/google

Exchanges the OAuth code for a zBase JWT. Rejects emails outside the allowed domains with 403.

{ "token": "<jwt>",
  "user": { "id": "<uuid>", "name": "…", "email": "…" },
  "org":  { "id": "<uuid>", "name": "…", "slug": "…" } }
GET/api/auth/me

The caller's profile and org memberships.

curl https://zbase.zavecoder.com/api/auth/me \
  -H "Authorization: Bearer <jwt>"

Who can sign in

Three conditions, all required:

ConditionWhy
Email domain is allowlisted Gates access to the service
Google reports the address as verified Your organization is derived from your email domain, so an unverified address would be enough to join someone else's org and inherit its databases
Workspace hd claim agrees, when present For Workspace accounts the hosted-domain claim is authoritative and must match the address
skubbs.com im.skubbs.com vaniceadvisory.com

Projects API

POST/api/projects

Creates a Postgres role and database owned by your org.

curl -X POST https://zbase.zavecoder.com/api/projects \
  -H "Authorization: Bearer <jwt>" \
  -H "Content-Type: application/json" \
  -d '{"name": "acme-corp"}'
{
  "ok": true,
  "projectId": "…",
  "projectName": "acme-corp",
  "region": "local",
  "connectionUri": "postgres://zdb_acme_corp_58b0b3:***@zbase.zavecoder.com:6432/zdb_acme_corp_58b0b3",
  "project": { "id": "…", "name": "acme-corp" },
  "connection_uris": [{ "connection_uri": "…" }],
  "databases": [{ "name": "zdb_acme_corp_58b0b3" }],
  "roles": [{ "name": "zdb_acme_corp_58b0b3" }]
}

The project / connection_uris / databases / roles fields exist so Neon-API client code works against zBase unmodified. Append ?sslmode=require to the URI before using it.

GET/api/projects

Lists your org's projects. No credentials included.

GET/api/projects/:idOrName

One project, including its connection string. The password is decrypted on the fly — it is stored encrypted (AES-256-GCM) and never held in plaintext at rest.

DELETE/api/projects/:idOrName

Drops the database and role, then soft-deletes the control-plane row. Irreversible.

Errors

StatusMeaning
400Missing or invalid input, or provisioning failed (e.g. duplicate name in your org)
401Missing, invalid, or expired Bearer token — or the session was revoked (TOKEN_REVOKED)
403Email outside the allowed domains, no organization, or role too low for the action
404No project by that id or name in your org
429Rate limit hit, or the org's project quota is full
500Unexpected server error

SQL over HTTP

Edge runtimes cannot hold a connection pool, so every request pays a full TCP + TLS + SCRAM handshake — ~300ms against this deployment. POST /sql holds the pool for you: one HTTP request per query, ~16ms measured end to end.

It is wire-compatible with @neondatabase/serverless, so migrating from Neon is one line:

// the only change — point the driver at zBase
import { neon, neonConfig } from '@neondatabase/serverless';
neonConfig.fetchEndpoint = () => 'https://zbase.zavecoder.com/sql';

const sql = neon(process.env.DATABASE_URL);
const rows = await sql`select * from items where id = ${id}`;

Or call it directly, with no driver at all:

curl -X POST https://zbase.zavecoder.com/sql \
  -H "Neon-Connection-String: $DATABASE_URL" \
  -H 'content-type: application/json' \
  -d '{"query":"select * from items where id = $1","params":[42]}'
HeaderEffect
Neon-Connection-StringRequired. Your project's connection string
Neon-Array-Modetrue returns rows as arrays, not objects
Neon-Raw-Text-Outputtrue returns every value as a string

One query per request, and each runs in its own transaction. Multi-statement transactions are not supported — if you need them, keep using the pooler on 6432 from a runtime that can hold a connection. This matters for read-modify-write paths such as inventory or balances, where a lost update is a correctness bug rather than a slow query.

Credentials are supplied per request and never stored: the service holds no secret of its own and can reach nothing you could not already reach. Connections are permitted only to this deployment's pooler. Statements are capped at 30 seconds.

This removes the handshake — it does not move the data. The instance is in Singapore, so a caller in Europe still pays that round trip per query. Hyperdrive can additionally cache reads at the edge and remains the better end state for a globally distributed audience.

Durability

Two independent layers. A nightly logical dump of every database at 03:15 UTC, and continuous WAL archiving with a weekly physical base backup, which together allow recovery to any point in time rather than only to the last nightly run.

PropertyValue
Recovery point objective~5 minutes
Nightly logical dump03:15 UTC, 3 days local, 90 days off-site
Physical base backupWeekly, 2 retained
Off-siteCloudflare R2, client-side encrypted

Restores are tested by actually performing them, not assumed: recovery to a target time is verified against markers written either side of that instant.

How it works