Updated February 2026: This post has been modernized to reflect significant changes to the companion repo — including IAM database authentication, removal of the EC2 bastion host, upgraded runtimes, and cdk-nag security checks. See Modernizing CDK Project for the full story on what changed and why.
Running a database in a private subnet is a common pattern, but connecting to it from Lambda requires some deliberate networking. In this post, we'll walk through a CDK stack that provisions a PostgreSQL RDS instance in an isolated subnet, initializes it with a custom resource, and queries it on a schedule using Lambda — all without any SSH bastion or direct internet exposure.
The full source is at github.com/schuettc/cdk-private-rds-with-lambda.
Architecture
The setup is straightforward:
- A VPC with isolated, private-with-egress, and public subnets
- A PostgreSQL RDS instance in the isolated subnet
- A Lambda-backed custom resource that creates the initial table
- A scheduled Lambda that writes data to the database via IAM authentication
- An EventBridge rule that triggers the Lambda on a cron schedule
VPC
We need three subnet tiers. The RDS instance goes in PRIVATE_ISOLATED — no internet access at all. The Lambda functions go in PRIVATE_WITH_EGRESS so they can reach AWS APIs (for IAM auth token generation) through the NAT gateway. We also create a shared security group that allows PostgreSQL traffic internally.
import { Vpc, SubnetType, SecurityGroup, Port } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
export class VPC extends Construct {
public vpc: Vpc;
public securityGroup: SecurityGroup;
constructor(scope: Construct, id: string) {
super(scope, id);
this.vpc = new Vpc(this, 'VPC', {
natGateways: 1,
subnetConfiguration: [
{ cidrMask: 24, name: 'Private', subnetType: SubnetType.PRIVATE_ISOLATED },
{ cidrMask: 24, name: 'PrivateWithEgress', subnetType: SubnetType.PRIVATE_WITH_EGRESS },
{ cidrMask: 24, name: 'Public', subnetType: SubnetType.PUBLIC },
],
});
this.securityGroup = new SecurityGroup(this, 'QuerySecurityGroup', {
vpc: this.vpc,
description: 'Security Group for Query',
allowAllOutbound: true,
});
this.securityGroup.connections.allowInternally(Port.tcp(5432));
}
}
The allowInternally(Port.tcp(5432)) call ensures that any resource sharing this security group can talk to any other resource in the same group on the PostgreSQL port. This is cleaner than adding individual ingress rules between each Lambda and the database.
RDS Instance
The database runs PostgreSQL 16.6 on a Graviton instance in the isolated subnet. Two flags are critical here: iamAuthentication: true and storageEncrypted: true.
this.database = new DatabaseInstance(this, 'database', {
engine: DatabaseInstanceEngine.postgres({
version: PostgresEngineVersion.of('16.6', '16.6'),
}),
vpc: props.vpc,
vpcSubnets: { subnetType: SubnetType.PRIVATE_ISOLATED },
instanceType: InstanceType.of(InstanceClass.BURSTABLE4_GRAVITON, InstanceSize.LARGE),
multiAz: false,
allowMajorVersionUpgrade: true,
autoMinorVersionUpgrade: true,
backupRetention: Duration.days(21),
securityGroups: [props.securityGroup],
iamAuthentication: true,
storageEncrypted: true,
});
IAM authentication means our Lambda functions won't need to retrieve passwords from Secrets Manager at runtime. Instead, they'll generate short-lived authentication tokens using the RDS API. Storage encryption is required for IAM auth to work — RDS enforces this.
IAM Database Authentication
Traditional RDS access involves storing credentials in Secrets Manager and retrieving them at connection time. IAM database authentication replaces that flow. Instead of a password, the Lambda generates a temporary token using rds.generate_db_auth_token(), which is valid for 15 minutes and tied to the caller's IAM identity.
On the CDK side, we grant the Lambda permission to connect:
props.dataBase.grantConnect(this.queryLambda, 'postgres');
This creates an IAM policy allowing rds-db:connect for the postgres database user. The Lambda also gets the database endpoint and port as environment variables:
environment: {
DB_HOST: props.dataBase.dbInstanceEndpointAddress,
DB_PORT: props.dataBase.dbInstanceEndpointPort,
},
On the Python side, token generation is a single boto3 call:
def get_auth_token():
client = boto3.client("rds")
return client.generate_db_auth_token(
DBHostname=os.environ.get("DB_HOST"),
Port=os.environ.get("DB_PORT"),
DBUsername="postgres",
)
The token is then used as the password in the psycopg2.connect() call with sslmode="require" — SSL is mandatory for IAM auth connections.
Initializing the Database
We use a CDK custom resource to create the table on first deploy. The Lambda runs during cdk deploy and sets up the schema.
const initializeLambda = new Function(this, 'InitializeTableLambda', {
code: Code.fromAsset(path.join(__dirname, 'resources/initialize_lambda'), {
bundling: {
image: Runtime.PYTHON_3_12.bundlingImage,
command: [
'bash', '-c',
'pip install -r requirements.txt -t /asset-output && cp -au . /asset-output',
],
},
}),
runtime: Runtime.PYTHON_3_12,
vpc: props.vpc,
vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
architecture: Architecture.ARM_64,
handler: 'index.handler',
timeout: Duration.minutes(5),
tracing: Tracing.ACTIVE,
environment: {
RDS_SECRET_NAME: props.dataBase.secret?.secretName!,
DB_HOST: props.dataBase.dbInstanceEndpointAddress,
DB_PORT: props.dataBase.dbInstanceEndpointPort,
},
});
props.dataBase.secret?.grantRead(initializeLambda);
props.dataBase.grantConnect(initializeLambda, 'postgres');
initializeLambda.connections.allowToDefaultPort(props.dataBase);
const provider = new Provider(this, 'CustomResourceProvider', {
onEventHandler: initializeLambda,
logRetention: RetentionDays.ONE_WEEK,
});
new CustomResource(this, 'customResourceResult', {
serviceToken: provider.serviceToken,
});
The initializer Lambda uses the same IAM auth pattern. It connects to the database, creates the queries table if it doesn't exist, and returns. Because it's wrapped in a Provider and CustomResource, CDK treats it as a deployment step — the table is ready before the query Lambda ever runs.
Note the bundling block: CDK builds the Python dependencies inside a Docker container matching the Lambda runtime, so native extensions like psycopg2 compile correctly for the ARM64/Linux target.
Query Lambda
The query Lambda writes a timestamp to the database on each invocation:
import os
import datetime
import boto3
import psycopg2
def get_auth_token():
client = boto3.client("rds")
return client.generate_db_auth_token(
DBHostname=os.environ.get("DB_HOST"),
Port=os.environ.get("DB_PORT"),
DBUsername="postgres",
)
def write_data():
connection = None
try:
connection = psycopg2.connect(
database="postgres",
user="postgres",
password=get_auth_token(),
host=os.environ.get("DB_HOST"),
port=os.environ.get("DB_PORT"),
sslmode="require",
)
cursor = connection.cursor()
postgres_insert_query = """INSERT INTO queries (query_date) VALUES (%s)"""
record_to_insert = (datetime.datetime.now(),)
cursor.execute(postgres_insert_query, record_to_insert)
connection.commit()
except (Exception, psycopg2.Error) as error:
print(f"Failed to insert record: {error}")
finally:
if connection:
cursor.close()
connection.close()
def handler(event, context):
try:
write_data()
return True
except Exception as err:
print(f"Error: {err}")
return False
The key detail: sslmode="require". IAM auth tokens only work over SSL connections. If you forget this, you'll get authentication failures that don't obviously point to the SSL requirement.
The CDK construct for this Lambda follows the same pattern as the initializer:
this.queryLambda = new Function(this, 'QueryLambda', {
code: Code.fromAsset(path.join(__dirname, 'resources/query_lambda'), {
bundling: {
image: Runtime.PYTHON_3_12.bundlingImage,
command: [
'bash', '-c',
'pip install -r requirements.txt -t /asset-output && cp -au . /asset-output',
],
},
}),
runtime: Runtime.PYTHON_3_12,
vpc: props.vpc,
vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
architecture: Architecture.ARM_64,
handler: 'index.handler',
timeout: Duration.minutes(5),
tracing: Tracing.ACTIVE,
environment: {
RDS_SECRET_NAME: props.dataBase.secret?.secretName!,
DB_HOST: props.dataBase.dbInstanceEndpointAddress,
DB_PORT: props.dataBase.dbInstanceEndpointPort,
},
});
props.dataBase.secret?.grantRead(this.queryLambda);
props.dataBase.grantConnect(this.queryLambda, 'postgres');
this.queryLambda.connections.allowToDefaultPort(props.dataBase);
Both Lambdas have Tracing.ACTIVE enabled for X-Ray, which gives you end-to-end visibility into the Lambda execution and the outbound database calls.
Scheduling
An EventBridge rule triggers the query Lambda on a daily cron schedule:
new Rule(this, 'Rule', {
schedule: Schedule.cron({ minute: '0', hour: '4' }),
targets: [new LambdaFunction(lambda.queryLambda)],
});
Putting It Together
The stack wires everything up:
export class PrivateRDSWithLambda extends Stack {
constructor(scope: Construct, id: string, props: StackProps = {}) {
super(scope, id, props);
const vpc = new VPC(this, 'VPC');
const rds = new RDS(this, 'RDS', {
vpc: vpc.vpc,
securityGroup: vpc.securityGroup,
});
new Initialize(this, 'Initialize', {
vpc: vpc.vpc,
securityGroup: vpc.securityGroup,
dataBase: rds.database,
});
const lambda = new Lambda(this, 'Lambda', {
vpc: vpc.vpc,
securityGroup: vpc.securityGroup,
dataBase: rds.database,
});
new Rule(this, 'Rule', {
schedule: Schedule.cron({ minute: '0', hour: '4' }),
targets: [new LambdaFunction(lambda.queryLambda)],
});
}
}
Verifying
After deploying with npx cdk deploy, you can test the query Lambda directly from the console or CLI:
aws lambda invoke \
--function-name <QueryLambdaFunctionName> \
--payload '{}' \
response.json
Check CloudWatch Logs for the Lambda execution output. With X-Ray tracing enabled, you can also view the service map in the X-Ray console to see the Lambda-to-RDS connection path.
Conclusion
This pattern — private RDS with Lambda access via IAM authentication — gives you a database that's not reachable from the internet, doesn't require long-lived passwords, and can be fully managed through CDK. The IAM auth approach eliminates an entire class of credential management concerns: no rotation schedules, no secret retrieval latency, no risk of leaked database passwords.
The full source includes cdk-nag security checks and snapshot tests if you want to see how the security posture is validated at synth time.