{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreif64unie3owzvjyg4qmat2l5ioutr6hzm5aa4kk7efwtluasenbt4",
"uri": "at://did:plc:6u4awktizhivwgqxl5j67h4k/app.bsky.feed.post/3mfcwyu3v26n2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreigyepetsekvmicsyxqdyizpirdqxvmswhchvfghw5akveytzy7hka"
},
"mimeType": "image/webp",
"size": 36450
},
"description": "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.\n\n\nHere's what changed and why.\n\n\n\nRemoving Projen\n\n\nProjen generates and manages your project configuration files — tsconfig.json, .gitignore, pack",
"path": "/modernizing-cdk-project/",
"publishedAt": "2026-02-20T20:26:43.000Z",
"site": "https://www.subaud.io",
"tags": [
"cdk-private-rds-with-lambda",
"Projen",
"IAM database authentication",
"cdk-nag",
"github.com/schuettc/cdk-private-rds-with-lambda",
"original post"
],
"textContent": "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.\n\nHere's what changed and why.\n\n## Removing Projen\n\nProjen 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.\n\nThe migration was mechanical:\n\n 1. Delete `.projenrc.ts`, the `.projen/` directory, and all `DO NOT EDIT` file markers\n 2. Replace `npx projen` scripts with direct commands:\n\n\n\n\n {\n \"scripts\": {\n \"build\": \"tsc\",\n \"test\": \"jest\",\n \"cdk\": \"cdk\",\n \"deploy\": \"npx cdk deploy\",\n \"destroy\": \"npx cdk destroy\"\n }\n }\n\n\n 1. Own your `tsconfig.json` and `.eslintrc` directly\n\n\n\nThe 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.\n\n## Restructuring the Project\n\nThe original had everything in a flat `src/` directory. The new layout separates stacks from constructs:\n\n\n src/\n constructs/\n index.ts\n vpc.ts\n rds.ts\n lambda.ts\n initialize.ts\n resources/\n query_lambda/\n initialize_lambda/\n stacks/\n private-rds-with-lambda.ts\n\n\nThis 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.\n\n## Removing the EC2 Bastion Host\n\nThe 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:\n\n * An instance in a public or NAT-routed subnet\n * Security group rules allowing SSH\n * Key pair management\n * An actual running instance (cost)\n\n\n\nEvery 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.\n\n## IAM Database Authentication\n\nThis 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:\n\n\n # Old approach\n secret = json.loads(\n secrets_client.get_secret_value(SecretId=secret_name)[\"SecretString\"]\n )\n connection = psycopg2.connect(\n password=secret[\"password\"],\n ...\n )\n\n\nThe new approach uses IAM database authentication. Instead of retrieving a stored password, the Lambda generates a short-lived token:\n\n\n def get_auth_token():\n client = boto3.client(\"rds\")\n return client.generate_db_auth_token(\n DBHostname=os.environ.get(\"DB_HOST\"),\n Port=os.environ.get(\"DB_PORT\"),\n DBUsername=\"postgres\",\n )\n\n connection = psycopg2.connect(\n password=get_auth_token(),\n sslmode=\"require\",\n ...\n )\n\n\nOn the CDK side, two things enable this:\n\n\n // On the RDS instance\n iamAuthentication: true,\n storageEncrypted: true, // required for IAM auth\n\n // On the Lambda\n props.dataBase.grantConnect(this.queryLambda, 'postgres');\n\n\n`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\"`).\n\nWhy this matters:\n\n * **No stored passwords** — nothing to rotate, nothing to leak\n * **Short-lived tokens** — even if intercepted, they expire quickly\n * **IAM-scoped** — access is tied to the Lambda's execution role, auditable in CloudTrail\n * **Simpler code** — no Secrets Manager call, no JSON parsing\n\n\n\nWe still keep Secrets Manager for the RDS master password (CDK creates it automatically), but the Lambdas don't need to read it at runtime.\n\n## Adding cdk-nag\n\ncdk-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.\n\nWe added it as a test:\n\n\n import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag';\n\n test('Security Checks', () => {\n const app = new App();\n const stack = new PrivateRDSWithLambda(app, 'test');\n Aspects.of(app).add(new AwsSolutionsChecks());\n\n NagSuppressions.addStackSuppressions(stack, [\n { id: 'AwsSolutions-RDS3', reason: 'Multi-AZ not enabled for demo cost savings.' },\n { id: 'AwsSolutions-RDS10', reason: 'Deletion protection disabled for easier cleanup.' },\n { id: 'AwsSolutions-VPC7', reason: 'Flow logs not enabled for cost/simplicity in this demo.' },\n // ... other documented suppressions\n ]);\n\n const annotations = Annotations.fromStack(stack);\n annotations.hasNoError('*', Match.anyValue());\n });\n\n\nEvery 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.\n\n## Adding Tests\n\nBeyond 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.\n\nBetween the snapshot test and the security test, you get two layers of protection:\n\n 1. **Snapshot** : \"Did the infrastructure change?\"\n 2. **cdk-nag** : \"Is the infrastructure secure?\"\n\n\n\nBoth run with `npm test` and take seconds.\n\n## Other Changes\n\nA few smaller improvements worth noting:\n\n * **Python 3.9 → 3.12** — keeping runtimes current\n * **X-Ray tracing** — `Tracing.ACTIVE` on all Lambda functions for observability\n * **Explicit security groups** — `allowInternally(Port.tcp(5432))` instead of relying on default behaviors\n * **Environment variables for DB connection** — `DB_HOST` and `DB_PORT` passed directly to Lambda instead of requiring a Secrets Manager lookup just to get the endpoint\n\n\n\n## Conclusion\n\nNone 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.\n\nIf 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.\n\nThe 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.",
"title": "Modernizing a CDK Project - Private RDS with Lambda",
"updatedAt": "2026-02-20T20:29:20.834Z"
}