This AWS CDK v2 example creates a private RDS PostgreSQL database and an EC2 client in the same VPC. The TypeScript stack generates credentials in Secrets Manager and permits PostgreSQL connections from the EC2 security group on port 5432.
Since both EC2 and RDS services require a Virtual Private Cloud (VPC), we'll also cover setting up a custom VPC with appropriate subnets. Specifically, the RDS instance will reside in an ISOLATED (private) subnet, while the EC2 instance will be deployed within a PUBLIC subnet.
Scope: This is a disposable learning stack: backups and deletion protection are disabled. The revised snippets have not been deployed to AWS for this update. The original project repository provides historical context; use the corrected configuration below for this revision.
Prerequisites
- AWS account with appropriate permissions
- AWS CLI installed and configured
- Node.js and AWS CDK v2 installed
- Basic TypeScript knowledge
- An EC2 key pair named
ec2-demo-key-pairin the deployment region, with its private key available locally
Use a sandbox account and check aws sts get-caller-identity before deployment. EC2, RDS, storage, public IPv4 addresses, and Secrets Manager can incur charges. These examples use CDK v2 APIs; preserve the generated package lock file.
Project Setup
First, create a new CDK project:
mkdir deploy-rds-ec2 cd deploy-rds-ec2 cdk init app --language typescript npm install --save-exact aws-cdk-lib@2.272.0 constructs@10.5.0
Download the complete revised stack and save it as lib/deploy-rds-ec2-stack.ts, or follow the two stages below.
The complete stack was type-checked with TypeScript 5.9.3 against the pinned packages above and synthesized locally with example account, region, Availability Zone, and SSH CIDR values. These checks do not confirm a deployment or engine availability in your account.
The code is split into two stages: keep the VPC and EC2 definitions from the first block, then insert the database definitions from the second block inside the same constructor.
Let's start by setting up our Virtual Private Cloud (VPC) and an EC2 instance. Open the file lib/deploy-rds-ec2-stack.ts and add the following implementation:
import { App, CfnOutput, Duration, RemovalPolicy, Stack, StackProps, } from "aws-cdk-lib"; import { MachineImage, Instance, InstanceClass, InstanceSize, InstanceType, IpAddresses, KeyPair, Peer, Port, SecurityGroup, SubnetType, Vpc, } from "aws-cdk-lib/aws-ec2"; import { Credentials, DatabaseInstance, DatabaseInstanceEngine, PostgresEngineVersion, } from "aws-cdk-lib/aws-rds"; export class DeployRDSEC2Stack extends Stack { constructor(scope: App, id: string, props?: StackProps) { super(scope, id, props); // create a vpc const vpc = new Vpc(this, "main-vpc", { ipAddresses: IpAddresses.cidr("10.0.0.0/16"), natGateways: 0, maxAzs: 2, subnetConfiguration: [ { name: "public-subnet-1", subnetType: SubnetType.PUBLIC, cidrMask: 24, }, { name: "isolated-subnet-1", subnetType: SubnetType.PRIVATE_ISOLATED, cidrMask: 28, }, ], }); // create a security group for the EC2 instance const ec2InstanceSG = new SecurityGroup(this, "ec2-instance-sg", { vpc, }); ec2InstanceSG.addIngressRule( Peer.ipv4(this.node.tryGetContext("sshCidr")), Port.tcp(22), "allow SSH from the configured client IPv4 CIDR" ); // importing your SSH key const keyPair = KeyPair.fromKeyPairName(this, "key-pair", "ec2-demo-key-pair"); // create the EC2 instance const ec2Instance = new Instance(this, "ec2-instance", { vpc, vpcSubnets: { subnetType: SubnetType.PUBLIC, }, securityGroup: ec2InstanceSG, instanceType: InstanceType.of( InstanceClass.BURSTABLE3, InstanceSize.MICRO ), machineImage: MachineImage.latestAmazonLinux2023(), associatePublicIpAddress: true, keyPair, }); new CfnOutput(this, "Ec2PublicIp", { value: ec2Instance.instancePublicIp, }); } }
Let's review the above code snippet:
-
We've configured our VPC with both
PUBLICandISOLATEDsubnet groups.A
PUBLICsubnet has a route to an internet gateway. An EC2 instance also needs a public IP and appropriate security rules to communicate over the internet. This setup is suitable for resources like our EC2 instance, which we'll deploy in thePUBLICsubnet.In this newly created VPC, isolated subnets have no internet route. RDS uses them because this exercise only needs connections from the EC2 client within the VPC. Other private connectivity, such as VPN or peering, would require additional network configuration.
-
We set up a Security Group specifically for our EC2 instance. Its inbound SSH rule uses the
sshCidrcontext value supplied at deployment. Set it to your current public IPv4 address followed by/32; do not open SSH to every address. -
We provisioned a
t3.microEC2 instance using the Amazon Linux 2023 AMI and placed it in thePUBLICsubnet.Note that we included the
keyPairproperty when creating the EC2 instance. This key pair will enable SSH access, allowing us to interact directly with our RDS database from the EC2 instance. Ensure that a key pair matching the specified name already exists in your default AWS region, otherwise the deployment process will fail.Note: Before proceeding to set up the RDS instance, verify that a key pair named
ec2-demo-key-pairalready exists in your default AWS region. This step is essential to ensure successful deployment and SSH access to the EC2 instance.Let's now create a key pair named
ec2-demo-key-pairin your default AWS region. Alternatively, you can replace this key name with an existing one already set up in your AWS account.Follow these steps to create the key pair:
-
Open the EC2 Management Console in AWS.
-
From the navigation pane, select Key Pairs.
-
Click on Create key pair.
-
Provide the key pair name as
ec2-demo-key-pair. -
Select the file format suitable for your operating system:
.pemfor Mac or Linux systems..ppkfor Windows systems (commonly used with PuTTY).
Download and securely store the key file, as you'll need it to SSH into your EC2 instance.
After the key pair has been created, change to the directory it was downloaded in and change its permissions:
-
chmod 400 ec2-demo-key-pair.pem
Now, let's add the RDS instance to our stack. The imports below repeat those from the first block for reference. Insert the constructor body below after the EC2 definition; keep the earlier VPC, EC2, and Ec2PublicIp output. The // ... marker is an insertion point, not a complete standalone stack:
import { App, CfnOutput, Duration, RemovalPolicy, Stack, StackProps, } from "aws-cdk-lib"; import { MachineImage, Instance, InstanceClass, InstanceSize, InstanceType, IpAddresses, KeyPair, Peer, Port, SecurityGroup, SubnetType, Vpc, } from "aws-cdk-lib/aws-ec2"; import { Credentials, DatabaseInstance, DatabaseInstanceEngine, PostgresEngineVersion, } from "aws-cdk-lib/aws-rds"; export class DeployRDSEC2Stack extends Stack { constructor(scope: App, id: string, props?: StackProps) { super(scope, id, props); // ... rest of the code // create RDS Instance const dbInstance = new DatabaseInstance(this, "db-instance", { vpc, vpcSubnets: { subnetType: SubnetType.PRIVATE_ISOLATED, }, engine: DatabaseInstanceEngine.postgres({ version: PostgresEngineVersion.VER_16, }), instanceType: InstanceType.of( InstanceClass.BURSTABLE3, InstanceSize.MICRO ), credentials: Credentials.fromGeneratedSecret("postgres"), multiAz: false, allocatedStorage: 100, storageEncrypted: true, maxAllocatedStorage: 120, allowMajorVersionUpgrade: false, autoMinorVersionUpgrade: true, backupRetention: Duration.days(0), deleteAutomatedBackups: true, removalPolicy: RemovalPolicy.DESTROY, deletionProtection: false, databaseName: "testdb", publiclyAccessible: false, }); dbInstance.connections.allowFrom(ec2Instance, Port.tcp(5432)); new CfnOutput(this, "DbEndpoint", { value: dbInstance.instanceEndpoint.hostname, }); new CfnOutput(this, "SecretName", { value: dbInstance.secret?.secretName!, }); } }
Let's review the above code snippet:
- We've created an RDS database by instantiating the
DatabaseInstanceclass from the AWS CDK. - The
propswe provided to the constructor are as follows:
| Setting | Value and purpose in this example |
|---|---|
vpcSubnets | Isolated subnets spanning two Availability Zones; see CDK subnet selection |
engine | PostgreSQL 16; check engine and instance-class availability in your region before deployment |
instanceType | t3.micro, a burstable demonstration instance |
credentials | Generated password stored in Secrets Manager; no password in source code |
multiAz | false; this example has no Multi-AZ standby |
allocatedStorage | 100 GiB initially |
storageEncrypted | true; enable encryption at rest for the database storage |
maxAllocatedStorage | 120 GiB storage autoscaling ceiling |
backupRetention | Zero days; automated backups are disabled for this disposable example |
deleteAutomatedBackups | true; remove automated backups on instance deletion |
removalPolicy | DESTROY; deleting the stack deletes the database without a final snapshot |
deletionProtection | false, allowing cleanup of the exercise |
databaseName | testdb, the initial application database |
publiclyAccessible | false; connect through the EC2 instance within the VPC |
These options follow the CDK DatabaseInstance reference. Before reusing the stack for real data, enable backups and deletion protection, choose an appropriate retention policy, and review availability requirements.
- Next, we allowed connections to our RDS instance, on port
5432, from the security group of the EC2 instance - The complete stack creates three outputs:
- The EC2 public IP for SSH
- The database hostname that we'll use to connect to our RDS instance
- The name of the secret that stores the password of the
postgresuser
Deploying the RDS and EC2 Instance
Let's deploy the stack and test our RDS instance:
npx tsc --noEmit npx aws-cdk synth -c sshCidr=YOUR_PUBLIC_IPV4/32 npx aws-cdk diff -c sshCidr=YOUR_PUBLIC_IPV4/32 npx aws-cdk deploy -c sshCidr=YOUR_PUBLIC_IPV4/32 --outputs-file ./stack-outputs.json
Replace YOUR_PUBLIC_IPV4 with your current public IPv4 address, and pass the same context value to every command. Confirm that the intended region supports PostgreSQL 16 on the selected instance class. If the account/region has not been bootstrapped, run npx aws-cdk bootstrap for that environment first; see what CDK bootstrap does.
We have directed the outputs into a file named stack-outputs.json located in the root directory.
Wait for CloudFormation to report a successful deployment. Provisioning time varies; check stack events if creation fails. The JSON output file nests Ec2PublicIp, DbEndpoint, and SecretName under your stack name.
Upon inspecting the RDS instance's security group, you'll notice it permits inbound connections on port 5432 specifically from the security group assigned to our EC2 instance. This configuration ensures our EC2 instance can securely communicate with the RDS database after we establish an SSH session.
Before initiating an SSH connection to the EC2 instance, you'll need the database user's password, which is securely stored as a secret in AWS Secrets Manager.
To retrieve this secret:
- Using the AWS Management Console:
- Open the Secrets Manager service.
- Select your secret, and click Retrieve Secret Value to reveal the password.
- Using AWS CLI:
- Replace
YOUR_SECRET_NAMEwith theSecretNameoutput value from thestack-outputs.jsonfile, and run this command to retrieve the secret: -
Copy and store the returned password safely, as you'll need it to access your database from the EC2 instance.aws secretsmanager get-secret-value \ --secret-id YOUR_SECRET_NAME --output yaml
- Replace
Connecting to an RDS from EC2 instance
Let’s now SSH into our EC2 instance and connect to the RDS database.
-
Open your terminal and navigate to the directory where you saved the
ec2-demo-key-pairprivate key file. -
Set the correct permissions on the key file (if needed):
chmod 400 ec2-demo-key-pair.pem -
SSH into the EC2 instance using the public IP address from the
stack-outputs.jsonfile:ssh -i ec2-demo-key-pair.pem ec2-user@<EC2_PUBLIC_IP>Replace
<EC2_PUBLIC_IP>with the actual public IP address of your EC2 instance.
Once connected, you'll be inside the EC2 instance, ready to install a PostgreSQL client and connect to your RDS database.
sudo dnf install -y postgresql16 psql --version
Now we can connect to the RDS instance. Replace YOUR_DB_ENDPOINT with the value of DbEndpoint from the stack-outputs.json file, alternatively grab the Endpoint value from the RDS management console.
psql "host=YOUR_DB_ENDPOINT port=5432 dbname=testdb user=postgres sslmode=require"
You will be prompted for the password of the postgres user. Paste the value you grabbed from Secrets Manager and you should be connected to the RDS instance.
Let's list the databases:
\l
The database list should include testdb. Installing a PostgreSQL server on EC2 is unnecessary: RDS runs the server, and EC2 only needs the client. AWS documents client installation and connection requirements. sslmode=require encrypts the connection; use the RDS CA bundle and verify-full when you also need server certificate verification.
Let's connect to it.
-- get current database SELECT current_database(); -- connect to the application database \c testdb
Let's create a table and insert a few rows in it.
CREATE TABLE IF NOT EXISTS demotable (id SERIAL PRIMARY KEY, text TEXT NOT NULL); INSERT INTO demotable (text) VALUES ('hello world');
Finally, let's print the records from the demotable table of our RDS instance.
SELECT * FROM demotable;
A successful query should return the inserted hello world row. This checks the EC2-to-RDS network path, credentials, and database selection. It is an expected result, not a recorded deployment result for this revision.
Note: Don't forget to delete the resources you have provisioned, to avoid incurring charges.
Cleanup
npx aws-cdk destroy -c sshCidr=YOUR_PUBLIC_IPV4/32
Cleanup: Review the destroy proposal only for the disposable exercise stack. Verify EC2 and RDS deletion, and inspect Secrets Manager, retained snapshots, and storage for anything still billable. Keep backups you need; do not delete snapshots simply because they exist. The imported EC2 key pair is not owned or removed by this stack.
Troubleshooting Common Issues
| Symptom | What to inspect |
|---|---|
| Missing or invalid SSH CIDR during synthesis | Pass -c sshCidr=YOUR_PUBLIC_IPV4/32 to synth, diff, deploy, and destroy |
| EC2 key pair not found | Create ec2-demo-key-pair in the deployment region or consistently replace the name in the code and SSH command |
| SSH timeout | EC2 public IP, internet-gateway route, network ACLs, and whether your public IP changed since deployment |
| PostgreSQL connection timeout | RDS availability, endpoint, DNS, and the database security group's TCP 5432 rule from the EC2 security group |
| Password authentication failed | The current SecretName output and its generated password, using username postgres |
| Database does not exist | Use dbname=testdb; the initial database name must match the stack |
| Engine or instance class rejected | Regional PostgreSQL engine availability and supported DB instance classes |
Production differences and next steps
The example disables recovery safeguards to make a temporary exercise removable. For production, configure backup retention, test restores, retain or snapshot data on deletion, enable deletion protection, and decide whether Multi-AZ availability is required. Restrict administrative access and review TLS certificate verification, credential rotation, and monitoring.
Check current regional pricing rather than assuming a micro instance is free. RDS storage, EC2, public IPv4, and secrets are separate costs; stopping a workload does not remove every charge.
If your network already exists, use Vpc.fromLookup to import an existing VPC instead of creating a new one, then verify its subnet layout. Compare the CDK workflow with getting started with Pulumi on AWS and GCP if you are choosing a TypeScript infrastructure tool.
Quick Recap
Why does the RDS instance go in an isolated subnet while the EC2 instance goes in a public subnet?
This demo uses SSH to a public EC2 instance and keeps RDS in isolated subnets without an internet route. Public subnet placement alone does not grant access: EC2 also needs a public IP and an allowed security group rule.
How do you generate and store RDS credentials securely with CDK?
Use Credentials.fromGeneratedSecret("postgres") on the DatabaseInstance construct. CDK auto-generates a password and stores it in AWS Secrets Manager rather than hardcoding it.
How do you allow an EC2 instance to connect to an RDS instance in CDK?
Call dbInstance.connections.allowFrom(ec2Instance, Port.tcp(5432)), which adds an ingress rule to the RDS security group permitting traffic from the EC2 instance's security group on the database port.
How do you retrieve the RDS password after deployment?
Open the secret in AWS Secrets Manager via the console (Retrieve Secret Value) or the CLI with aws secretsmanager get-secret-value --secret-id YOUR_SECRET_NAME, using the secret name from the stack's CfnOutput.
Comments
Share your thoughts and questions below