Skip to content

MySQL

This example connects a Better Auth Lambda to an existing MySQL database over TCP with TLS. The database, credentials, and network access must already exist.

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

Configure AWS through an Alchemy profile and set DATABASE_URL in the deploying environment. Both the deployer and Lambda must be able to reach that URL.

src/auth-database.ts
import { MySQL } from "@alchemy.run/better-auth/MySQL";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
export const AuthDatabase = Layer.unwrap(
Effect.gen(function* () {
const url = yield* Config.Redacted("DATABASE_URL");
return MySQL(url, {
pool: { ssl: { rejectUnauthorized: true } },
});
}),
);

The URL is bound during host construction; the mysql2 pool opens per invocation and closes when it settles. The TLS configuration suits hosted services such as PlanetScale; provide your database’s CA when it is not covered by the system trust store.

src/function.ts
import { BetterAuth } from "@alchemy.run/better-auth";
import * as AWS from "alchemy/AWS";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import { AuthDatabase } from "./auth-database.ts";
export default class AuthApi extends AWS.Lambda.Function<AuthApi>()(
"AuthApi",
{
main: import.meta.url,
functionUrl: true,
memorySize: 512,
timeout: Duration.seconds(30),
build: { install: ["mysql2"] },
},
Effect.gen(function* () {
const auth = yield* BetterAuth({
basePath: "/api/auth",
emailAndPassword: { enabled: true },
});
return { fetch: auth.fetch };
}).pipe(Effect.provide(AuthDatabase)),
) {}

The function exposes Better Auth at /api/auth through a public Function URL. The driver is installed in the deployment artifact, and the signing secret is generated and bound automatically.

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import AuthApi from "./src/function.ts";
export default Alchemy.Stack(
"BetterAuthMySQL",
{
providers: AWS.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const api = yield* AuthApi;
return { url: api.functionUrl };
}),
);

Preserve and protect the local .alchemy state directory across deployments. The example assumes network access without VPC attachment; a private database also needs Lambda VPC and security-group configuration.

Terminal window
bunx alchemy deploy

Automatic migrations use the resolved URL and the same pool options before updating the function. The deployer needs schema-changing permissions; migrations create auth tables inside the existing database.

Set AUTH_ORIGIN to the printed Function URL and check the endpoint:

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

Set DATABASE_MIGRATION_URL alongside the runtime URL and replace src/auth-database.ts with this file:

src/auth-database.ts
import { MySQL } from "@alchemy.run/better-auth/MySQL";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
export const AuthDatabase = Layer.unwrap(
Effect.gen(function* () {
const url = yield* Config.Redacted("DATABASE_URL");
const migrationUrl = yield* Config.Redacted("DATABASE_MIGRATION_URL");
return MySQL(url, {
migrate: migrationUrl,
pool: { ssl: { rejectUnauthorized: true } },
});
}),
);

Configuration read during host construction is bound into its environment, including this migration URL. Use a deploy-only resource Output instead when the runtime must not receive privileged migration credentials.

For Workers, prefer Hyperdrive with a MySQL origin and nodejs_compat. Changing adapters does not transfer users or sessions between databases.

Review MySQL index and string-length constraints in the 1.6 to 1.7 upgrade guide before upgrading a populated database. See configuration, migrations, and the MySQL reference.