Launch an app with a serverless SQLite database included
How to launch an app on Ploy with a serverless SQLite database: one line in ploy.yaml, plain .sql migrations applied on deploy, typed queries with Drizzle, and a local emulator that mirrors production.

Every "deploy in 30 seconds" story has a footnote: the database. Provision it somewhere else, copy a connection string into an environment variable, pick a migration tool, remember to run it before each deploy, and hope the preview branch does not point at production.
Ploy includes a serverless SQLite database with every project. You declare it in ploy.yaml, commit .sql migrations next to your code, and Ploy applies them on production deploys. Locally, ploy dev gives you the same database in an emulator with a dashboard to browse it. This guide launches an app with a database included, from zero to a typed query.
One line in ploy.yaml
kind: workerbuild: pnpm buildout: distdb:DB: default
kind: workerbuild: pnpm buildout: distdb:DB: default
The key is the binding name your code sees (env.DB), the value is the database identifier. Run ploy types and env.d.ts declares DB: Database.
Each project gets its own isolated database that persists across deployments. It is not shared with other projects, and nothing in your app ever holds a connection string.
Migrations are .sql files in Git
Create migrations/ and add your first file:
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE,created_at INTEGER);CREATE INDEX idx_users_email ON users (email);
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL,email TEXT NOT NULL UNIQUE,created_at INTEGER);CREATE INDEX idx_users_email ON users (email);
Ploy runs migrations in filename order and records each applied file, with a checksum, in a ploy_internal_db_migrations table inside the same database. Each migration runs in a transaction together with its bookkeeping row. Applied migrations are immutable: if a file that already ran is edited, the deploy fails and tells you to add a new migration instead.
Two rules worth knowing:
- Migrations run on production deploys from the default branch, before the deployment is uploaded, and the applied files are listed in the build log. Preview branches never change the schema.
- Ploy reads migrations from the repository at the deployed commit. Files generated during the build are ignored, so commit them.
Nested folders work too, and only .sql files are executed, so a Drizzle-style 20260403171410_init/migration.sql with a sibling snapshot.json is fine. With several db bindings, scope folders by binding name (migrations/DB/, migrations/ANALYTICS_DB/).
Query it
The raw binding is a prepared-statement API with all(), first(), run(), batch(), and exec():
interface User {id: number;name: string;email: string;}export default {async fetch(request, env) {const url = new URL(request.url);if (request.method === "POST" && url.pathname === "/users") {const { name, email } = (await request.json()) as Omit<User, "id">;const { meta } = await env.DB.prepare("INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)",).bind(name, email, Date.now()).run();return Response.json({ id: meta.last_row_id }, { status: 201 });}const { results } = await env.DB.prepare("SELECT id, name, email FROM users ORDER BY id DESC",).all<User>();return Response.json({ users: results });},} satisfies Ploy;
interface User {id: number;name: string;email: string;}export default {async fetch(request, env) {const url = new URL(request.url);if (request.method === "POST" && url.pathname === "/users") {const { name, email } = (await request.json()) as Omit<User, "id">;const { meta } = await env.DB.prepare("INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)",).bind(name, email, Date.now()).run();return Response.json({ id: meta.last_row_id }, { status: 201 });}const { results } = await env.DB.prepare("SELECT id, name, email FROM users ORDER BY id DESC",).all<User>();return Response.json({ users: results });},} satisfies Ploy;
meta.changes tells you how many rows a statement affected, which is how you make a conditional update safe against double submission:
const { meta } = await env.DB.prepare("UPDATE credits SET claimed = 1 WHERE id = ? AND claimed = 0",).bind(grantId).run();if (meta.changes === 0) {// Already claimed by another request.}
const { meta } = await env.DB.prepare("UPDATE credits SET claimed = 1 WHERE id = ? AND claimed = 0",).bind(grantId).run();if (meta.changes === 0) {// Already claimed by another request.}
Always bind parameters; never interpolate user input into SQL.
Typed queries with Drizzle
Add drizzle-orm and describe the table with its SQLite core:
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";export const users = sqliteTable("users", {id: integer("id").primaryKey({ autoIncrement: true }),name: text("name").notNull(),email: text("email").notNull().unique(),createdAt: integer("created_at", { mode: "timestamp" }),});
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";export const users = sqliteTable("users", {id: integer("id").primaryKey({ autoIncrement: true }),name: text("name").notNull(),email: text("email").notNull().unique(),createdAt: integer("created_at", { mode: "timestamp" }),});
In a plain worker, wrap the binding with Drizzle's D1-compatible driver, as in examples/db-drizzle:
import { eq } from "drizzle-orm";import { drizzle } from "drizzle-orm/d1";import { users } from "./schema.js";export default {async fetch(request, env) {const db = drizzle(env.DB);const email = new URL(request.url).searchParams.get("email") ?? "";const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);return Response.json({ user: user ?? null });},} satisfies Ploy;
import { eq } from "drizzle-orm";import { drizzle } from "drizzle-orm/d1";import { users } from "./schema.js";export default {async fetch(request, env) {const db = drizzle(env.DB);const email = new URL(request.url).searchParams.get("email") ?? "";const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);return Response.json({ user: user ?? null });},} satisfies Ploy;
If you build the worker with @meetploy/start, withDrizzle("DB", schema) puts the typed client on ctx.state.db; see Ploy Start. In Next.js, the same binding is on getPloyContext().env.DB; see Deploy Next.js on Ploy.
Local development
ploy dev
ploy dev
The emulator applies your migrations on startup and keeps data in .ploy/ between restarts. The dashboard at http://localhost:4000 lets you browse tables, run SQL, and inspect the schema. Statement limits (100 KB of SQL and 100 bound parameters per statement, 100 statements per batch()) are enforced locally too, so an oversized query fails on your machine, not in production.
Frequently Asked Questions
Is serverless SQLite durable enough for production?
Each project's database persists across deployments. Self-hosted instances choose the storage mode: local SQLite files by default, or Turso Cloud for managed replication and point-in-time restore.
Can I use Postgres instead?
Yes. The db binding is optional; connect to any external database from your worker. Many teams use the included SQLite for the app and keep a separate warehouse elsewhere.
How do I run a migration that only exists locally?
ploy dev applies migrations from your working tree. Production applies only files committed at the deployed commit on the default branch.
What happens if a migration fails?
It runs in a transaction, so a failed file leaves the database unchanged, the deploy fails, and the error appears in the build log.