Cloudflare Workers
In this guide, you add Prisma ORM to a Cloudflare Worker, create tables in a PostgreSQL database, insert and count rows from the Worker's fetch handler, and deploy the Worker with Wrangler.
Cloudflare Workers do not expose raw TCP sockets by default, which is why most PostgreSQL drivers cannot run in them. With the nodejs_compat compatibility flag enabled, the pg driver that @prisma/orm-postgres uses connects through Cloudflare's Node.js socket API, so Prisma ORM runs inside the Worker without a special build. This works with Prisma Postgres, which pools connections for you, with any PostgreSQL server reachable over TCP and TLS, and with Hyperdrive in front of your own database.
Every command, code block, and output below was run with wrangler dev against a local PostgreSQL 17 database, and the same Worker was also run against a Prisma Postgres database over TLS.
- Node.js 24 or later
- A Cloudflare account for the deploy step
- A PostgreSQL connection string, or nothing at all:
npx create-db@latestcan create a Prisma Postgres database for you
To delegate this guide to your coding agent, copy the prompt below and hand it over:
Add Prisma ORM to a new Cloudflare Worker, query PostgreSQL from it, and prepare it for deployment.
1. Scaffold: `npm create cloudflare@latest prisma-cloudflare-worker -- --type=hello-world --lang=ts --git --no-deploy --no-agents`. In `prisma-cloudflare-worker`, run `npx prisma@latest orm init --yes --target postgres --authoring psl`, then `npx prisma@latest init` so the Prisma agent skills are installed and stay current, and use them.
2. Add `"compatibility_flags": ["nodejs_compat"]` to `wrangler.jsonc`. Get a database connection string: use the one I give you, or create a Prisma Postgres database with `npx create-db@latest` and show me the claim URL it prints. Write it to `.env` as `DATABASE_URL`; both the Prisma CLI and `wrangler dev` read that file.
3. Run `npx prisma db init` to create the tables from `src/prisma/contract.prisma`.
4. Replace `src/index.ts` following https://www.prisma.io/docs/guides/deployment/cloudflare-workers.md: create one `postgres<Contract>({ contractJson, url: env.DATABASE_URL })` client per request inside `fetch`, run the queries, and close it with `ctx.waitUntil(db.close())`. Do not import the module-level client from `src/prisma/db.ts` in the Worker; a connection shared across requests hangs the second request. Run `npx wrangler types` so `Env` includes `DATABASE_URL`.
5. Start `npm run dev` in the background, wait until it reports ready, verify `curl http://localhost:8787` twice returns a created user and a growing count, then stop the dev server.
6. Deploy: check `npx wrangler whoami`; if I am not logged in, stop and ask me to run `npx wrangler login`. Then run `npx wrangler secret put DATABASE_URL` with the production connection string and `npm run deploy`, and verify the live URL.Scaffold a TypeScript "Hello World" Worker:
bunx create-cloudflare prisma-cloudflare-worker --type hello-world --lang=ts --git --no-deploy --no-agentspnpm create cloudflare prisma-cloudflare-worker --type=hello-world --lang=ts --git --no-deploy --no-agentsyarn create cloudflare prisma-cloudflare-worker --type=hello-world --lang=ts --git --no-deploy --no-agentsnpm create cloudflare@latest prisma-cloudflare-worker -- --type=hello-world --lang=ts --git --no-deploy --no-agents╭ Create an application with Cloudflare Step 1 of 3
│
├ In which directory do you want to create your application?
│ dir ./prisma-cloudflare-worker
│
├ What would you like to start with?
│ category Hello World example
│
├ Which template would you like to use?
│ type Worker only
│
├ Which language do you want to use?
│ lang TypeScript
│
╰ Application configured
🎉 SUCCESS Application created successfully!The flags skip the interactive prompts. Drop them if you prefer to answer the questions yourself. Then enter the project:
cd prisma-cloudflare-worker╭ Create an application with Cloudflare Step 1 of 3
│
├ In which directory do you want to create your application?
│ dir ./prisma-cloudflare-worker
│
├ What would you like to start with?
│ category Hello World example
│
├ Which template would you like to use?
│ type Worker only
│
├ Which language do you want to use?
│ lang TypeScript
│
╰ Application configured
🎉 SUCCESS Application created successfully!The flags skip the interactive prompts. Drop them if you prefer to answer the questions yourself. Then enter the project:
cd prisma-cloudflare-workerPrisma ORM has no Prisma Client to generate, no driver adapter package, and no query engine binary. orm init adds everything the Worker needs:
bunx prisma@latest orm init --yes --target postgres --authoring pslpnpm dlx prisma@latest orm init --yes --target postgres --authoring pslyarn dlx prisma@latest orm init --yes --target postgres --authoring pslnpx prisma@latest orm init --yes --target postgres --authoring pslWithout --yes, the command asks for the contract authoring style and the schema path instead; the values above are the defaults. It installs the packages and emits the contract:
{"kind":"step-finished","step":"npm add @prisma/orm-postgres dotenv","outcome":"ok"}
{"kind":"step-finished","step":"npm add -D prisma@latest","outcome":"ok"}
{"kind":"step-finished","step":"npm add -D @prisma/cli-engine@0.6.1","outcome":"ok"}
{"kind":"step-finished","step":"Emit the contract","outcome":"ok"}
{"kind":"result","envelope":{"ok":true,"result":{"target":"postgres","authoring":"psl","schemaPath":"src/prisma/contract.prisma","filesWritten":["src/prisma/contract.prisma","prisma.config.ts","src/prisma/db.ts","prisma-8.md",".env.example","tsconfig.json",".gitignore",".gitattributes","package.json","README.md"]}}}The important files:
src/prisma/contract.prisma: your schema. It starts with aUserand aPostmodel.src/prisma/contract.jsonandsrc/prisma/contract.d.ts: emitted from the contract. The Worker imports both; there is noprisma generatestep.prisma.config.ts: tells the CLI where the contract is and readsDATABASE_URLfrom.env.src/prisma/db.ts: a module-level client for Node.js scripts. The Worker does not import it; step 3 explains why.
orm init also sets "type": "module" in package.json and adds a contract:emit script. Whenever you edit src/prisma/contract.prisma, run npm run contract:emit to refresh the emitted files.
Without --yes, the command asks for the contract authoring style and the schema path instead; the values above are the defaults. It installs the packages and emits the contract:
{"kind":"step-finished","step":"npm add @prisma/orm-postgres dotenv","outcome":"ok"}
{"kind":"step-finished","step":"npm add -D prisma@latest","outcome":"ok"}
{"kind":"step-finished","step":"npm add -D @prisma/cli-engine@0.6.1","outcome":"ok"}
{"kind":"step-finished","step":"Emit the contract","outcome":"ok"}
{"kind":"result","envelope":{"ok":true,"result":{"target":"postgres","authoring":"psl","schemaPath":"src/prisma/contract.prisma","filesWritten":["src/prisma/contract.prisma","prisma.config.ts","src/prisma/db.ts","prisma-8.md",".env.example","tsconfig.json",".gitignore",".gitattributes","package.json","README.md"]}}}The important files:
src/prisma/contract.prisma: your schema. It starts with aUserand aPostmodel.src/prisma/contract.jsonandsrc/prisma/contract.d.ts: emitted from the contract. The Worker imports both; there is noprisma generatestep.prisma.config.ts: tells the CLI where the contract is and readsDATABASE_URLfrom.env.src/prisma/db.ts: a module-level client for Node.js scripts. The Worker does not import it; step 3 explains why.
orm init also sets "type": "module" in package.json and adds a contract:emit script. Whenever you edit src/prisma/contract.prisma, run npm run contract:emit to refresh the emitted files.
Add the nodejs_compat flag to wrangler.jsonc. It gives the pg driver inside @prisma/orm-postgres the node:net and node:tls modules it needs to open a PostgreSQL connection from the Worker:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "prisma-cloudflare-worker",
"main": "src/index.ts",
"compatibility_date": "2026-09-07",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"enabled": true
},
"upload_source_maps": true
}Create a .env file with your PostgreSQL connection string. If you do not have a database, npx create-db@latest creates a Prisma Postgres database and prints a connection string and a claim URL you can open to keep it.
DATABASE_URL="postgres://user:password@localhost:5432/mydb"Both tools read this one file: the Prisma CLI through prisma.config.ts, and wrangler dev directly, which exposes the value to the Worker as env.DATABASE_URL.
bunx prisma db initpnpm prisma db inityarn prisma db initnpx prisma db init"summary": "Applied 5 operation(s) across 1 space(s), database signed"db init creates the user and post tables from the contract and signs the database, which records that it matches the emitted contract. There is no prisma migrate dev in Prisma ORM; use db update for later schema changes during development and migration plan when you want a checked-in migration.
"summary": "Applied 5 operation(s) across 1 space(s), database signed"db init creates the user and post tables from the contract and signs the database, which records that it matches the emitted contract. There is no prisma migrate dev in Prisma ORM; use db update for later schema changes during development and migration plan when you want a checked-in migration.
Replace src/index.ts with a handler that creates a user and counts all users:
import postgres from "@prisma/orm-postgres/runtime";
import type { Contract } from "./prisma/contract.d";
import contractJson from "./prisma/contract.json" with { type: "json" };
export default {
async fetch(request, env, ctx): Promise<Response> {
const path = new URL(request.url).pathname;
if (path === "/favicon.ico") return new Response("Not found", { status: 404 });
const db = postgres<Contract>({ contractJson, url: env.DATABASE_URL });
try {
const user = await db.orm.public.User.create({
email: `user-${Math.ceil(Math.random() * 1000)}@prisma.io`,
name: "Jon Doe",
});
const { total } = await db.orm.public.User.aggregate((a) => ({ total: a.count() }));
return new Response(
`Created new user: ${user.name} (${user.email}).\nNumber of users in the database: ${total}.\n`,
);
} finally {
ctx.waitUntil(db.close());
}
},
} satisfies ExportedHandler<Env>;Three things to notice:
- The client is created inside
fetch, with the connection string fromenv, and closed after the response withctx.waitUntil(db.close()). This is the opposite of a Node.js server, where you keep one client for the life of the process. Workers do not let a socket opened during one request be used by another, so a module-level client works for the first request and hangs the next one. That is why the Worker does not importsrc/prisma/db.ts. - Models are namespace-qualified on PostgreSQL:
db.orm.public.User..create(...)returns the inserted row, database defaults included. contract.jsonis imported withwith { type: "json" }. Wrangler bundles it into the Worker.
wrangler dev reads DATABASE_URL from .env. Regenerate the Worker types so Env declares it:
bunx wrangler typespnpm dlx wrangler typesyarn dlx wrangler typesnpx wrangler types✨ Types written to worker-configuration.d.tsworker-configuration.d.ts now contains DATABASE_URL: string on Env, and npx tsc --noEmit passes.
✨ Types written to worker-configuration.d.tsworker-configuration.d.ts now contains DATABASE_URL: string on Env, and npx tsc --noEmit passes.
bun run devpnpm run devyarn devnpm run devUsing secrets defined in .env
Your Worker has access to the following bindings:
Binding Resource Mode
env.DATABASE_URL ("(hidden)") Environment Variable local
⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8787Open http://localhost:8787 or call it from another terminal. Each request creates a user:
curl http://localhost:8787Created new user: Jon Doe (user-241@prisma.io).
Number of users in the database: 16.Call it again and the count goes up by one. The queries run inside workerd, the same runtime Cloudflare uses in production.
Using secrets defined in .env
Your Worker has access to the following bindings:
Binding Resource Mode
env.DATABASE_URL ("(hidden)") Environment Variable local
⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8787Open http://localhost or call it from another terminal. Each request creates a user:
curl http://localhost:8787Created new user: Jon Doe (user-241@prisma.io).
Number of users in the database: 16.Call it again and the count goes up by one. The queries run inside workerd, the same runtime Cloudflare uses in production.
4.1. Store the connection string as a secret
Section titled “4.1. Store the connection string as a secret”Wrangler does not upload .env. Store the production connection string as a Worker secret instead. Log in first if you have not (npx wrangler login opens a browser), then run:
bunx wrangler secret put DATABASE_URLpnpm dlx wrangler secret put DATABASE_URLyarn dlx wrangler secret put DATABASE_URLnpx wrangler secret put DATABASE_URLPaste the connection string when prompted. In production, the database must accept TCP connections with TLS from Cloudflare's network. A Prisma Postgres direct connection string (postgres://...@db.prisma.io:5432/postgres?sslmode=require) works as is and pools connections on the database side, so a Worker creating one client per request stays within the connection limit.
Paste the connection string when prompted. In production, the database must accept TCP connections with TLS from Cloudflare's network. A Prisma Postgres direct connection string (postgres://...@db.prisma.io:5432/postgres?sslmode=require) works as is and pools connections on the database side, so a Worker creating one client per request stays within the connection limit.
bun run deploypnpm run deployyarn deploynpm run deployWrangler bundles the Worker (about 1.4 MB, 300 KB gzipped, with Prisma ORM included) and prints the live URL, https://prisma-cloudflare-worker.<your-subdomain>.workers.dev. Open it: the Worker creates a user in your production database on every request, exactly as it did locally. The upload itself was not run while validating this guide; the bundle size comes from wrangler deploy --dry-run, and the production path (TLS to Prisma Postgres from inside workerd) was verified with wrangler dev.
Wrangler bundles the Worker (about 1.4 MB, 300 KB gzipped, with Prisma ORM included) and prints the live URL, https://prisma-cloudflare-worker.<your-subdomain>.workers.dev. Open it: the Worker creates a user in your production database on every request, exactly as it did locally. The upload itself was not run while validating this guide; the bundle size comes from wrangler deploy --dry-run, and the production path (TLS to Prisma Postgres from inside workerd) was verified with wrangler dev.
If your database is not Prisma Postgres, Hyperdrive keeps a warm connection pool close to your database so each request does not pay for a new TCP and TLS handshake. Create a Hyperdrive config from your connection string:
bunx wrangler hyperdrive create prisma-cloudflare-worker --connection-string="postgres://user:password@host:5432/database"pnpm dlx wrangler hyperdrive create prisma-cloudflare-worker --connection-string="postgres://user:password@host:5432/database"yarn dlx wrangler hyperdrive create prisma-cloudflare-worker --connection-string="postgres://user:password@host:5432/database"npx wrangler hyperdrive create prisma-cloudflare-worker --connection-string="postgres://user:password@host:5432/database"Add the binding it prints to wrangler.jsonc. The localConnectionString is what wrangler dev uses instead of Hyperdrive. The hyperdrive create command was not run while validating this guide; the local binding below was:
{ "compatibility_flags": ["nodejs_compat"], "hyperdrive": [ { "binding": "HYPERDRIVE", "id": "<id printed by hyperdrive create>", "localConnectionString": "postgres://user:password@localhost:5432/mydb" } ]}Run npx wrangler types again, then pass the Hyperdrive connection string to the client instead of env.DATABASE_URL:
const db = postgres<Contract>({ contractJson, url: env.HYPERDRIVE.connectionString });Everything else in the handler stays the same. The localConnectionString must include a password, even for a local server that does not check one; otherwise wrangler dev refuses to start with You must provide a password.
Add the binding it prints to wrangler.jsonc. The localConnectionString is what wrangler dev uses instead of Hyperdrive. The hyperdrive create command was not run while validating this guide; the local binding below was:
{
"compatibility_flags": ["nodejs_compat"],
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "<id printed by hyperdrive create>",
"localConnectionString": "postgres://user:password@localhost:5432/mydb"
}
]
}Run npx wrangler types again, then pass the Hyperdrive connection string to the client instead of env.DATABASE_URL:
const db = postgres<Contract>({ contractJson, url: env.HYPERDRIVE.connectionString });Everything else in the handler stays the same. The localConnectionString must include a password, even for a local server that does not check one; otherwise wrangler dev refuses to start with You must provide a password.
Run npx prisma@latest init once to install the Prisma ORM skills for your coding agent and keep them matching your installed packages. Prompts that map to this guide:
- "Using the prisma-8 skill, add a
GET /usersroute to the Worker that returns all users as JSON." - "Add a
POST /usersroute that creates a user from the request body and returns 201." - "Add a
published Boolean @default(false)field toPostinsrc/prisma/contract.prisma, emit the contract, and update the database withdb update."
- Learn the fundamentals: filtering, sorting, pagination, and writes.
- Change the schema in
src/prisma/contract.prisma, then runnpm run contract:emitandnpx prisma db update. - Use Hono for routing on Workers; the Hono guide shows the same per-route query pattern.
- Cloudflare Workers documentation and the Node.js compatibility reference.
- Read the Prisma ORM overview for the concepts behind contracts and typed queries.