Provisioning AWS RDS Databases with Pulumi
Learn how to provision a PostgreSQL RDS instance with Pulumi and TypeScript, including secret password handling and a private subnet group
This guide provisions a PostgreSQL RDS instance with Pulumi - a subnet group confined to private subnets, a security group scoped to the application tier, and a master password handled as a Pulumi secret rather than a plaintext argument.
Prerequisites
- Private subnets in at least two Availability Zones (RDS subnet groups require multi-AZ coverage even for a single-AZ instance) - see the Pulumi VPC guide
- An application security group to reference in the database’s ingress rule
Handling the Master Password as a Secret
Never write a database password as a plain string literal in your program. Read it from Pulumi config as a secret instead:
pulumi config set --secret dbPassword "a-real-generated-password"
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const config = new pulumi.Config();
const dbPassword = config.requireSecret("dbPassword");
requireSecret() returns a pulumi.Output<string> that Pulumi treats as secret end-to-end: it’s encrypted in stack state, redacted from pulumi up/pulumi preview output, and redacted from the Pulumi Cloud console. Passing it straight into password: dbPassword below keeps that secret-ness intact.
Subnet Group and Security Group
const dbSubnetGroup = new aws.rds.SubnetGroup("app-db", {
subnetIds: privateSubnets.map(s => s.id),
tags: { Name: "app-db-subnet-group" },
});
const dbSecurityGroup = new aws.ec2.SecurityGroup("app-db", {
vpcId: vpc.id,
description: "PostgreSQL access from the application tier only",
ingress: [{
protocol: "tcp",
fromPort: 5432,
toPort: 5432,
securityGroups: [appSecurityGroup.id],
}],
egress: [{
protocol: "-1",
fromPort: 0,
toPort: 0,
cidrBlocks: ["0.0.0.0/0"],
}],
});
Referencing appSecurityGroup.id in the ingress rule’s securityGroups list (instead of a CIDR block) means only instances that are themselves members of the application security group can reach port 5432 - the database is unreachable from anywhere else in the VPC, regardless of IP address.
The Database Instance
const db = new aws.rds.Instance("app-db", {
engine: "postgres",
engineVersion: "16",
instanceClass: "db.t3.micro",
allocatedStorage: 20,
maxAllocatedStorage: 100,
dbName: "appdb",
username: "dbadmin",
password: dbPassword,
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [dbSecurityGroup.id],
storageEncrypted: true,
backupRetentionPeriod: 7,
multiAz: false,
skipFinalSnapshot: true,
tags: { Environment: "production" },
});
maxAllocatedStorage enables RDS storage autoscaling - the instance grows from allocatedStorage up to this ceiling automatically as data grows, instead of you monitoring free space and resizing manually. skipFinalSnapshot: true is convenient for a guide but wrong for a real production database - leave it false (the default) so RDS takes a final snapshot before any pulumi destroy, and set finalSnapshotIdentifier to a fixed name so the destroy doesn’t fail looking for one.
Multi-AZ for Production
const dbProd = new aws.rds.Instance("app-db-prod", {
// ...same arguments as above...
multiAz: true,
deletionProtection: true,
skipFinalSnapshot: false,
finalSnapshotIdentifier: "app-db-final-snapshot",
});
multiAz: true provisions a synchronously-replicated standby in a second AZ that RDS fails over to automatically; deletionProtection: true makes the instance reject deletion (including via pulumi destroy) until you explicitly turn the flag back off first - a deliberate speed bump against destroying a production database by mistake.
Stack Outputs
export const dbEndpoint = db.endpoint;
export const dbPort = db.port;
Note that dbEndpoint here is not automatically marked secret, even though it’s derived from a resource that took a secret input - only the password property itself (and other properties the schema explicitly marks) propagate secret-ness. Never export the password itself as a stack output.
Best Practices
- Always pass the password through
config.requireSecret(), never a literal string - a literal is stored in plaintext in your program’s source and in Pulumi’s state. skipFinalSnapshot: false(the default) anddeletionProtection: truefor anything production - the small inconvenience of a required flip-a-flag step before destroying is the point.- Scope the security group to
securityGroups, notcidrBlocks, wherever the caller is another AWS resource rather than an external IP range.
Conclusion
aws.rds.Instance’s arguments here map directly onto the same aws_db_instance arguments the RDS Terraform guide on this site covers - the meaningful Pulumi-specific piece is config.requireSecret(), which gives you a typed, tracked secret value instead of Terraform’s convention of marking a variable sensitive = true and relying on the backend to encrypt state at rest.
For more Pulumi topics, check out: