Most serverless tutorials stop at DynamoDB. Real migrations rarely do -- the application already has a Postgres schema with joins, constraints and reporting queries, and nobody is rewriting it to fit a single-table design. This tutorial covers the part that actually breaks: putting Lambda in front of a relational database without melting the connection pool, and doing it in Serverless Framework V4 configuration you can copy.
The problem in one paragraph
A Lambda function scales by creating more execution environments. Each environment that opens its own database connection is one more connection Postgres has to hold open, and each Postgres connection costs memory (roughly 5-10 MB) on the instance. A db.t4g.medium tops out around 340 connections. Two hundred concurrent Lambdas with a naive new Client() per invocation will exhaust that, and the failure mode is ugly: remaining connection slots are reserved, retries, a queue backing up, and a database that is too busy to accept the connection that would let you fix it.
There are three ways out, and you will usually combine the first two:
- Amazon RDS Proxy -- a managed connection pooler that multiplexes many client connections onto a small number of database connections.
- Reuse connections across invocations -- create the client outside the handler so a warm environment reuses it.
- The Aurora Data API -- an HTTP endpoint, no VPC and no connections at all, at the price of higher per-query latency.
Step 1: Put the function in the VPC (only the ones that need it)
RDS and Aurora live in a VPC. To reach them over the standard Postgres port, the function has to be in that VPC too. In V4 you attach subnets and security groups per function, or once at the provider level:
service: orders-api
provider:
name: aws
runtime: nodejs22.x
region: eu-west-1
vpc:
securityGroupIds:
- ${ssm:/orders/${sls:stage}/lambda-sg}
subnetIds:
- ${ssm:/orders/${sls:stage}/private-subnet-a}
- ${ssm:/orders/${sls:stage}/private-subnet-b}
functions:
createOrder:
handler: src/orders.create
environment:
DB_HOST: ${ssm:/orders/${sls:stage}/proxy-endpoint}
DB_NAME: orders
DB_USER: orders_app
Three rules that save you a support ticket:
- Use private subnets, at least two AZs. A Lambda in a public subnet has no route to the internet and no benefit from being there.
- Security groups are the actual access control. The database security group must allow inbound TCP 5432 from the Lambda security group (source = security group ID, not a CIDR). The Lambda security group needs egress to it.
- Only VPC-attach what needs it. VPC attachment no longer adds meaningful cold-start time, but it does cut the function off from public AWS endpoints unless you add a NAT Gateway or VPC endpoints. A function in a VPC that calls Secrets Manager, S3 or DynamoDB needs interface/gateway endpoints -- or a NAT Gateway at roughly $32/month plus data processing charges. Keep your non-database functions outside the VPC.
Step 2: Front the database with RDS Proxy and IAM auth
RDS Proxy is the difference between "works in staging" and "survives a traffic spike". It holds a warm pool of database connections and hands them to Lambda clients on demand, so 500 concurrent functions can share 30 real connections.
Define it in resources so it ships with the service:
resources:
Resources:
DbProxy:
Type: AWS::RDS::DBProxy
Properties:
DBProxyName: orders-${sls:stage}
EngineFamily: POSTGRESQL
RoleArn: !GetAtt ProxyRole.Arn
Auth:
- AuthScheme: SECRETS
SecretArn: ${ssm:/orders/${sls:stage}/db-secret-arn}
IAMAuth: REQUIRED
ClientPasswordAuthType: POSTGRES_SCRAM_SHA_256
RequireTLS: true
IdleClientTimeout: 1800
VpcSubnetIds:
- ${ssm:/orders/${sls:stage}/private-subnet-a}
- ${ssm:/orders/${sls:stage}/private-subnet-b}
VpcSecurityGroupIds:
- ${ssm:/orders/${sls:stage}/proxy-sg}
With IAMAuth: REQUIRED the function never handles a password. It asks the AWS SDK for a short-lived token and uses that as the Postgres password:
import { Signer } from '@aws-sdk/rds-signer';
import pg from 'pg';
let pool; // module scope: reused by every warm invocation
async function getPool() {
if (pool) return pool;
const signer = new Signer({
region: process.env.AWS_REGION,
hostname: process.env.DB_HOST,
port: 5432,
username: process.env.DB_USER,
});
pool = new pg.Pool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
database: process.env.DB_NAME,
port: 5432,
max: 1, // one connection per execution environment
ssl: { rejectUnauthorized: true },
password: () => signer.getAuthToken(), // refreshed per connection; tokens last 15 min
});
return pool;
}
export const create = async (event) => {
const db = await getPool();
const { rows } = await db.query(
'insert into orders (customer_id, total_cents) values ($1, $2) returning id',
[event.customerId, event.totalCents]
);
return { id: rows[0].id };
};
The IAM policy is narrow -- rds-db:connect on the proxy resource for that database user:
provider:
iam:
role:
statements:
- Effect: Allow
Action: rds-db:connect
Resource: arn:aws:rds-db:${aws:region}:${aws:accountId}:dbuser:${ssm:/orders/${sls:stage}/proxy-resource-id}/orders_app
Four details people get wrong:
max: 1in the client pool. A Lambda environment handles one request at a time. A pool of 10 just holds 9 idle proxy connections.- Never call
pool.end()in the handler. Closing on every invocation defeats the reuse. - Avoid session pinning. RDS Proxy multiplexes only while sessions stay stateless. Prepared statements held across transactions,
SETon the session, temp tables and advisory locks pin a client to a connection for its lifetime and quietly collapse the pool back to one-connection-per-Lambda. Watch theDatabaseConnectionsCurrentlySessionPinnedCloudWatch metric; if it is not near zero, find the offending query. Some ORMs enable prepared statements by default -- innode-postgresavoid named queries, in Prisma use the pgbouncer-compatible connection settings. - Set function
timeoutbelow the client's statement timeout, so a stuck query surfaces as a query error rather than a Lambda timeout that leaves the connection busy.
Step 3: Size the database for spiky, bursty load
Aurora Serverless v2 scales capacity in 0.5 ACU steps and is the natural fit for serverless traffic, but it is not free when idle unless you configure it that way. Two settings decide your bill:
MinCapacity-- since the 0-ACU option shipped, you can scale to zero and pay only storage while idle, at the cost of a resume delay (typically a handful of seconds) on the first query after a pause. Excellent for dev, preview and internal stages; risky for a user-facing login path. Production APIs usually sit at a minimum of 0.5-2 ACU.MaxCapacity-- your blast-radius limit. Set it, alarm onServerlessDatabaseCapacityreaching it, and remember that a bad query plan scales capacity (and cost) rather than failing.
OrdersCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-postgresql
EngineVersion: '16.4'
ServerlessV2ScalingConfiguration:
MinCapacity: 0.5
MaxCapacity: 8
SecondsUntilAutoPause: 3600 # only meaningful when MinCapacity is 0
StorageEncrypted: true
EnableIAMDatabaseAuthentication: true
If your workload is a low-traffic internal tool or an event-driven job, consider the Aurora Data API instead: an HTTPS call, no VPC attachment, no NAT Gateway, no pooling to think about. You pay for it in per-request latency and a clunkier result format, and long-running transactions do not fit. For a nightly reconciliation job it is the cheapest correct answer.
Step 4: Run migrations without a deploy-time footgun
A schema migration is not a Lambda deploy. Two patterns that work:
- A dedicated migrate function invoked from CI after the deploy step, in the same VPC, running your migration tool (
node-pg-migrate, Flyway, Prisma Migrate) against the cluster endpoint -- not the proxy, since migrations use session state and will pin.
functions:
migrate:
handler: src/migrate.handler
timeout: 300
environment:
DB_HOST: ${ssm:/orders/${sls:stage}/cluster-endpoint}
serverless deploy --stage prod
serverless invoke --function migrate --stage prod
- Expand/contract. Deploy the additive migration first (new nullable column), then the code that writes to it, then the backfill, then the constraint, then remove the old column in a later release. This keeps every intermediate state deployable and rollback-safe -- which matters a lot when the deploy unit is a stack of functions that roll back independently of your schema.
Do not run migrations from an application handler at cold start. Twenty environments starting at once will race for the same lock.
Step 5: Watch the right metrics
On day one of production, put four numbers on a dashboard and alarm on the first three:
| Metric | Source | Alarm when |
|---|---|---|
DatabaseConnections | RDS Proxy | > 70% of MaxConnectionsPercent |
DatabaseConnectionsCurrentlySessionPinned | RDS Proxy | > 0 sustained |
ClientConnectionsSetupFailedAuth | RDS Proxy | any |
ServerlessDatabaseCapacity | Aurora | at MaxCapacity for 5 minutes |
Add Lambda Throttles and the function's p95 duration. In our experience, a serverless-plus-Postgres incident almost always shows up on this list ten minutes before customers notice.
The cost traps, briefly
- NAT Gateway for VPC functions that also call AWS APIs. Replace with interface endpoints for Secrets Manager/KMS and gateway endpoints for S3 and DynamoDB (gateway endpoints are free).
- RDS Proxy is billed per vCPU of the underlying instance, per hour -- it is always on. Worth it for a production API; overkill for a single cron job.
- Aurora Serverless v2 minimum capacity running 24/7 across five preview stages. Use 0-ACU auto-pause on non-production, and tear down preview stages with
serverless remove. - Retries into an overloaded database. Set sensible
maximumRetryAttemptson async invocations and use SQS with a dead-letter queue rather than letting a retry storm finish the job the spike started.
Wrapping up
Relational data and Lambda work well together, but only when the connection lifecycle is designed deliberately: functions in private subnets, RDS Proxy with IAM auth in front, one connection per execution environment, migrations out of the request path, and pinning kept at zero. Everything above is plain Serverless Framework V4 configuration -- no plugin required.
If you are moving a Postgres-backed application onto Lambda and want a second pair of eyes on the networking, pooling and migration plan, our migration to serverless infrastructure and serverless architecture design teams do exactly this. Get in touch with your current schema size, peak concurrency and target region and we will tell you which of the three approaches above fits.