DEV Community

Cover image for The Myth of ‘Just Connect Lambda to RDS’
Steven Leung
Steven Leung

Posted on

The Myth of ‘Just Connect Lambda to RDS’

You and I often see blogs about “connecting your Lambda to RDS is as simple as”...

Often will see blogs on internet with:

  • Connect your lambda to same VPC as RDS
  • Ensure VPC Security Groups correctly configured

This one‑liner manner, the reality is connecting Lambda to RDS is more to it then that, this orchestration is one of the more trickier parts within AWS serverless architecture.

I really dislike this oversimplification as it hides a lot of detail the real engineering challenges. In this blog I hope to offer more substantive deep dive and aim to provide reliably repeatable instructions.

CDK great for blogs and teaching

It would not be infrastructure blog without talking about CDK.
CDK very effective tool for conveying cloud infrastructure concepts in blogs.

  • Code-first approach: Infrastructure is defined using familiar programming languages, making it easier for developers to understand and follow, easy to convey concepts.
  • Readable and maintainable: Readers grasp concepts quickly.
  • Reusable: Code examples can be reused and extended.
  • Immediate feedback: Developers can test examples locally and possibly iterate, ideal for tutorials.

All Up Front

Tutorials tend to introduce larger concepts in small pieces then sewn together at the end

That approach is effective for teaching to beginners because it reduces mental overload and builds confidence.

However this blog is aimed at experienced AWS practitioners who only interested in the RDS/Lambda recipe.

So here, we will do the opposite, introducing the full solution first and then breaking it down into smaller pieces

This tutorial does assume working knowledge of CDK on reader's part.
The source code for this article can be found at Github

export class DatabaseConstruct extends Construct {
    private lambda: lambda.IFunction;

    const stackName = cdk.Stack.of(this).stackName;
    const instanceIdentifier = `${stackName}-rds-instance`;
    const vpcName = `${stackName}-vpc`;
    const lambdaSgName = `${stackName}-lambda-sg`;
    const rdsSgName = `${stackName}-rds-sg`;
    const secretName = `${stackName}-rds-secret`;

    constructor(scope: Construct, id: string) {
        super(scope, id);

    this.vpc = new ec2.Vpc(this, `${stackName}Vpc`, {
                subnetConfiguration: [
                    {
                        name: "Isolated",
                        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
                        cidrMask: 24,
                    },
                ],
                natGateways: 0, // No NAT Gateway
                maxAzs: 2,
                vpcName
            });

    // Security group for Lambda
    this.lambdaSg = new ec2.SecurityGroup(this, `${stackName}LambdaSecurityGroup`, {
                vpc: this.vpc,
                description: "Lambda security group",
                securityGroupName: lambdaSgName,
                allowAllOutbound: false // Do NOT allow all outbound traffic
            });

    // Security group for RDS
    this.rdsSg = new ec2.SecurityGroup(this, `${stackName}RdsSecurityGroup`, {
                vpc: this.vpc,
                description: "RDS security group",
                securityGroupName: rdsSgName,
                allowAllOutbound: false // Do NOT allow all outbound traffic
            });

    this.rdsInstance = new rds.DatabaseInstance(this, `${stackName}RdsInstance`, {
                engine: rds.DatabaseInstanceEngine.postgres({version: rds.PostgresEngineVersion.VER_17_4}),
                vpc: this.vpc,
                instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
                credentials: rds.Credentials.fromGeneratedSecret('postgres', {secretName}),
                multiAz: false,
                allocatedStorage: 20,
                maxAllocatedStorage: 100,
                publiclyAccessible: false, //Do NOT make RDS publiclyAccessible.
                vpcSubnets: {subnetType: ec2.SubnetType.PRIVATE_ISOLATED},
                removalPolicy: cdk.RemovalPolicy.SNAPSHOT,
                deletionProtection: false,
                securityGroups: [this.rdsSg],
                instanceIdentifier,
                storageEncrypted: true,
            });

    this.dbSecret = (this.rdsInstance as rds.DatabaseInstance)?.secret;

    // Add this after VPC creation
    this.vpc.addInterfaceEndpoint('SecretsManagerEndpoint', {
                service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
            });

    this.rdsSg.addIngressRule(this.lambdaSg, ec2.Port.tcp(5432), "Allow Lambda to access RDS");
    this.lambdaSg.addEgressRule(
                ec2.Peer.anyIpv4(),
                ec2.Port.tcp(443),
                "Allow outbound HTTPS to Secrets Manager"
            );
    this.lambdaSg.addEgressRule(this.rdsSg, ec2.Port.tcp(5432), "Allow outbound access to RDS");

    // Get the subnet used by RDS
    // Prevents Cross Zone Data Transfer Charges
    // After VPC creation, select the isolated subnets for RDS
    const rdsSubnets = this.vpc.selectSubnets({subnetType: ec2.SubnetType.PRIVATE_ISOLATED});
        const rdsSubnet = rdsSubnets.subnets[0];

    const role = new iam.Role(this, `${stackName}LambdaExecutionRole`, {
                assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
                description: "Role for Lambda functions to access RDS and Secrets Manager",
                roleName: `${stackName}LambdaExecutionRole`,
                managedPolicies: [
                    iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
                    iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaVPCAccessExecutionRole"),
                ],

                inlinePolicies: {
                    // Allow Lambda to access RDS and Secrets Manager
                    LambdaRdsSecretsManagerPolicy: new iam.PolicyDocument({
                        statements: [
                            new iam.PolicyStatement({
                                effect: iam.Effect.ALLOW,
                                actions: ["secretsmanager:GetSecretValue"],
                                resources: [stage != 'prod'
                                    ? `arn:aws:secretsmanager:ap-southeast-2:${cdk.Stack.of(this).account}:secret:${secretName}*`
                                    : this.dbSecret!.secretFullArn ?? ''
                                ],
                            }),
                            new iam.PolicyStatement({
                                effect: iam.Effect.ALLOW,
                                actions: ["secretsmanager:ListSecrets"],
                                resources: ["*"],
                            }),
                        ],
                    }),
                },
            }
        );

    // Lambda function in VPC
    this.lambda = this.createLamba({
            stackName,
            functionName: "Lambda",
            code: "/dist/",
            description: "Lambda function",
            role,
            rdsSubnet,
            timeout: Duration.seconds(29),
        });

    }
}
Enter fullscreen mode Exit fullscreen mode

Sanity Check

Once successfully deployed and before proceeding, you can use AWS CloudShell to connect to your RDS instance.
CloudShell provides a browser-based terminal with AWS
CLI and common tools pre-installed.

You can connect to your RDS database using the psql command for PostgreSQL or mysql for MySQL, as long as:

The RDS instance is publicly accessible or accessible from the CloudShell VPC.
The security group of your RDS instance allows inbound connections from the CloudShell IP range.
Example for PostgreSQL:

psql --host=<RDS-endpoint> --port=5432 --username=<username> --dbname=<database>
Enter fullscreen mode Exit fullscreen mode

Caveats and Nuances

Because of the simple one line "Just connect your lambda to RDS". A lot of nuances are overlooked. Here I've done the research so you don't have to.

The full solution available above, I won't go over every line of code. I do want to highlight certain lines that would serve as gotcha moments.

1. Be wary of CDK Defaults

// Create VPC
this.vpc = new ec2.Vpc(this, `${stackName}Vpc`, {
subnetConfiguration: [
{
                        name: "Isolated",
                        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
                        cidrMask: 24,
},
],
natGateways: 0, // No NAT Gateway
maxAzs: 2,
vpcName
});

          this.lambdaSg = new ec2.SecurityGroup(this, `${stackName}LambdaSecurityGroup`, {
                vpc: this.vpc,
                ...
                allowAllOutbound: false // Do NOT allow all outbound traffic
            });

            // Security group for RDS
            this.rdsSg = new ec2.SecurityGroup(this, `${stackName}RdsSecurityGroup`, {
                vpc: this.vpc, 
                ...
                allowAllOutbound: false // Do NOT allow all outbound traffic
            });
Enter fullscreen mode Exit fullscreen mode

You need to be explicit in CDK as when you do not, CDK will assume defaults. Important you become familiar with CDK defaults/fallbacks.

One example when not specified CDK will create VPCs with public subnets and NAT gateways as fallback.

VPC security groups are also created with allowing all outbound by default which is not what we want in this scenario.

Besides being explicit in code is good habit for developers to form as it makes your code more readable to other developers.

2. RDS A pain to instantiate

If we honest, whether it's CDK, Cloudformation or Clickops. Instatitating an instance of RDS can be painful if you get wrong. Not easy to correct. Once created, some settings are not a case of edit and clicking "save changes".

 this.rdsInstance = new rds.DatabaseInstance(this, `${stackName}RdsInstance`, {
engine: rds.DatabaseInstanceEngine.postgres({version: rds.PostgresEngineVersion.VER_17_4}),
                vpc: this.vpc,
                instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO),
                credentials: rds.Credentials.fromGeneratedSecret('postgres', {secretName}),
                multiAz: false,
                allocatedStorage: 20,
                maxAllocatedStorage: 100,
                publiclyAccessible: false, //Do NOT make RDS publiclyAccessible.
                vpcSubnets: {subnetType: ec2.SubnetType.PRIVATE_ISOLATED},
                removalPolicy: cdk.RemovalPolicy.SNAPSHOT,
                deletionProtection: false,
                securityGroups: [this.rdsSg],
                instanceIdentifier,
                storageEncrypted: true,
});
Enter fullscreen mode Exit fullscreen mode

RDS resource is not easily editable once created making exploration and experimentation difficult.
RDS instances take long time to stand down, delete and reinstantiate.

Properties to look out for:

  • multiAz: For development purposes, do not need multiple AZs. Not to mention costly. Prod deployments would be another conversation.
  • instanceType: For our development purposes, use free tier of RDS, Micro T4 instance to keep costs low.
  • publiclyAccessible: we do not want this or any DB instance to be publicly available on the internet.
  • vpcSubnets: Part our security, we need to specify that RDS exists in private subnet with no access to internet.
  • removalPolicy/deletionProtection: For development purposes, these properties make easier to tear down and redo.
  • storageEncrypted: Enables database encryption, good practice for data at rest to be encrypted. Should DB be created without encryption, challenging to enable later as afterthought, so better to enable at time of DB creation.

3. AWS Secrets still needs to be allowed accessed

Security practice is to simply provide AWS Secret Name as lambda environmental variable, and lambda logic would retrieve database connection string from AWS Secrets using secret name

Remember this VPC is tightly locked down to internal access only with no external access allowed. We have also disabled NAT gateways.

As such our VPC will need to allow Lambda to access to AWS Secrets using VPC Interface(s).

this.vpc.addInterfaceEndpoint('SecretsManagerEndpoint', {
                service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
            });

...

this.lambdaSg.addEgressRule(
                ec2.Peer.anyIpv4(),
                ec2.Port.tcp(443),
                "Allow outbound HTTPS to Secrets Manager"
            );
Enter fullscreen mode Exit fullscreen mode

4. Reduce Data Transfer Fees

const rdsSubnets = this.vpc.selectSubnets({subnetType: ec2.SubnetType.PRIVATE_ISOLATED});
const rdsSubnet = rdsSubnets.subnets[0];

...

this.lambda = this.createLamba({
            stackName,
            functionName: "Lambda",
            code: "/dist/",
            description: "Lambda function",
            role,
            rdsSubnet,
            timeout: Duration.seconds(29),
        });
Enter fullscreen mode Exit fullscreen mode

When your Lambda function and RDS instance are in different Availability Zones (AZs) within the same AWS Region.

AWS does charge for data transferred between those AZs. This is called "cross-AZ data transfer" and is billed per GB.
Key points:

  • Data transfer between AZs in the same region is not free.
  • If Lambda and RDS are in different AZs, every query result or data exchange incurs cross-AZ charges.

This line of code ensures both lambda and RDS are in the same AZ helping to avoid cross zone charges.

5. Lambda Execution Roles IAM Policy

const role = new iam.Role(this, `${stackName}${stage}LambdaExecutionRole`, {
                assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
                description: "Role for Lambda functions to access RDS and Secrets Manager",
                roleName: `${stackName}${stage}LambdaExecutionRole`,
                managedPolicies: [
                    iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
                    iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaVPCAccessExecutionRole"),
                ],


            }
        );
Enter fullscreen mode Exit fullscreen mode

Your lambda needs the AWSLambdaVPCAccessExecutionRole policy because your Lambda function runs inside a VPC. This policy grants permissions for Lambda to manage network interfaces (ENIs) inside your VPC, which are required for your function to access resources like RDS, private subnets and so on.

Without this policy, Lambda will not be able to create or manage the necessary network interfaces, and your lambda function will fail to start in a VPC.

6. Imported Resources from other instances of CDK stack

If your solution followed traditional CI/CD using stages dev, uat and prod.

CDK would create RDS instances for each stage which could get costly.
To reduce cost, you could import resources created from one stage into another.

this.vpc = Vpc.fromLookup(this, `${stackName}Vpc`, {vpcName});

this.lambdaSg = SecurityGroup.fromLookupByName(this, `${stackName}LambdaSecurityGroup`, lambdaSgName, this.vpc);

this.rdsSg = SecurityGroup.fromLookupByName(this, `${stackName}RdsSecurityGroup`, rdsSgName, this.vpc);

this.rdsInstance = rds.DatabaseInstance.fromLookup(this, `${stackName}ProdRdsImport`, {
                instanceIdentifier,
            });

this.dbSecret = secretsmanager.Secret.fromSecretNameV2(this, `${stackName}RdsSecret`, secretName);
Enter fullscreen mode Exit fullscreen mode

Once this.dbSecret is imported, we can access access secretFullArn property aka this.dbSecret!.secretFullArn

Incorrect, secretFullArn is only available for secrets instantiated using new constructor style. When you import a secret using fromSecretNameV2, limited to properties like secretArn and secretName are available, the secretFullArn will not be available.

Workaround: Provide this.dbSecret!.secretName as environmental variable to your lambda and the logic within your lambda will resolve secret name to secret arn, hence why secretsmanager:ListSecrets action with "*" resources was provided as IAM policy within LambdaExecutionRole

Conclusion

Connecting a Lambda function to RDS is never a one line exercise, and treating as such sets developers up for pain later. The real work resides with Infrastructure as code: networking, security, and the subtle properties AWS services that only become obvious when exploring and experimenting.

My goal in this blog was to move past the one‑liner advice and give you a repeatable approach you can rely on.

Also encourage you to take the time to explore and understand all moving parts.

About Myself

I am a Senior Software Engineer with Australia's national IT consultancy Versent, I hope to be known for my AWS expertise, holding multiple AWS certifications and being recognised as an AWS Community Builder.

With several decades of software engineering experience, I have delivered full‑stack, integration, and cloud‑native solutions across major enterprise clients in energy, health, and education.

I aim not only to bring technical capability, also community spirit by being involved with local tech events and conferences.

More Info

Top comments (0)