Databases
Per-site SQL databases — Cloudflare D1 (SQLite) on every plan, Neon Postgres on the developer plan with copy-on-write branching
shiply gives every account a real SQL database. Pick the engine that fits the job:
- Cloudflare D1 (SQLite at the edge) — default. Free on every plan. Great for catalogs, configuration, simple write-light apps.
- Neon Postgres — opt in with
--postgres. Requires the developer plan. One isolated Neon project per database, copy-on-write branches offmain, andenv.DATABASE_URLauto-injected into your serving Worker.
The CLI, REST API, and MCP contracts are provider-agnostic — the same commands work against either engine. The differences are documented inline below.
CLI
shiply db create app # provision a D1 (binding SITE_DB by default)
shiply db create app --postgres # provision a Neon Postgres (binding DATABASE_URL) — developer plan
shiply db ls # list databases (name, provider, size, attached site)
shiply db sql app "SELECT 1" # one-shot query (uses your API key) — works on D1 + Neon
shiply db sql app "SELECT * FROM users WHERE id = ?1" --params '[42]'
shiply db migrate app ./migrations # apply every *.sql file in dir, sorted
shiply db attach app --site my-site # attach an existing DB to a different site
shiply db delete app --yes # drop the DB and every row in it
# Neon-only — branching off main:
shiply db branch app dev # copy-on-write Neon branch (cheap, fast)
shiply db branches app # list branches of a Neon database
shiply db delete-branch dev --yes # drop a Neon branch (and its compute endpoint)
shiply db merge dev # no-op alias — prints the pg_dump migration tipA few things to know:
- Auto-attach on publish. Running
shiply db createinside a directory that already has a.shiply.jsonrecords the newdatabaseIdthere, so the nextshiply publishbinds it without extra steps. Works for D1 and Neon alike. <name-or-id>accepts either. A UUID is used directly; anything else is resolved to your DB by name. Two DBs can never share a name per account.- Bindings default to
SITE_DBfor D1,DATABASE_URLfor Neon. Override with--binding(the dashboard auto-derives<NAME>_DBfrom the database name when you create from the UI). The binding name is the path segment your site queries for D1 (see Querying from a published site); for Neon it's theprocess.env.<name>your code reads.
Querying from a published site (/_shiply/db/<binding>/query)
When a database is attached to a site, shiply mounts a reserved-namespace fetch shim at the site root. Your published HTML/JS calls a relative URL — no API key, no domain config:
const res = await fetch('/_shiply/db/SITE_DB/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sql: 'SELECT id, title FROM posts WHERE slug = ?1',
params: [slug],
}),
})
const { results } = await res.json()The response shape mirrors D1's:
{ results: [...rows], meta: { rows_read, rows_written, duration } }.
Public by default. The shim runs as the site owner unconditionally — there is no visitor JWT to authenticate. Anyone who can fetch your site can run any SQL against the bound database. Until a private-mode gate ships, treat the shim as a SELECT-only data layer: read public catalogs, not user PII or write paths. The plan storage cap and size guard still apply, so a hostile visitor can't blow past quota.
Postgres (Neon) — developer plan
shiply db create --postgres <name> provisions a fully isolated
Neon Postgres project under shiply's organisation —
one Neon project per database — and binds it to your site on the next
shiply publish. Your serving Worker receives the connection string as
env.DATABASE_URL; the URI itself is AES-256-GCM encrypted at rest
(same secretbox envelope used for account variables) and only decrypted
server-side when the bindings ship to the Worker.
shiply db create app --postgres # ~5–10s — Neon project creation
shiply publish ./dist # next publish injects env.DATABASE_URLA minimal serverless query:
import { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL)
const rows = await sql`SELECT now()`Anything that speaks Postgres works — pg, postgres, drizzle, kysely,
prisma (with the Neon adapter). Connections are pooled and HTTPS-only.
Branching
Branches are Neon's headline feature: instant, copy-on-write forks of your main database. Use them for previews, schema migrations, or throwaway experiments without copying data.
shiply db branch app dev # branch off main → 'dev'
shiply db branches app # list branches
shiply db delete-branch dev --yes # drop branch + endpoint
shiply db merge dev # NOOP — see "no auto-merge" belowEach branch is its own row in your databases list, with its own
DATABASE_URL and its own compute endpoint. Attach a branch to a site
the same way you attach the parent — by including its id in
.shiply.json (the branch command prints it).
No auto-merge. shiply db merge is a deliberate no-op alias —
Neon branches don't have a server-side merge primitive. To promote a
branch's data into the parent:
pg_dump "$BRANCH_DATABASE_URL" | psql "$PARENT_DATABASE_URL"To keep schemas aligned, run your migrations against both branches.
The merge command exists so agents reaching for it get pointed at this
pattern instead of failing with "unknown command".
Preview-branch publishes
To bind a specific publish version to a branch instead of main — useful
for preview deploys, A/B tests, or safe schema rollouts — pass the
branch row id (the site_databases.id, NOT the Neon branch id) to
shiply publish:
shiply publish ./dist --preview-branch=<branchDbId>The version that goes live binds env.DATABASE_URL to the branch's URI.
Subsequent publishes without the flag fall back to the parent. If the id
is foreign or unowned, the publish silently uses the parent URI — the
flag is best-effort, not a hard gate.
MCP — same tools, Postgres dialect
The per-database MCP server (/api/mcp/db/<id>/(sse|mcp)) works
identically for Neon: db_query, db_list_tables, and db_schema all
dispatch to Postgres under the hood. Schema introspection uses
information_schema instead of D1's sqlite_master, so an agent sees
proper Postgres types (integer, timestamp with time zone, etc.).
Plan + limits
| Plan | Neon access | Branches per database | Storage per branch |
|---|---|---|---|
| Founder Special / Hobby | — | — | — |
| Developer | ✓ | Neon's default (10) | Neon's default (50 GB) |
Trying to create a Postgres database on Founder Special / Hobby returns
402 payment_required with a pointer to
the plan page. Compute hours are
re-sold at 2× Neon's underlying cost, included in the developer plan up
to a generous monthly cap; future overage billing will surface in the
dashboard before it bites.
What's not yet supported
- Branch-of-branch. Branches are off
mainonly. The Neon API allows nested branches; we don't expose them in v1. - Auto preview-branch on every publish. Opt in per-publish with
--preview-branch=<branchDbId>. Auto-branching every preview is too noisy and expensive to make default. - Cross-region. All Neon projects land in shiply's default region.
REST API (Authorization: Bearer shp_…)
| Method & path | Purpose |
|---|---|
GET /api/v1/databases | list your databases (D1 + Neon) |
POST /api/v1/databases | {name, siteSlug?, binding?, provider?} → provision; provider defaults to "d1", pass "neon" for Postgres |
GET /api/v1/databases/{id} | one database (size, attached site, provider) |
DELETE /api/v1/databases/{id} | drop the database |
POST /api/v1/databases/{id}/query | {sql, params?} → {results, meta} (works for both providers) |
POST /api/v1/databases/{id}/attach | {siteSlug} → bind an existing DB to a site |
POST /api/v1/databases/{id}/branches | {name} → create a Neon branch (400 on D1) |
GET /api/v1/databases/{id}/branches | list branches of a Neon database |
DELETE /api/v1/databases/{id}/branches/{branchId} | drop a Neon branch + endpoint |
The query route is the same code path as the per-site shim, but authenticated with your API key — use it for migrations, exports, anything you don't want to run unauthenticated.
MCP — per-database server
Every database gets its own MCP endpoint so an agent can be scoped to a single DB without seeing the rest of your account:
https://shiply.now/api/mcp/db/<databaseId>/sse
https://shiply.now/api/mcp/db/<databaseId>/mcp # streamable-httpAuthenticate with Authorization: Bearer shp_…. Three tools are registered:
db_query—{sql, params?}→{results, meta}db_list_tables—{}→{tables}db_schema—{table}→{columns}
This is the same surface as the global shiply MCP, narrowed to one
database — useful for handing an agent a SQL-only context. The same
three tools work against D1 (SQLite) and Neon (Postgres) — the
dispatcher picks the right backend per database id, and db_schema /
db_list_tables introspect sqlite_master or information_schema
accordingly.
Plan limits
| Plan | D1 databases | D1 size per DB | Neon Postgres |
|---|---|---|---|
| Founder Special (free) | 1 | 100 MB | — |
| Hobby | 5 | 1 GB | — |
| Developer | unlimited | 10 GB | ✓ (with branching) |
D1 size is sampled every 15 minutes by a background poller and stored as
meta.lastSizeBytes. Writes are rejected with 402 payment_required once a
D1 database is over its plan cap; reads still work so you can export data or
delete rows to get back under quota. The 10 GB ceiling on Developer is
D1's per-database limit — split larger datasets across multiple databases.
Neon storage is billed against shiply's developer plan compute-hour allowance (re-sold at 2× Neon's underlying rate). See the Postgres (Neon) section above for the branch + compute trade-offs.
D1 caveats (SQLite dialect)
- SQLite syntax.
INTEGER PRIMARY KEY AUTOINCREMENT,TEXT,REAL,BLOB,BOOLEAN(stored as INTEGER). NoSERIAL, noRETURNING *on every statement (D1 supportsRETURNINGonINSERT/UPDATE/DELETE). - No cross-database joins. Each D1 is isolated. If you need a relation spanning datasets, keep them in one DB.
- No PRAGMA
journal_modeor other server-tuning PRAGMAs — D1 manages those for you. - One statement per
querycall in the shim path. Useshiply db migratefor multi-statement scripts; it sends each file as one request.
Errors
| Status | error.code | Fix |
|---|---|---|
| 402 | payment_required | DB count or size over plan cap — delete data or upgrade |
| 403 | forbidden | not your database (or a write to a suspended one) |
| 404 | not_found | bad <id>, or shim path with no DB bound at that binding |
| 400 | invalid_request | bad SQL, bad params JSON, or a malformed name |