If you have spent any time deploying resources in AWS, you know that clicking through the AWS Management Console is fine for experimenting, but terrible for repeatable, production-grade systems. Historically, the answer to this was AWS CloudFormation — writing extensive JSON or YAML templates to declare your infrastructure. While CloudFormation is robust, writing thousands of lines of YAML isn't exactly a developer's dream.
The AWS Cloud Development Kit (CDK) is an open-source software development framework that lets you define your cloud application infrastructure using familiar programming languages like TypeScript, Python, Java, or C#. It acts as a powerful abstraction layer over CloudFormation. Instead of writing declarative YAML, you write imperative code to generate those templates. This means you get to use loops, conditionals, object-oriented principles, and your IDE's auto-completion to build your cloud architecture.
However, deploying a simple API Gateway connected to a Lambda and a Database is easy in a CDK tutorial, but difficult to scale across an enterprise. In large organizations, we face strict compliance requirements, security reviews, and the need for high developer velocity. A single monolithic CDK stack simply won't survive contact with multiple engineering teams.
In this article, we will walk through setting up a CDK project and explore the architectural decisions necessary to make a Serverless API (backed by Amazon Aurora PostgreSQL) secure, maintainable, and enterprise-ready.
Getting Started: Setting Up the CDK Project
Before diving into enterprise patterns, let's look at how to initialize a fresh CDK project. You will need Node.js and the AWS CLI installed and configured.
First, install the CDK toolkit globally:
npm install -g aws-cdk
Next, create a new directory for your project and initialize a TypeScript CDK app:
mkdir backend
cd backend
cdk init app --language typescript
Finally, if this is your first time using CDK in your AWS account/region, you need to "bootstrap" it. This provisions the necessary S3 buckets and IAM roles CDK needs to deploy your apps:
cdk bootstrap aws://ACCOUNT-NUMBER/REGION
After initialization, your core project structure will look like this:
backend/
├── bin/
│ └── backend.ts # The entry point of your CDK application
├── lib/
│ └── backend-stack.ts # Where your infrastructure stack is defined
├── cdk.json # Configuration file telling CDK how to execute your app
├── package.json
└── tsconfig.json
While this structure is great for a starter project, we need to evolve it to support an enterprise architecture.
The Architecture Overview
We are building a typical modern backend:
- Amazon API Gateway (REST API) as the front door.
- AWS Lambda functions (Node.js) to process business logic.
- Amazon Aurora Serverless v2 (PostgreSQL) for resilient storage.
- Amazon RDS Proxy to manage database connections.
- AWS Secrets Manager to handle credentials securely.
Let’s break down the CDK decisions that elevate this from a weekend project to an enterprise architecture.
Decision 1: Modularity through L3 Domain Constructs
The Enterprise Problem: If network engineers, database administrators, and application developers all commit to the same backend-stack.ts file, you will suffer from merge conflicts, accidental blast-radius damage, and slow deployments.
The Architectural Solution: We must decouple our infrastructure into Level 3 (L3) Domain Constructs. Instead of one massive file, we define logical boundaries within a new constructs folder:
├── lib/
│ ├── backend-stack.ts # Now acts only as the Orchestrator
│ └── constructs/ # Domain-Driven L3 Constructs
│ ├── api.ts # API Gateway & Compute
│ ├── network.ts # VPC & Routing
│ ├── secrets.ts # Secrets Manager Integration
│ └── storage.ts # Databases & Proxies
We define explicit TypeScript contracts (Interfaces) to pass dependencies between these domains. The Api construct doesn't need to know how the database was built; it only needs the Proxy Endpoint and the Secret to connect.
// lib/constructs/api.ts
export interface ApiProps {
vpc: ec2.IVpc;
databaseProxy: rds.DatabaseProxy;
databaseSecret: secretsmanager.ISecret;
authSecrets: secretsmanager.ISecret;
}
export class Api extends Construct {
constructor(scope: Construct, id: string, props: ApiProps) {
super(scope, id);
// ... API Logic ...
}
}
This inversion of control allows the Platform team to update the database configuration without ever touching the API construct code.
Decision 2: A Secure-by-Default Network Topology
The Enterprise Problem: Security cannot be an afterthought. Leaving a database in a public subnet or manually managing security group IP addresses is a critical audit failure.
The Architectural Solution: We enforce a strict, 3-tier VPC architecture and utilize IAM for all internal authentication.
-
Isolated Storage: The Aurora cluster is deployed exclusively into
SubnetType.PRIVATE_ISOLATED. It has absolutely no route to the internet. -
Private Compute: Lambda functions are deployed into
SubnetType.PRIVATE_WITH_EGRESSso they can reach external APIs if needed, but cannot be invoked directly from the internet (only via API Gateway). - Connection Pooling & IAM Auth: We deploy an RDS Proxy. Instead of lambdas opening direct connections to the database using hardcoded passwords, they connect to the Proxy.
We codify this security by granting access using CDK's principle of least privilege methods:
// Inside the API Construct wiring
// 1. Allow Network Traffic from Lambda to Proxy
props.databaseProxy.connections.allowDefaultPortFrom(lambda);
// 2. Grant IAM permission to read the DB credentials
props.databaseSecret.grantRead(lambda);
Security teams can easily review these explicit grants rather than untangling complex Security Group rules.
Decision 3: "Convention over Configuration" for API Routing
The Enterprise Problem: Platform engineers become a bottleneck if application developers have to ask them to update IaC every time a new API endpoint (e.g., POST /v1/users) is created.
The Architectural Solution: We build dynamic provisioning into the CDK code. Instead of manually instantiating every NodejsFunction and LambdaIntegration, we program the CDK to read the application folder structure during synthesis.
Imagine a project structure like this:
├── src/
│ └── lambda/
│ └── api/
│ ├── v1/
│ │ ├── users/
│ │ │ ├── get.ts # GET /v1/users
│ │ │ └── post.ts # POST /v1/users
│ │ └── status/
│ │ └── get.ts # GET /v1/status
The CDK Api construct can dynamically read this directory. It parses the file paths (v1/users) and the file names (get.ts), and automatically provisions the required Lambda function and maps it to the API Gateway.
This pattern massively accelerates Developer Velocity. Application developers can build and deploy new features using standard Node.js practices without ever needing to learn CDK or touch the infrastructure repository.
Decision 4: Infrastructure-Aware Database Migrations
The Enterprise Problem: You deployed the new API and the database, but the application crashes because the SQL tables haven't been created yet. Relying on manual scripts or separate CI/CD steps for database migrations leads to configuration drift and failed deployments.
The Architectural Solution: We integrate the schema migration (using tools like Drizzle or Prisma) directly into the CDK lifecycle using AWS Custom Resources.
We define a specific Lambda function (DatabaseMigrator) that holds our SQL schema files. We then use a custom-resources.Provider to trigger this Lambda during the CloudFormation deployment process.
// Inside the main Stack Orchestrator (lib/backend-stack.ts)
// 1. The Migration Trigger
const databaseMigrationTrigger = new cdk.CustomResource(this, "MigrationTrigger", {
serviceToken: databaseMigratorProvider.serviceToken,
properties: { forceUpdate: Date.now().toString() }, // Ensure it runs every deploy
});
// 2. The Dependency Lock
databaseMigrationTrigger.node.addDependency(storage.databaseCluster);
By enforcing the dependency (addDependency), we guarantee the database is fully available before the migration runs. The deployment becomes atomic: if the infrastructure deploys but the migration fails, CloudFormation can halt or roll back. This guarantees that your infrastructure state and your database schema state are always in perfect sync.
Decision 5: Secure Secrets Management
The Enterprise Problem: Developers frequently make the mistake of hardcoding API keys, JWT secrets, or third-party tokens as plain text environment variables in the CDK. When synthesized, these secrets become plainly visible in the generated CloudFormation template (cdk.out), presenting a massive security vulnerability.
The Architectural Solution: Never pass plaintext secrets into your CDK code. Instead, manually provision your secrets in AWS Secrets Manager (or use automated pipelines to create them), and then have your CDK code reference them by Name or ARN.
In our Secrets construct, we load an existing secret:
// lib/constructs/secrets.ts
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
export class Secrets extends Construct {
public readonly authSecrets: secretsmanager.ISecret;
constructor(scope: Construct, id: string) {
super(scope, id);
// We only reference the secret name, not the value!
this.authSecrets = secretsmanager.Secret.fromSecretNameV2(
this,
"AuthSecrets",
"Backend/AuthSecrets"
);
}
}
Instead of injecting the actual secret values into our Lambda environment variables, we pass the ARN (Amazon Resource Name) of the secret:
// Inside the API construct provisioning the Lambda
environment: {
AUTH_SECRETS_ARN: props.authSecrets.secretArn,
},
Inside the Lambda function execution environment (at runtime), the application uses the AWS SDK to fetch the secret using the ARN. This guarantees that sensitive values are never logged, never stored in Git, and never exposed in the generated CloudFormation JSON files.
Conclusion
Building serverless applications on AWS is relatively straightforward, but scaling that process across an enterprise requires intent.
By abandoning monolithic stacks in favor of domain constructs, enforcing strict network topologies, automating developer workflows via dynamic routing, integrating custom resource migrations, and utilizing dynamic secret referencing, you transform CDK from a simple scripting tool into a robust, enterprise-grade platform engineering capability.

Top comments (0)