Project structure
An AWS Lambda function is a handler plus everything it imports, bundled into one file. Keeping the database setup in its own module makes the reuse rule visible:
src/
db/schema.ts tables and relations
db/index.ts client at module scope, tiny pool
handlers/posts.ts API Gateway handler
handlers/migrate.ts optional: runs migrations on demand
kastor.config.ts for the CLI in CI
kastor/migrations/
template.yaml SAM template (or CDK, Serverless Framework, SST)npm install kastor-orm pg
npm install -D esbuild @types/aws-lambda
npx kastor-orm init --db postgresA client that survives warm invocations
Code at module scope runs once per execution environment, at cold start. Everything created there, including the pool and its open connection, is reused by later invocations on the same environment. The pool should be tiny: each concurrent environment holds its own, and a burst of traffic multiplies them.
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './schema'
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 1,
idleTimeoutMillis: 60_000,
connectionTimeoutMillis: 5_000,
})
export const db = kastor({ adapter: postgres(pool), schema, relations })Put RDS Proxy (or PgBouncer, or the pooler of Neon or Supabase) between Lambda and the database. It holds a small set of real connections and lends them to whichever environments are active, so a spike to 500 concurrent invocations does not become 500 database connections. With RDS Proxy in front, max: 1 per environment is usually enough.
Environment variables and secrets
The simplest setup passes DATABASE_URL as an encrypted environment variable. If the credentials live in Secrets Manager, fetch them once at cold start with top level await, which Node.js Lambda runtimes support for ES modules, and build the client after:
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './schema'
const sm = new SecretsManagerClient({})
const secret = await sm.send(new GetSecretValueCommand({ SecretId: process.env.DB_SECRET_ARN }))
const { username, password, host, port, dbname } = JSON.parse(secret.SecretString ?? '{}') as Record<string, string>
export const db = kastor({
adapter: postgres(new pg.Pool({ user: username, password, host, port: Number(port), database: dbname, max: 1, ssl: { rejectUnauthorized: true } })),
schema,
relations,
})The handler
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'
import { isKastorError } from 'kastor-orm'
import { db } from '../db'
const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
statusCode,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
try {
if (event.requestContext.http.method === 'GET') {
const posts = await db.posts.findMany({
where: { published: true },
include: { author: { select: { name: true } } },
orderBy: { id: 'desc' },
take: Number(event.queryStringParameters?.take ?? 20),
})
return json(200, posts)
}
const body = JSON.parse(event.body ?? '{}') as { title: string; authorId: number }
return json(201, await db.posts.create({ data: body }))
} catch (e) {
if (isKastorError(e)) {
const status = e.code === 'UNIQUE_VIOLATION' ? 409 : e.code === 'FOREIGN_KEY_VIOLATION' ? 422 : e.code === 'CONNECTION_FAILED' ? 503 : 400
return json(status, { error: e.code, message: e.message })
}
throw e
}
}Do not call db.$disconnect() at the end of the handler; that throws away the connection the next invocation would reuse. When Lambda retires the environment, the connection goes with it, and the pooler cleans up.
Bundling without an engine
kastor compiles queries in JavaScript and has no binary to download or ship in a layer. Bundle each handler with esbuild; mark pg-native as external, because pg only loads it when asked:
npx esbuild src/handlers/posts.ts --bundle --platform=node --target=node20 --format=esm \
--outfile=dist/posts.mjs --external:pg-native --external:@aws-sdk/*Resources:
PostsFunction:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs20.x
Handler: posts.handler
CodeUri: dist/
MemorySize: 512
Timeout: 10
Environment:
Variables:
DATABASE_URL: '{{resolve:secretsmanager:app/db:SecretString:url}}'
VpcConfig:
SecurityGroupIds: [sg-0123456789abcdef0]
SubnetIds: [subnet-0123456789abcdef0, subnet-0fedcba9876543210]
Events:
Api:
Type: HttpApiA database in a VPC means the function must run in that VPC too, as above. The AWS SDK v3 is part of the Node.js runtime, which is why it is external.
Migrations before the new version
Run migrations from CI before deploying the functions, using a direct (unpooled) URL that can reach the database, for example from a runner inside the VPC:
npx kastor migrate deploy
sam build && sam deployIf CI cannot reach the database, ship a small migration function in the same VPC and invoke it as a deploy step. deploy from kastor-orm/migrate is the engine behind the CLI command. Copy kastor/migrations next to the bundle so the function can read it:
import pg from 'pg'
import { postgres } from 'kastor-orm/postgres'
import { deploy } from 'kastor-orm/migrate'
export async function handler() {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_DIRECT_URL, max: 1 })
try {
const { applied } = await deploy(postgres(pool), './migrations')
return { applied: applied.map((m) => m.id) }
} finally {
await pool.end()
}
}Cold starts and concurrency
kastor adds little to cold starts: there is no engine to load, and creating the client does not open a connection until the first query. To keep the first request fast, you can issue a cheap query during initialization, which Lambda runs before the first invocation. With provisioned concurrency, that warm up happens before traffic arrives.
Production checklist
Before the first deploy, check these points. Each one prevents a problem that otherwise shows up only under real traffic, usually as exhausted connections, a leaked secret or a schema that does not match the code that is running:
- The client is created at module scope, never inside the handler.
- Each pool has
max1 or 2, behind RDS Proxy or another pooler. - Connection timeouts are shorter than the function timeout.
- Migrations run before the new functions are published.
- The bundle marks
pg-nativeand the AWS SDK as external.
Pitfalls
- Pools larger than 1 or 2. Concurrency multiplies them. Without a pooler, a traffic spike exhausts
max_connections. - Long transactions. A transaction holds its connection; with
max: 1, the handler cannot run other queries until it ends. Keep callbacks short. - Timeouts. Set
connectionTimeoutMillisbelow the function timeout so a database outage returns a cleanCONNECTION_FAILEDerror instead of a Lambda timeout. - Bigint in JSON.
JSON.stringifythrows onbigint; convert big keys before returning them.
Questions people ask
Q1Should I create the database client inside the Lambda handler?
No. Create it at module scope. Lambda reuses the execution environment for warm invocations, so a client created outside the handler keeps its connection for the next request instead of reconnecting every time.
Q2How many connections should each Lambda use?
One or two. Each concurrent execution environment has its own pool, so set max: 1 or 2 and let RDS Proxy, PgBouncer or your provider's pooler multiplex them onto the database.
Q3Does kastor need a binary or layer on Lambda?
No. kastor is plain JavaScript with no query engine. Bundle it with esbuild like any dependency; only native drivers would need care, and pg, mysql2 and mssql are pure JavaScript.
Q4Can I use the Aurora Data API?
Not directly. kastor talks to databases through drivers such as pg and mysql2, and the Data API is an HTTP service rather than a driver. Connect to Aurora with the regular driver, ideally through RDS Proxy.