Skip to content

Cloudflare Hyperdrive

This example provisions a Neon Postgres database, a Hyperdrive connection, and a Better Auth Worker. Hyperdrive provides pooled access to the origin; it does not create the origin database itself.

Terminal window
bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely pg

Configure Cloudflare and Neon through Alchemy profiles, or supply NEON_API_KEY for Neon. If you only need direct Neon access, the Neon serverless layer avoids Hyperdrive and pg.

src/auth-database.ts
import { CloudflareHyperdrive } from "@alchemy.run/better-auth/CloudflareHyperdrive";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Neon from "alchemy/Neon";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
const AuthDb = Neon.Project("AuthDb");
export const AuthDatabase = Layer.unwrap(
Effect.gen(function* () {
const db = yield* AuthDb;
const connection = yield* Cloudflare.Hyperdrive.Connection("AuthConnection", {
origin: db.origin,
caching: { disabled: true },
});
return CloudflareHyperdrive(connection, {
migrate: db.connectionUri,
});
}),
);

Keep Hyperdrive caching disabled so session and verification reads cannot return stale data. Migrations connect directly to the origin URL because Hyperdrive’s runtime connection string exists only inside the Worker.

src/worker.ts
import { BetterAuth } from "@alchemy.run/better-auth";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { AuthDatabase } from "./auth-database.ts";
export default class AuthApi extends Cloudflare.Worker<AuthApi>()(
"AuthApi",
{
main: import.meta.url,
compatibility: { flags: ["nodejs_compat"] },
},
Effect.gen(function* () {
const auth = yield* BetterAuth({
basePath: "/api/auth",
emailAndPassword: { enabled: true },
});
return { fetch: auth.fetch };
}).pipe(Effect.provide(AuthDatabase)),
) {}

The layer registers Hyperdrive’s native binding and opens a driver pool inside each request, never during Worker construction. Better Auth’s signing secret is generated and bound automatically.

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Neon from "alchemy/Neon";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import AuthApi from "./src/worker.ts";
export default Alchemy.Stack(
"BetterAuthHyperdrive",
{
providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const api = yield* AuthApi;
return { url: api.url.as<string>() };
}),
);

Preserve and protect the local .alchemy state directory so later deployments retain resource identities and the signing secret.

Terminal window
bunx alchemy deploy

The deployer must reach the origin with schema-changing permissions; the migration completes before the Worker is updated. Set AUTH_ORIGIN to the printed URL and verify the endpoint:

Terminal window
curl -i "$AUTH_ORIGIN/api/auth/get-session"

Omitting the database layer’s migrate source disables automatic migration support. In that case, leave Better Auth’s migrate unset or false and manage the schema yourself; explicitly requesting migrate: true fails.

For an existing MySQL origin, install its driver instead of pg:

Terminal window
bun add mysql2

Configure the Hyperdrive resource with that MySQL origin, select dialect: "mysql" in CloudflareHyperdrive, and supply the origin’s deploy-resolvable URL as migrate. Keep caching disabled; changing the dialect alone neither converts the Neon origin above nor moves existing auth records.

The pool option passes through to the selected driver, and migration credentials may come from a resource Output without binding them to the Worker. See configuration, migrations, the upgrade guide, and the CloudflareHyperdrive reference.