Provisioning RDS PostgreSQL with Pulumi (Python)

Provisioning a managed PostgreSQL instance with Pulumi Python means wiring together a subnet group, a security group, and a secret-managed password into a single typed program — part of the broader AWS Provider Deep Dive workflow. The hard part is not the aws.rds.Instance resource itself; it is keeping the master password out of state in plaintext and exposing a connection string downstream without leaking it.

This guide builds a production-shaped RDS PostgreSQL instance: a DB subnet group spanning private subnets, a tightly scoped security group, an encrypted password sourced from a Pulumi config secret, and typed stack outputs for the endpoint and connection details.

Context

A database is the resource where a sloppy IaC pattern costs you the most. An over-broad security group exposes the instance to the internet; a hardcoded password lands in version control and the state file; a single-AZ subnet group blocks any later move to Multi-AZ. Doing it correctly the first time is cheaper than migrating a live database. The same secret-handling discipline applies when securing Pulumi secrets with AWS KMS and HashiCorp Vault, and the network primitives here are the ones an EKS cluster consumes from the same VPC.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 and pulumi-aws>=6.0 installed in a virtualenv.
  • An existing VPC with at least two private subnets in different AZs (RDS subnet groups require multi-AZ coverage even for single-AZ instances).
  • IAM permissions for rds:*, ec2:*SecurityGroup*, and ec2:DescribeSubnets on the deployment role.
  • A master password staged as a Pulumi config secret: pulumi config set --secret dbPassword <value>.
  • mypy for static checking of the typed config object.

Implementation

1. Define a typed configuration object

Model the instance parameters in a frozen dataclass so mypy --strict catches a misnamed engine version or instance class before a deploy attempt.

# infra/rds_config.py
# CLI: mypy --strict infra/
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List

@dataclass(frozen=True)
class RdsConfig:
    identifier: str
    db_name: str
    username: str
    instance_class: str = "db.t3.micro"
    engine_version: str = "16.3"
    allocated_storage: int = 20
    multi_az: bool = False
    subnet_ids: List[str] = field(default_factory=list)
    vpc_id: str = ""
    # State implication: changing `identifier` forces replacement of the
    # instance — Pulumi will destroy the old DB and create a new one.

2. Create the subnet group and security group

The subnet group pins the database to private subnets. The security group starts closed and admits PostgreSQL traffic only from a referenced application security group, never a CIDR like 0.0.0.0/0.

# infra/rds.py
# CLI: pulumi preview --diff
from __future__ import annotations
import pulumi
import pulumi_aws as aws
from infra.rds_config import RdsConfig

def build_network(cfg: RdsConfig, app_sg_id: pulumi.Input[str]) -> tuple[aws.rds.SubnetGroup, aws.ec2.SecurityGroup]:
    subnet_group = aws.rds.SubnetGroup(
        f"{cfg.identifier}-subnets",
        subnet_ids=cfg.subnet_ids,
        tags={"Name": f"{cfg.identifier}-subnets"},
    )

    sg = aws.ec2.SecurityGroup(
        f"{cfg.identifier}-sg",
        vpc_id=cfg.vpc_id,
        description=f"Postgres access for {cfg.identifier}",
        ingress=[aws.ec2.SecurityGroupIngressArgs(
            protocol="tcp",
            from_port=5432,
            to_port=5432,
            # Provider note: scope ingress to the app SG, not a CIDR block.
            security_groups=[app_sg_id],
        )],
        egress=[aws.ec2.SecurityGroupEgressArgs(
            protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"],
        )],
    )
    return subnet_group, sg

3. Provision the instance with a secret password

Pull the password from pulumi.Config().require_secret(). Pulumi keeps secret config encrypted in state and marks the password input as a secret, so it never appears in plaintext in pulumi preview output or the state file.

# infra/rds.py (continued)
# CLI: pulumi up
def build_instance(
    cfg: RdsConfig,
    subnet_group: aws.rds.SubnetGroup,
    sg: aws.ec2.SecurityGroup,
) -> aws.rds.Instance:
    config = pulumi.Config()
    password = config.require_secret("dbPassword")

    return aws.rds.Instance(
        cfg.identifier,
        identifier=cfg.identifier,
        engine="postgres",
        engine_version=cfg.engine_version,
        instance_class=cfg.instance_class,
        allocated_storage=cfg.allocated_storage,
        db_name=cfg.db_name,
        username=cfg.username,
        password=password,  # State implication: stored encrypted as a secret
        db_subnet_group_name=subnet_group.name,
        vpc_security_group_ids=[sg.id],
        multi_az=cfg.multi_az,
        storage_encrypted=True,
        skip_final_snapshot=False,
        final_snapshot_identifier=f"{cfg.identifier}-final",
        backup_retention_period=7,
        # Provider note: omit `publicly_accessible` (defaults to False) to
        # keep the instance off public subnets.
    )

4. Export typed outputs

Export the endpoint and a composed connection string. Use Output.all().apply() to assemble the string so the secret password stays a secret in the resulting output.

# __main__.py
# CLI: pulumi stack output dbConnection --show-secrets
import pulumi
from infra.rds_config import RdsConfig
from infra.rds import build_network, build_instance

cfg = RdsConfig(
    identifier="orders-db",
    db_name="orders",
    username="app",
    vpc_id="vpc-0abc123",
    subnet_ids=["subnet-0aaa", "subnet-0bbb"],
)

subnet_group, sg = build_network(cfg, app_sg_id="sg-0app123")
instance = build_instance(cfg, subnet_group, sg)

pulumi.export("dbEndpoint", instance.endpoint)
conn = pulumi.Output.all(instance.address, instance.port).apply(
    lambda args: f"postgresql://{cfg.username}@{args[0]}:{args[1]}/{cfg.db_name}"
)
pulumi.export("dbConnection", conn)

Verification

Confirm the password input is treated as a secret and the instance is not publicly reachable.

# tests/test_rds.py
# CLI: pytest tests/test_rds.py
from __future__ import annotations
import pulumi
from typing import Any, Dict, Tuple

class Mocks(pulumi.runtime.Mocks):
    def new_resource(self, args: pulumi.runtime.MockResourceArgs) -> Tuple[str, Dict[str, Any]]:
        return (f"{args.name}-id", {**args.inputs, "endpoint": "db.local:5432", "address": "db.local", "port": 5432})
    def call(self, args: pulumi.runtime.MockCallArgs) -> Dict[str, Any]:
        return {}

pulumi.runtime.set_mocks(Mocks(), preview=False)

import importlib
infra_main = importlib.import_module("__main__")

@pulumi.runtime.test
def test_not_public() -> pulumi.Output:
    return infra_main.instance.publicly_accessible.apply(
        lambda v: pulumi.log.info("publicly_accessible") if v in (False, None) else (_ for _ in ()).throw(AssertionError("DB is public"))
    )

Out of band, confirm the live endpoint resolves to a private address:

# CLI: read the exported endpoint, then resolve it
aws rds describe-db-instances --db-instance-identifier orders-db \
  --query 'DBInstances[0].{Endpoint:Endpoint.Address,Public:PubliclyAccessible}'

Gotchas & Edge Cases

The subnet group needs two AZs even for a single-AZ instance. AWS rejects a DB subnet group whose subnets all sit in one AZ with DBSubnetGroupDoesNotCoverEnoughAZs. Supply at least two subnets in different AZs in subnet_ids even when multi_az=False; you can flip to Multi-AZ later without re-creating the subnet group.

Changing the password in config does not always rotate it. After pulumi config set --secret dbPassword, Pulumi updates the password input in place via ModifyDBInstance. But if you originally created the instance with manage_master_user_password=True (Secrets Manager integration), setting password directly conflicts. Pick one strategy and stay with it for the life of the instance.

skip_final_snapshot=False blocks pulumi destroy without a snapshot name. With a final snapshot required, you must also set final_snapshot_identifier. Omitting it makes pulumi destroy fail mid-operation, leaving the stack partially torn down. Keep both set together.

Frequently Asked Questions

How do I keep the RDS master password out of the Pulumi state file in plaintext? Set it as a secret with pulumi config set --secret dbPassword and read it with Config().require_secret(). Pulumi encrypts secret config and propagates the secret marker to the password input, so the state file stores it encrypted and pulumi preview masks it.

Can I enable Multi-AZ without recreating the instance? Yes. Flip multi_az from False to True and run pulumi up. AWS performs an in-place modification (a brief failover) rather than a replacement, as long as the subnet group already covers multiple AZs.

Why does my subnet group fail with "does not cover enough AZs"? All the subnets you passed live in the same Availability Zone. RDS requires a subnet group to span at least two AZs. Add a subnet from a second AZ to subnet_ids.

How do I rotate the password after launch? Stage the new value with pulumi config set --secret dbPassword <new> and run pulumi up. Pulumi issues a ModifyDBInstance call to set the new password. Coordinate the change with application credential reloads to avoid a connection gap.

Should I let Pulumi manage the password or use Secrets Manager? For most stacks a Pulumi config secret is sufficient and keeps the value in one place. If you need automatic rotation and broad service access to the credential, use AWS-managed master passwords or a Vault integration instead — see the secrets page linked above.