Skip to content

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.

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.

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 (app by default),
  • a DB subnet group over the subnets you passed,
  • an Aurora PostgreSQL cluster (aurora-postgresql) with a database named app, storage snapshots tagged, and the Data API enabled (dataApi: false to 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.

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 },
});
  • readers adds read replicas alongside the writer, with promotion tiers set so failover order is deterministic.
  • proxy: true wires 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 of true to tune auth, the target group, or add an extra endpoint.
  • scaling sets the Serverless v2 ACU range; for provisioned instances set instanceClass: "db.r6g.large" instead of the default db.serverless.

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:

src/database.ts
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:

src/api.ts
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:

alchemy.run.ts
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.

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:

// init
const execute = yield* AWS.RDSData.ExecuteStatement(db.cluster, {
secret: db.secret,
database: "app",
});
// runtime
const 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.

  • VPC & networking — the Network helper 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: