Connect to a Database with Hyperdrive
You’ve now wired Durable Objects, hibernatable WebSockets, a Container, a Workflow, and an AI Gateway into your Worker. The last missing piece for most apps is a real database. This tutorial provisions a serverless database and fronts it with Cloudflare Hyperdrive, then binds the connection into your Worker.
Hyperdrive is a Cloudflare-managed pooler that sits between Workers and your origin database. The Worker sees a familiar Postgres / MySQL connection string, but the connection itself is already pooled at the edge — no per-request TCP handshakes, no cold-start connection storms.
Three providers are wired up out of the box. Pick whichever you prefer; the rest of the tutorial follows your selection:
- Neon — serverless Postgres with copy-on-write branching.
- PlanetScale (Postgres) — managed Postgres with branch-per-PR workflows.
- PlanetScale (MySQL) — Vitess-backed MySQL, same branching model.
Add the provider
Section titled “Add the provider”Each database provider is its own providers() layer. Register it
alongside Cloudflare.providers() in your stack:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Neon from "alchemy/Neon";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "MyStack", { providers: Cloudflare.providers(), providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()), state: Alchemy.localState(), }, // ...);Neon’s auth is API-key-based. The first bun alchemy login after
this change adds a Neon step that either reads NEON_API_KEY
from the environment (good for CI) or stores a key under
~/.alchemy/credentials/<profile>/neon-stored.json.
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Planetscale from "alchemy/Planetscale";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "MyStack", { providers: Cloudflare.providers(), providers: Layer.mergeAll(Cloudflare.providers(), Planetscale.providers()), state: Alchemy.localState(), }, // ...);PlanetScale’s auth uses an API token id + secret. bun alchemy login
adds a Planetscale step that either reads PLANETSCALE_API_TOKEN_ID,
PLANETSCALE_API_TOKEN, and PLANETSCALE_ORGANIZATION from the
environment (good for CI) or stores the credentials under
~/.alchemy/credentials/<profile>/planetscale-stored.json.
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Planetscale from "alchemy/Planetscale";import * as Layer from "effect/Layer";
export default Alchemy.Stack( "MyStack", { providers: Cloudflare.providers(), providers: Layer.mergeAll(Cloudflare.providers(), Planetscale.providers()), state: Alchemy.localState(), }, // ...);PlanetScale’s auth uses an API token id + secret. bun alchemy login
adds a Planetscale step that either reads PLANETSCALE_API_TOKEN_ID,
PLANETSCALE_API_TOKEN, and PLANETSCALE_ORGANIZATION from the
environment (good for CI) or stores the credentials under
~/.alchemy/credentials/<profile>/planetscale-stored.json.
Provision the database
Section titled “Provision the database”Create src/Db.ts. The shape is the same across providers — a
top-level database resource, a branch, and (for PlanetScale) a
credentials resource that owns the password.
A Neon.Project is the top-level container. It owns a default
branch, a default role, and the WAL history that copy-on-write
branches fork from:
import * as Neon from "alchemy/Neon";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { const project = yield* Neon.Project("app-db", { region: "aws-us-east-1", });
const branch = yield* Neon.Branch("app-branch", { project, });
return { project, branch };});Neon.Branch is a copy-on-write fork — cheap to create, fast to
destroy, and ideal for preview environments. Each branch has its
own connection string (branch.connectionUri) and a pooled variant
(branch.pooledConnectionUri).
A Planetscale.PostgresDatabase owns the long-lived cluster.
PostgresBranch is a branch off of it (cheap to fork for previews),
and PostgresRole materializes a user + password we can feed to
Hyperdrive:
import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { const database = yield* Planetscale.PostgresDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", });
const branch = yield* Planetscale.PostgresBranch("app-branch", { database, isProduction: false, });
const role = yield* Planetscale.PostgresRole("app-role", { database, branch, inheritedRoles: ["postgres"], });
return { database, branch, role };});The role’s origin (host / port / database / user / password) is
what Hyperdrive consumes — it’s materialized lazily so the actual
password never lands in plan output.
A Planetscale.MySQLDatabase is the long-lived cluster.
MySQLBranch is a branch off of it (cheap to fork for previews),
and MySQLPassword materializes a user + password we can feed to
Hyperdrive:
import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { const database = yield* Planetscale.MySQLDatabase("app-db", { region: { slug: "us-east" }, clusterSize: "PS_10", allowForeignKeyConstraints: true, });
const branch = yield* Planetscale.MySQLBranch("app-branch", { database, isProduction: false, });
const password = yield* Planetscale.MySQLPassword("app-password", { database, branch, role: "readwriter", });
return { database, branch, password };});The password’s origin (host / port / database / user / password)
is what Hyperdrive consumes — it’s materialized lazily so the
actual secret never lands in plan output.
Put Hyperdrive in front
Section titled “Put Hyperdrive in front”Hyperdrive needs an origin describing where your database lives.
Each provider exposes a pre-parsed origin output ({ scheme, host, port, database, user, password }) ready to feed straight in:
import * as Cloudflare from "alchemy/Cloudflare";import * as Neon from "alchemy/Neon";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { const project = yield* Neon.Project("app-db", { region: "aws-us-east-1" }); const branch = yield* Neon.Branch("app-branch", { project }); return { project, branch };});
export const Hyperdrive = Effect.gen(function* () { const { branch } = yield* Db; return yield* Cloudflare.Hyperdrive("app-hyperdrive", { origin: branch.origin, });});branch.origin points at the direct (non-pooled) Neon endpoint —
the recommended target when sitting behind Hyperdrive, since
Hyperdrive already does its own connection pooling.
import * as Cloudflare from "alchemy/Cloudflare";import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { // ... return { database, branch, role };});
export const Hyperdrive = Effect.gen(function* () { const { role } = yield* Db; return yield* Cloudflare.Hyperdrive("app-hyperdrive", { origin: role.origin, });});role.origin is the connection target Hyperdrive will pool. The
origin is an Output, so alchemy orders PostgresDatabase →
PostgresBranch → PostgresRole → Hyperdrive on the deploy graph.
import * as Cloudflare from "alchemy/Cloudflare";import * as Planetscale from "alchemy/Planetscale";import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () { // ... return { database, branch, password };});
export const Hyperdrive = Effect.gen(function* () { const { password } = yield* Db; return yield* Cloudflare.Hyperdrive("app-hyperdrive", { origin: password.origin, });});password.origin is the connection target Hyperdrive will pool —
its scheme is "mysql", so Hyperdrive provisions a MySQL pooler.
The origin is an Output, so alchemy orders MySQLDatabase →
MySQLBranch → MySQLPassword → Hyperdrive on the deploy graph.
Install the client library
Section titled “Install the client library”The Worker uses a Node-compatible driver to talk to Hyperdrive over the binding. Pick the one that matches your engine:
pg — node-postgres:
bun add pgbun add -d @types/pgnpm install pgnpm install -D @types/pgpnpm add pgpnpm add -D @types/pgyarn add pgyarn add -D @types/pgpg — node-postgres:
bun add pgbun add -d @types/pgnpm install pgnpm install -D @types/pgpnpm add pgpnpm add -D @types/pgyarn add pgyarn add -D @types/pgbun add mysql2npm install mysql2pnpm add mysql2yarn add mysql2Bind Hyperdrive to your Worker
Section titled “Bind Hyperdrive to your Worker”Cloudflare.Hyperdrive.bind(...) returns a typed accessor for the
runtime binding — connection string, host, port, user, password,
database — plus a raw escape hatch for libraries that want the
underlying Hyperdrive object:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Client } from "pg";import { Hyperdrive } from "./Db.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.path, compatibility: { // node-postgres needs Node.js APIs to run inside a Worker. flags: ["nodejs_compat"], }, }, Effect.gen(function* () { const hd = yield* Cloudflare.Hyperdrive.bind(Hyperdrive);
return { fetch: Effect.gen(function* () { const connectionString = yield* hd.connectionString; const rows = yield* Effect.promise(async () => { // One client per request — Hyperdrive does the pooling. const client = new Client({ connectionString }); await client.connect(); try { const r = await client.query("SELECT now() as now"); return r.rows; } finally { await client.end().catch(() => {}); } }); return yield* HttpServerResponse.json({ ok: true, rows }); }), }; }), }).pipe(Effect.provide(Cloudflare.HyperdriveConnectionLive)),) {}import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Client } from "pg";import { Hyperdrive } from "./Db.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.path, compatibility: { // node-postgres needs Node.js APIs to run inside a Worker. flags: ["nodejs_compat"], }, }, Effect.gen(function* () { const hd = yield* Cloudflare.Hyperdrive.bind(Hyperdrive);
return { fetch: Effect.gen(function* () { const connectionString = yield* hd.connectionString; const rows = yield* Effect.promise(async () => { // One client per request — Hyperdrive does the pooling. const client = new Client({ connectionString }); await client.connect(); try { const r = await client.query("SELECT now() as now"); return r.rows; } finally { await client.end().catch(() => {}); } }); return yield* HttpServerResponse.json({ ok: true, rows }); }), }; }), }).pipe(Effect.provide(Cloudflare.HyperdriveConnectionLive)),) {}import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { createConnection } from "mysql2/promise";import { Hyperdrive } from "./Db.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.path, compatibility: { // mysql2 needs Node.js APIs to run inside a Worker. flags: ["nodejs_compat"], }, }, Effect.gen(function* () { const hd = yield* Cloudflare.Hyperdrive.bind(Hyperdrive);
return { fetch: Effect.gen(function* () { const connectionString = yield* hd.connectionString; const rows = yield* Effect.promise(async () => { // One connection per request — Hyperdrive does the pooling. const conn = await createConnection(connectionString); try { const [r] = await conn.query("SELECT NOW() as now"); return r; } finally { await conn.end().catch(() => {}); } }); return yield* HttpServerResponse.json({ ok: true, rows }); }), }; }), }).pipe(Effect.provide(Cloudflare.HyperdriveConnectionLive)),) {}Two things to notice (regardless of engine):
- The
nodejs_compatflag is required because the driver uses Node’snetandcryptoAPIs. - The fetch handler opens a fresh connection per request and ends it on the way out. This is intentional — Hyperdrive does the pooling on Cloudflare’s side, so the Worker doesn’t need its own. (We’ll revisit this in the Drizzle tutorial when we want long-lived clients.)
Wire the resources into the stack
Section titled “Wire the resources into the stack”Update alchemy.run.ts to yield Db and Hyperdrive so they
become part of the deploy graph:
import Api from "./src/Api.ts";import { Db, Hyperdrive } from "./src/Db.ts";
export default Alchemy.Stack( "MyStack", { /* ... */ }, Effect.gen(function* () { yield* Db; yield* Hyperdrive; const api = yield* Api; return { url: api.url.as<string>() }; }),);Deploy
Section titled “Deploy”bun alchemy deployWatch the deploy plan: alchemy creates the database, then the branch, then the credentials (for PlanetScale), then the Hyperdrive that points at the connection origin, then the Worker with the Hyperdrive binding attached. After it completes, hit your Worker URL and you should see something like:
{ "ok": true, "rows": [{ "now": "2026-05-04T07:53:48.123Z" }] }You’re now talking to your database from the edge through Hyperdrive. The next tutorial swaps the raw driver client for Drizzle ORM and lets alchemy own migration generation as part of every deploy.