I recently revisited a CDK project I published in late 2022 — cdk-private-rds-with-lambda — and ended up rewriting most of it. The original worked fine, but it carried complexity that didn't earn its keep: projen for project management, an EC2 bastion host for database access, password-based auth via Secrets Manager, and no automated security checks.

Here's what changed and why.

Removing Projen

Projen generates and manages your project configuration files — tsconfig.json, .gitignore, package.json scripts, and more. For large projects or organizations standardizing across many repos, that's valuable. For a single CDK stack with a handful of constructs, it's overhead.

The migration was mechanical:

  1. Delete .projenrc.ts, the .projen/ directory, and all DO NOT EDIT file markers
  2. Replace npx projen scripts with direct commands:
{
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "cdk": "cdk",
    "deploy": "npx cdk deploy",
    "destroy": "npx cdk destroy"
  }
}
  1. Own your tsconfig.json and .eslintrc directly

The result: fewer files, no magic, and anyone reading the repo immediately understands the build process. If you're maintaining a projen project and finding yourself fighting the generated output more than benefiting from it, this is worth considering.

Restructuring the Project

The original had everything in a flat src/ directory. The new layout separates stacks from constructs:

src/
  constructs/
    index.ts
    vpc.ts
    rds.ts
    lambda.ts
    initialize.ts
    resources/
      query_lambda/
      initialize_lambda/
  stacks/
    private-rds-with-lambda.ts

This is a minor change, but it makes the project navigable at a glance. Stacks compose constructs. Constructs are self-contained. The resources/ directory holds the Python Lambda code alongside the constructs that deploy it.

Removing the EC2 Bastion Host

The original post included an EC2 instance in the VPC so you could SSH in and run psql against the database. This is a common pattern, but it's a security surface we don't need. The EC2 instance requires:

  • An instance in a public or NAT-routed subnet
  • Security group rules allowing SSH
  • Key pair management
  • An actual running instance (cost)

Every one of those is something to manage and something that could be misconfigured. Since the entire point of this project is Lambda interacting with RDS, we removed the EC2 instance entirely. If you need to inspect the database, invoke the Lambda or add a temporary read-query Lambda — don't leave a bastion running.

IAM Database Authentication

This was the most impactful change. The original used Secrets Manager to store the RDS master password, then each Lambda retrieved the secret at connection time:

# Old approach
secret = json.loads(
    secrets_client.get_secret_value(SecretId=secret_name)["SecretString"]
)
connection = psycopg2.connect(
    password=secret["password"],
    ...
)

The new approach uses IAM database authentication. Instead of retrieving a stored password, the Lambda generates a short-lived token:

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",
    )

connection = psycopg2.connect(
    password=get_auth_token(),
    sslmode="require",
    ...
)

On the CDK side, two things enable this:

// On the RDS instance
iamAuthentication: true,
storageEncrypted: true,  // required for IAM auth

// On the Lambda
props.dataBase.grantConnect(this.queryLambda, 'postgres');

grantConnect() creates an IAM policy allowing rds-db:connect for the specified database user. The token is valid for 15 minutes and requires an SSL connection (sslmode="require").

Why this matters:

  • No stored passwords — nothing to rotate, nothing to leak
  • Short-lived tokens — even if intercepted, they expire quickly
  • IAM-scoped — access is tied to the Lambda's execution role, auditable in CloudTrail
  • Simpler code — no Secrets Manager call, no JSON parsing

We still keep Secrets Manager for the RDS master password (CDK creates it automatically), but the Lambdas don't need to read it at runtime.

Adding cdk-nag

cdk-nag runs security and best-practice checks against your synthesized CloudFormation. It catches things like unencrypted storage, overly permissive IAM policies, and missing logging — at synth time, before you deploy anything.

We added it as a test:

import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag';

test('Security Checks', () => {
  const app = new App();
  const stack = new PrivateRDSWithLambda(app, 'test');
  Aspects.of(app).add(new AwsSolutionsChecks());

  NagSuppressions.addStackSuppressions(stack, [
    { id: 'AwsSolutions-RDS3', reason: 'Multi-AZ not enabled for demo cost savings.' },
    { id: 'AwsSolutions-RDS10', reason: 'Deletion protection disabled for easier cleanup.' },
    { id: 'AwsSolutions-VPC7', reason: 'Flow logs not enabled for cost/simplicity in this demo.' },
    // ... other documented suppressions
  ]);

  const annotations = Annotations.fromStack(stack);
  annotations.hasNoError('*', Match.anyValue());
});

Every suppression has a documented reason. When you're building a demo, some rules don't apply (you probably don't want Multi-AZ for a sample project). But the suppressions make those decisions explicit and reviewable, rather than silent.

Adding Tests

Beyond cdk-nag, we added a standard snapshot test. Snapshot tests catch unintended infrastructure changes — if a CDK upgrade or code change alters the synthesized CloudFormation, the test fails and shows you exactly what changed.

Between the snapshot test and the security test, you get two layers of protection:

  1. Snapshot: "Did the infrastructure change?"
  2. cdk-nag: "Is the infrastructure secure?"

Both run with npm test and take seconds.

Other Changes

A few smaller improvements worth noting:

  • Python 3.9 → 3.12 — keeping runtimes current
  • X-Ray tracingTracing.ACTIVE on all Lambda functions for observability
  • Explicit security groupsallowInternally(Port.tcp(5432)) instead of relying on default behaviors
  • Environment variables for DB connectionDB_HOST and DB_PORT passed directly to Lambda instead of requiring a Secrets Manager lookup just to get the endpoint

Conclusion

None of these changes are individually dramatic. But taken together, the project went from "it works" to "it works and I can explain why it's secure." Removing the bastion host eliminated an attack surface. IAM auth eliminated stored credentials. cdk-nag made the security posture verifiable. And dropping projen made the whole thing readable.

If you have CDK projects from a couple years ago, they're probably worth a pass like this. The ecosystem has matured — IAM database auth, cdk-nag, Graviton instances — and taking advantage of those improvements is usually a few hours of work.

The full source is at github.com/schuettc/cdk-private-rds-with-lambda. The original post has been updated to reflect the current state of the repo.