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

  1. 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.
  2. skipFinalSnapshot: false (the default) and deletionProtection: true for anything production - the small inconvenience of a required flip-a-flag step before destroying is the point.
  3. Scope the security group to securityGroups, not cidrBlocks, 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: