RDS & Aurora
Aurora is AWS’s managed Postgres/MySQL. Bringing it up by hand
means a secret, a subnet group, a cluster, instances, and
optionally a proxy — alchemy wraps all of that in the
Aurora helper, one call that returns every underlying
DB* resource so you can drop down to the canonical surface
(DBCluster, DBInstance, DBProxy, …) whenever you outgrow
the defaults.
At runtime there are two ways in: the Connect binding,
which resolves host, port, and credentials from Secrets Manager
so you can use a normal Postgres driver like pg, and the
Data API (AWS.RDSData.*), which executes SQL over an HTTPS
endpoint with no database connection at all.
Network prerequisite
Section titled “Network prerequisite”Aurora lives inside a VPC. If you don’t have one, the
Network helper builds the standard
public/private layout in one call; two security groups let the
function reach the database on the Postgres port and nothing
else:
const network = yield* AWS.EC2.Network("Network", { cidrBlock: "10.0.0.0/16", availabilityZones: 2, nat: "single",});
const functionSecurityGroup = yield* AWS.EC2.SecurityGroup( "FunctionSecurityGroup", { vpcId: network.vpcId, description: "Security group for the service function", },);
const databaseSecurityGroup = yield* AWS.EC2.SecurityGroup( "DatabaseSecurityGroup", { vpcId: network.vpcId, description: "Security group for the Aurora cluster", ingress: [ { ipProtocol: "tcp", fromPort: 5432, toPort: 5432, referencedGroupId: functionSecurityGroup.groupId, description: "Allow the function to reach Aurora PostgreSQL", }, ], },);See VPC & networking for what Network
creates and how to build the same layout by hand.
The Aurora helper
Section titled “The Aurora helper”Aurora needs only the subnets and
security groups the database should live in:
const db = yield* AWS.RDS.Aurora("Database", { subnetIds: network.privateSubnetIds, securityGroupIds: [databaseSecurityGroup.groupId],});That one call creates:
- a Secrets Manager secret with a generated 32-character
password for the admin user (
appby default), - a DB subnet group over the subnets you passed,
- an Aurora PostgreSQL cluster (
aurora-postgresql) with a database namedapp, storage snapshots tagged, and the Data API enabled (dataApi: falseto turn it off), - one Serverless v2 writer instance scaling between 0.5 and 1 ACU.
The result exposes every underlying resource — db.secret,
db.subnetGroup, db.cluster, db.writer, db.readers,
and db.proxy when enabled — so expanding into the low-level
AWS.RDS.DB* surface never means rewriting the stack.
Readers, scaling, and a proxy
Section titled “Readers, scaling, and a proxy”Grow the same call as needs grow:
const db = yield* AWS.RDS.Aurora("Database", { subnetIds: network.privateSubnetIds, securityGroupIds: [databaseSecurityGroup.groupId], readers: 2, proxy: true, scaling: { minCapacity: 0.5, maxCapacity: 4 },});readersadds read replicas alongside the writer, with promotion tiers set so failover order is deterministic.proxy: truewires up an RDS Proxy — the IAM role that reads the secret, the proxy itself (TLS required by default), and a target group pointed at the cluster. Pass an object instead oftrueto tune auth, the target group, or add an extra endpoint.scalingsets the Serverless v2 ACU range; for provisioned instances setinstanceClass: "db.r6g.large"instead of the defaultdb.serverless.
Connect from Lambda with pg
Section titled “Connect from Lambda with pg”The Connect binding resolves
connection settings — host, port, database, username, password —
for a cluster, proxy, or proxy endpoint. Credentials are read
from Secrets Manager at request time, so they never land in
environment variables, and the binding attaches the
secretsmanager:GetSecretValue IAM statement to the function
automatically.
Wrap it in an Effect service so the rest of your code depends on
query, not on Aurora:
import * as AWS from "alchemy/AWS";import * as Context from "effect/Context";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { Client } from "pg";import { Network } from "./network.ts";
export class SqlError extends Data.TaggedError("SqlError")<{ readonly message: string; readonly cause?: unknown;}> {}
export class Database extends Context.Service< Database, { query( statement: string, ): Effect.Effect<ReadonlyArray<Record<string, unknown>>, SqlError>; }>()("Database") {}
export const DatabaseAurora = Layer.effect( Database, Effect.gen(function* () { const { vpc, databaseSecurityGroup } = yield* Network;
const db = yield* AWS.RDS.Aurora("Database", { subnetIds: vpc.privateSubnetIds, securityGroupIds: [databaseSecurityGroup.groupId], });
const connect = yield* AWS.RDS.Connect(db.cluster, { secret: db.secret, database: "app", });
return Database.of({ query: (statement) => connect.pipe( Effect.catch((cause) => Effect.fail( new SqlError({ message: "Failed to resolve connection", cause }), ), ), Effect.flatMap((connection) => Effect.tryPromise({ try: async () => { const client = new Client({ host: connection.host, port: connection.port, database: connection.database, user: connection.username, password: connection.password, ssl: connection.ssl ? { rejectUnauthorized: false } : undefined, }); await client.connect(); try { return (await client.query(statement)).rows; } finally { await client.end(); } }, catch: (cause) => new SqlError({ message: `Failed to execute: ${statement}`, cause }), }), ), ), }); }),);yield* AWS.RDS.Connect(db.cluster, ...) runs in the function’s
init phase and returns an inner Effect; each yield* of that
inner Effect at runtime resolves fresh ConnectionInfo from the
secret. Network here is the same network from the prerequisite
section, wrapped in its own layer (see the
examples/aws-rds
for the full three-file layout).
The Lambda function provides both layers plus the
AWS.RDS.ConnectHttp implementation and serves queries over
HTTP:
import * as AWS from "alchemy/AWS";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Database, DatabaseAurora } from "./database.ts";import { NetworkLive } from "./network.ts";
export default class Api extends AWS.Lambda.Function<Api>()( "Api", { main: import.meta.url, runtime: "nodejs24.x" }, Effect.gen(function* () { const db = yield* Database;
return { fetch: db.query("select now()::text as now").pipe( Effect.flatMap((rows) => HttpServerResponse.json(rows[0] ?? null)), Effect.catchTag("SqlError", (error) => HttpServerResponse.json({ error: error.message }, { status: 500 }), ), ), }; }).pipe( Effect.provide( Layer.provideMerge( Layer.mergeAll(DatabaseAurora), Layer.mergeAll(NetworkLive, AWS.RDS.ConnectHttp), ), ), ),) {}The handler only sees Database — swap DatabaseAurora for a
layer backed by Neon or a local Postgres and nothing
above it changes.
Yield the function in your Stack as usual:
import * as Alchemy from "alchemy";import * as AWS from "alchemy/AWS";import * as Effect from "effect/Effect";import Api from "./src/api.ts";
export default Alchemy.Stack( "MyApp", { providers: AWS.providers(), state: AWS.state() }, Effect.gen(function* () { const api = yield* Api; return { url: api.functionUrl }; }),);To reach a private cluster over a direct connection, the Lambda
function also accepts a vpc prop
(vpc: { subnetIds, securityGroupIds }) that attaches it to the
VPC and adds the AWSLambdaVPCAccessExecutionRole managed
policy for you.
The Data API
Section titled “The Data API”Aurora’s Data API executes SQL over an HTTPS endpoint — no
connection pool, no driver. The Aurora helper enables it by
default, and the AWS.RDSData bindings expose it the same way
as any other capability:
// initconst execute = yield* AWS.RDSData.ExecuteStatement(db.cluster, { secret: db.secret, database: "app",});
// runtimeconst result = yield* execute({ sql: "select now()" });Provide AWS.RDSData.ExecuteStatementHttp as a layer on the
function. The binding attaches both the
rds-data:ExecuteStatement and secret-read IAM statements —
no policy JSON to write.
Transactions and batches follow the same shape:
BatchExecuteStatement, BeginTransaction, CommitTransaction,
and RollbackTransaction, each with a matching *Http layer.
Where next
Section titled “Where next”- VPC & networking — the
Networkhelper and the primitives underneath it. - Lambda — the runtime this page binds Aurora into.
- Secrets & env — how the generated secret and GetSecretValue binding this page relies on actually work.
- Neon — serverless Postgres with branching, no VPC required.
- PlanetScale — horizontally sharded MySQL and Postgres.
Reference:
Aurorareference — every prop on the helper.ConnectreferenceExecuteStatementreference