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.0andpulumi-aws>=6.0installed 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*, andec2:DescribeSubnetson the deployment role. - A master password staged as a Pulumi config secret:
pulumi config set --secret dbPassword <value>. mypyfor 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.
Related
- How to Deploy an EKS Cluster with Pulumi (Python) — provisions the compute tier that connects to this database over the same VPC subnets.
- Deploying AWS Lambda Functions with Pulumi (Python) — a serverless consumer that reads the exported connection string via a VPC-attached function.
- Securing Pulumi Secrets with AWS KMS and HashiCorp Vault — harden the master password beyond a plain config secret.
- AWS Provider Deep Dive — the parent guide to credential routing and provider configuration for these resources.
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.