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.
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.
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.
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.
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.

A new RDS instance starts with these documented defaults, and each one can be changed per environment in the dashboard:
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.
Run in your terminal to get started locally.
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.
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.
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.
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.
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.