Updated Nov 5, 20267 min read

Amazon RDS with Terraform: Postgres, Subnets, Security Groups and Parameter Groups

Concrete Terraform for an RDS PostgreSQL instance with a DB subnet group, security group, parameter group and Secrets Manager password, the networking and state pain that comes with it, and how Encore provisions the same application database from code.

Standing up Amazon RDS for PostgreSQL in Terraform takes more than the aws_db_instance resource, since the instance needs a DB subnet group across two Availability Zones, a security group that only accepts traffic from the application tier, a parameter group for engine settings, and a place for the master password that is not a string sitting in state forever.

This guide walks through a concrete configuration for those pieces, calls out the state, IAM and networking costs that stick around, and shows how Encore (infrastructure from code) provisions the same application database from a declaration in TypeScript or Go.

What does an RDS Terraform setup need?

PieceTerraform resourcePurpose
Subnet groupaws_db_subnet_groupPrivate subnets in at least two AZs for placement
Security groupaws_security_groupIngress on 5432 from the application subnet or SG
Parameter groupaws_db_parameter_groupEngine settings such as logging thresholds
Instanceaws_db_instancePostgres engine, class, storage, Multi-AZ
Passwordmanage_master_user_passwordMaster credentials in AWS Secrets Manager

The sample below assumes an existing VPC with private application subnets and private database subnets, the layout covered in AWS VPC with Terraform. It follows the same resource set as HashiCorp's Manage AWS RDS instances tutorial, with private subnets and Secrets Manager instead of a public demo database.

Terraform for RDS PostgreSQL

The subnet group and security group place the instance and control who can connect. Replace the subnet and peer security group IDs with values from your VPC module or data sources:

resource "aws_db_subnet_group" "app" { name = "app-postgres" subnet_ids = var.database_subnet_ids tags = { Name = "app-postgres" } } resource "aws_security_group" "rds" { name = "app-postgres" vpc_id = var.vpc_id ingress { from_port = 5432 to_port = 5432 protocol = "tcp" security_groups = [var.app_security_group_id] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } }

The parameter group family must match the major engine version. HashiCorp's tutorial uses postgres18 with engine_version = "18.3", which matches a current RDS for PostgreSQL release. Create a custom group even if you only set one parameter, because the default group cannot be edited later without swapping groups and rebooting:

resource "aws_db_parameter_group" "app" { name = "app-postgres18" family = "postgres18" parameter { name = "log_min_duration_statement" value = "500" } } resource "aws_db_instance" "app" { identifier = "app-postgres" engine = "postgres" engine_version = "18.3" instance_class = "db.t4g.medium" allocated_storage = 20 max_allocated_storage = 100 storage_type = "gp3" storage_encrypted = true db_subnet_group_name = aws_db_subnet_group.app.name vpc_security_group_ids = [aws_security_group.rds.id] parameter_group_name = aws_db_parameter_group.app.name publicly_accessible = false multi_az = true db_name = "app" username = "app_admin" # RDS stores the master password in Secrets Manager. # See the aws_db_instance docs for manage_master_user_password. manage_master_user_password = true backup_retention_period = 7 deletion_protection = true skip_final_snapshot = false final_snapshot_identifier = "app-postgres-final" performance_insights_enabled = true }

manage_master_user_password tells RDS to create and rotate the master user secret in Secrets Manager, which is the pattern documented on the aws_db_instance resource. That avoids a password argument in your .tf files. The secret ARN is still visible to anyone who can read Terraform state or the RDS API, so state access control and a remote backend with encryption remain required. Point the application at aws_db_instance.app.address and read the secret at runtime, or inject it through your orchestrator's secret mechanism.

What still hurts after the first apply?

The resources above create one environment. A staging twin needs another subnet group or shared networking with carefully separated security groups, another state file or workspace, and a reviewed plan every time the parameter group or engine version changes. Storage growth, Multi-AZ failovers and major version upgrades are safe only when the plan matches what AWS will actually reboot, and apply_immediately can take the instance down outside a maintenance window.

IAM grows around the database rather than inside the instance resource: the ECS task role or Lambda execution role needs secretsmanager:GetSecretValue on the master secret or on an application user secret you create separately, plus network reachability through the security group. Schema migrations are entirely outside Terraform, so CI needs a path into the private subnet to run them. Teams that already maintain Terraform for accounts and shared networking feel this as ongoing drift between the database module and the services that depend on it.

How does Encore provision RDS instead?

Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. You declare a database in application code, and on AWS Encore creates an RDS for PostgreSQL instance by default, or an Aurora PostgreSQL cluster, an imported existing database, or a Neon database if you pick those for the environment, as listed in the AWS infrastructure docs.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

A new RDS instance starts with these documented defaults, and each one can be changed per environment in the dashboard:

SettingDefault
PostgreSQL version18.3
Instance classdb.m5.large
Storage10 GiB of gp2, autoscaling up to 20 GiB
Multi-AZEnabled
Backup retention7 days
Max connections1000

The instance is encrypted at rest, has deletion protection and Performance Insights enabled, and sits in the private storage subnet, accepting connections only from your application's compute subnets. Each service gets its own database role with access to only the databases it uses. The surrounding VPC, load balancer, secrets and queues are provisioned with the same deploy rather than as a separate module graph.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create users --example=ts/hello-world$ cd users && mkdir -p user/migrations

A database is a package-level variable next to the code that uses it:

// user/user.ts import { api, APIError } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; const db = new SQLDatabase("users", { migrations: "./migrations" }); interface User { id: number; email: string; } export const get = api( { expose: true, method: "GET", path: "/users/:id" }, async ({ id }: { id: number }): Promise<User> => { const row = await db.queryRow<User>` SELECT id, email FROM users WHERE id = ${id} `; if (!row) { throw APIError.notFound("user not found"); } return row; }, );
-- user/migrations/1_create_users.up.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE );

encore run starts Postgres locally and applies the migration, and encore db shell users opens a psql session against it. On deploy, Encore creates the RDS instance, applies migrations through Emissary, a SOCKS proxy running in your environment, so the database never has to be exposed to the internet, and injects the credentials at runtime, so no connection string is committed or copied into CI. The query API is covered in the TypeScript database docs, with the same model in Go. An existing instance can be attached instead with import an existing RDS database.

An AI coding agent can add a table with a migration file and test it against the local Postgres, and the same migration runs against RDS on the next deploy. Object storage, cron jobs and secrets declared beside the database follow the same path.

Best fit: TypeScript and Go teams who want RDS for PostgreSQL in their own AWS account, provisioned and migrated from the code that queries it, while keeping Terraform for account-level or shared networking if they already have it.

Good to know

  • Major version upgrades are not automatic on RDS, and a PostgreSQL version can be upgraded but never downgraded. In Encore, changing the version reboots the instance.
  • Terraform stores sensitive values in state even when outputs are marked sensitive; use a remote backend with encryption and tight IAM on the state bucket.
  • Test a point-in-time restore before you need one, since it produces a new instance with a new endpoint.

Frequently asked questions

How do I create an RDS PostgreSQL instance with Terraform?

Define an aws_db_subnet_group spanning at least two private subnets, an aws_security_group that allows Postgres from your application subnets, an aws_db_parameter_group with family postgres18, and an aws_db_instance with engine postgres. Prefer manage_master_user_password so RDS stores the master password in Secrets Manager instead of a plaintext password argument.

What is an RDS DB subnet group?

A DB subnet group is a named set of subnets in your VPC, typically private subnets in at least two Availability Zones, that RDS uses to place the instance. Without one, Terraform places the instance in the default VPC, which is rarely what a production backend wants.

Why does changing an RDS parameter group require a reboot?

AWS applies some engine parameters only after a reboot, and switching which parameter group an instance uses always requires a reboot. Create a custom parameter group up front so you can change settings over the instance life cycle without swapping groups later.

How do I get an RDS Postgres database for a TypeScript backend without Terraform?

With Encore, declare the database with new SQLDatabase and a migrations folder, then query it with db.queryRow. Postgres runs locally during development, and deploying to your AWS account creates an RDS for PostgreSQL instance in a private subnet, runs the migrations and gives the service its own database role.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders