AWS Databases

Launch Amazon RDS (MySQL & PostgreSQL) Hands-On: Networking, Backups & Secure Connect

Quick take: Amazon RDS (Relational Database Service) is a managed MySQL / PostgreSQL / MariaDB / SQL Server / Oracle engine where AWS runs the operating system, the patches, the backups and the replication, and you own the schema, the queries and the data. Launching one looks like a five-minute wizard, but the wizard quietly asks you a dozen one-way questions — which engine, which instance class (and whether the cheap burstable one will throttle you), gp3 or Provisioned IOPS storage and whether to turn on storage autoscaling, which VPC and DB subnet group, whether the endpoint is publicly accessible (the answer is almost always no), how the master password is stored, how many days of automated backups, and whether the volume is encrypted at rest — and that last one you can never change after creation. Get the defaults right and RDS is the most boring, reliable component you own. Accept the wizard’s public-by-default suggestion, skip encryption, leave backups at one day, and you have shipped a database that is on the internet, unrecoverable and failing its next audit. This article takes every one of those decisions apart with real numbers, then launches a private, encrypted, Secrets-Manager-authenticated PostgreSQL instance hands-on and connects to it through SSM port forwarding without ever putting it on the internet — in both aws CLI and Terraform.

Almost every application you will ever build needs a relational database, and almost nobody wants to be the person who runs one by hand — patching Postgres at 2 a.m., testing that last night’s pg_dump actually restores, wiring up a standby and praying failover works. Amazon RDS exists so you do not have to. You choose an engine and a size, click launch, and AWS gives you an endpoint that speaks the exact wire protocol your app already knows — psql, mysql, JDBC, whatever — while it handles the undifferentiated heavy lifting underneath. The catch is that “managed” does not mean “safe by default.” RDS will happily let you build something insecure, unrecoverable and expensive if you accept every default without knowing what it means.

This guide is the complete, decision-by-decision treatment of launching an RDS instance for someone who wants to operate it in production, not just pass a lab. We define the managed-database mental model and where RDS sits between running your own database on EC2 and going fully serverless with Aurora or DynamoDB. We go choice by choice through the launch: engine (MySQL vs PostgreSQL vs MariaDB, and a clear pointer to where Aurora fits), instance class (the db.t / db.m / db.r families and the burstable-credit trap), storage (gp3 vs io1/io2, allocated size, and the storage-autoscaling toggle that stops a full disk from taking you down), the network (VPC, the DB subnet group across at least two Availability Zones, the security group, and the PubliclyAccessible = false default that keeps your data off the internet), parameter and option groups, the master user and the Secrets Manager-managed master password with rotation, automated backups, snapshots, the maintenance window and minor-version auto-upgrade, deletion protection and the final snapshot, and encryption at rest (KMS, create-time only) plus TLS in transit. Then we connect — the endpoint, from a private app and through SSM port forwarding so nothing is ever exposed.

By the end you can look at the RDS launch screen and make every choice on purpose, launch a private PostgreSQL instance that is encrypted, backed up, deletion-protected and authenticated out of Secrets Manager, reach it securely without a public IP or an open bastion, tune a parameter group and reboot into it, and take a manual snapshot you can restore — every step as both an aws CLI command and Terraform. This maps to the database domains of CLF-C02 (Cloud Practitioner), SAA-C03 (Solutions Architect Associate) and SOA-C02 (SysOps Administrator).

What problem this solves

The problem RDS solves is running a relational database is a full-time job you do not want. A production database needs the OS patched, the engine upgraded, backups taken and — crucially — tested, a standby kept in sync for failover, storage grown before it fills, monitoring wired up, and the whole thing kept off the public internet. Do all of that yourself on an EC2 instance and you own an operational burden that scales with every database you add. RDS takes the operational half — provisioning, patching, backups, replication, failover, storage scaling — and runs it for you behind an endpoint, leaving you the half that is actually your business: the schema and the queries.

What breaks without understanding it is almost never the creation — the console makes a database appear in ten minutes. It is everything the ten-minute path silently decided for you. You accept Public access: Yes because it was offered and now your database answers on the internet, one weak password away from a breach. You leave storage at 20 GiB with autoscaling off, the disk fills during a traffic spike, and RDS drops the instance into storage-full where it rejects every write until you manually resize it. You pick db.t3.micro for a steady production workload, exhaust its CPU credits, and watch every query slow to a crawl at exactly the worst time. You skip encryption at rest to “add it later” and discover there is no later — encryption is a create-time-only property, and turning it on means snapshot, copy, restore, cut over. You set backup retention to 0 to save a few rupees and delete the only path back from a bad DELETE. You hard-code the master password into Terraform, and now it lives in plaintext in state, in your CI logs and in three engineers’ shell histories. Each is a specific, avoidable trap baked into the launch, and each is in this article.

Who hits this: every developer and platform engineer who has ever needed “just a database.” It bites hardest on people shipping their first production RDS (the public-access and encryption traps), on teams migrating from a self-run database (the instance-class and IOPS sizing questions), on anyone under compliance pressure (encryption, TLS, private networking, rotation), and on cost-optimizers who do not realize a stopped RDS instance still bills for its storage and that Multi-AZ doubles the instance cost. Here is the whole field in one frame — the launch questions every RDS instance forces you to answer, whether or not you noticed answering them:

The launch question The default if you don’t decide What it costs to get wrong Where in this article
Which engine? Whatever you clicked first Feature gaps, a painful migration later, wrong licensing Choosing the engine
Which instance class? db.t3.micro (free-tier bait) Credit throttling under steady load, or overpay for idle Sizing the instance class
Which storage + autoscaling? 20 GiB gp3, autoscaling off storage-full read-only outage; or throttled IOPS Storage: gp3, io1/io2 & autoscaling
Public or private? Public access Yes on default VPC Database on the internet, brute-force target Networking: VPC, subnet group, SG
How is the password stored? You type one in plaintext Password in Terraform state, logs, shell history Identity & the master password
How many days of backups? 1 day (CLI) / 7 (console) Can’t recover from yesterday’s bad migration Backups, snapshots & maintenance
Encrypted at rest? No unless you tick it — and it’s create-time only Failed audit; costly snapshot-copy re-key to fix Deletion protection, encryption & TLS
Deletion protection + final snapshot? Off / prompted One delete command wipes the only copy Deletion protection, encryption & TLS

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account, the AWS CLI v2 configured with a non-root IAM identity, and a VPC with at least two private subnets in different Availability Zones. If your networking is fuzzy, build the substrate first with Building an AWS VPC from Scratch: Subnets, Route Tables, IGW & a Public/Private Design; the private-subnet, no-inbound-from-internet design there is exactly what a database wants. You should be comfortable at a shell running psql or mysql and reading a Terraform resource block — you do not need fluency, only recognition.

This sits in the Databases track and is the natural next step after you have decided you need a relational store at all. That decision — RDS vs DynamoDB vs Aurora — is its own topic, covered in AWS Databases: RDS, DynamoDB and Aurora — Choose the Right Store; this article assumes you have already chosen RDS and now need to launch one well. The security group and private-subnet reasoning leans on Security Groups vs NACLs: A Deep Dive and a Connectivity-Blocking Troubleshooting Guide, and the “connect without a public IP” trick uses AWS Systems Manager Hands-On: Session Manager, Run Command & Patch Manager. Two wave siblings pick up where this one stops: RDS Multi-AZ and Read Replicas: High Availability and Read Scaling for making it survive an AZ failure and scale reads, and RDS Connection Timeouts: A Troubleshooting Playbook for when it will not connect at all.

Here is the assumed knowledge, why each piece matters specifically for launching RDS, and where to shore it up:

You should know… Why it matters here Brush up
What a VPC, subnet and AZ are The DB needs a subnet group across ≥2 private AZs Building an AWS VPC from Scratch
How a security group works (stateful, allow-only) The SG is the DB’s real firewall — port + source Security Groups vs NACLs Deep Dive
Basic SQL client use (psql, mysql) You connect over the standard wire protocol Any SQL primer
What KMS and a CMK are Encryption at rest is backed by a KMS key Deletion protection, encryption & TLS, below
That RDS vs DynamoDB vs Aurora is a real choice You should have already chosen relational, managed RDS, DynamoDB and Aurora Compared
How SSM Session Manager reaches a private host It’s how you connect with no public IP or bastion Systems Manager Hands-On

Core concepts

Seven mental models make every later launch decision obvious.

RDS is a shared-responsibility split, not a whole-hog handover. AWS operates the host OS, the database engine binaries, patching, the underlying storage, automated backups, replication and failover. You still own the schema, the indexes, the queries, the connection management, the database-level users and grants, and every choice on the launch screen. RDS will not stop you from writing a slow query, running out of connections, or leaving the door open — it manages the plumbing, not your decisions. The whole point of this article is the decisions.

An RDS “instance” is a managed EC2-plus-EBS you never SSH into. Under the covers a DB instance is a VM with attached block storage, but you get no shell and no root — you interact only through the API, the endpoint, and parameter/option groups. This is why some things you would do on a normal server (install an extension binary, edit postgresql.conf directly, apt upgrade the engine) are instead done through RDS-specific abstractions: a parameter group replaces editing the config file, an option group replaces installing add-ons, and a maintenance window replaces you running the patch.

The endpoint is a DNS name, not an IP — and it moves. You connect to something like mydb.abcd1234.us-east-1.rds.amazonaws.com, a CNAME that RDS controls. On a failover or a storage change the IP behind the name changes while the name stays stable — which is exactly why you must always connect by the endpoint and never cache the resolved IP. Whether that name resolves to a public or private address is the single most important security decision on the launch screen: PubliclyAccessible = false means it only ever resolves to a private VPC address.

A DB instance lives in one Availability Zone; a DB subnet group tells RDS which AZs it may live in. You do not place the instance in a subnet directly. You create a DB subnet group — a named set of subnets spanning at least two AZs — and RDS picks a subnet from it. Two AZs are required even for a single-AZ instance, because RDS needs somewhere to put the standby if you ever enable Multi-AZ. Give it only private subnets and the database can never get a public IP even if you later fat-finger PubliclyAccessible.

The security group is the database’s firewall. RDS has no inbound rules of its own — reachability is entirely the attached security group. The correct rule is “allow the engine port (5432 for PostgreSQL, 3306 for MySQL/MariaDB) from the application’s security group id,” which is both tighter and more durable than any CIDR. 0.0.0.0/0 on a database port is the single most common serious misconfiguration in the wild.

Backups come in two flavors that behave very differently. Automated backups are taken daily in a window plus continuous transaction logs, they enable point-in-time recovery (PITR) to any second within the retention period (1–35 days), and they are deleted when the instance is deleted unless you retain them. Manual snapshots are point-in-time copies you trigger, they live until you delete them, they survive the instance, and they are how you keep something forever or copy across Regions and accounts. Confusing the two is how people lose data on a delete.

Encryption at rest is a one-way, create-time-only door. You choose StorageEncrypted and a KMS key when the instance is created. You cannot turn encryption on for an existing unencrypted instance, and you cannot turn it off. To encrypt an existing database you snapshot it, copy the snapshot with a KMS key (the copy is where encryption is applied), and restore the encrypted copy into a new instance. Knowing this at launch — and just ticking the box — saves a painful migration later.

Here is every moving part of an RDS launch in one table, with who sets it and the gotcha that bites:

Building block What it is You set it… Gotcha
Engine + version MySQL / PostgreSQL / MariaDB / SQL Server / Oracle At create; major upgrade later Minor auto-upgrades in the window; majors are manual
Instance class The VM size (db.t3.microdb.r6g.16xlarge) At create; modifiable later db.t burstable throttles on credits
Storage type + size gp3 / io1 / io2 / magnetic, allocated GiB At create; growable later Can’t shrink; magnetic is legacy
Storage autoscaling Auto-grow up to a max cap At create or later Off by default → storage-full outage
VPC + DB subnet group Which subnets/AZs RDS may use At create; hard to change later Needs ≥2 AZs, all-private for a private DB
Security group The inbound firewall At create; editable anytime 0.0.0.0/0 = database on the internet
PubliclyAccessible Public vs private endpoint At create; modifiable later Default Yes in the default VPC wizard
Parameter group Engine config (postgresql.conf) Attach at create; edit anytime Static params need a reboot
Option group Engine add-ons (e.g. SQL Server audit) Attach at create; edit anytime Empty for basic MySQL/Postgres
Master user + password The bootstrap superuser At create Store in Secrets Manager, not plaintext
Backup retention Days of PITR (0–35) At create; changeable 0 disables PITR entirely
Encryption at rest KMS-encrypted volume At create only No in-place toggle, ever
Deletion protection Blocks delete At create; toggle anytime Off by default

Choosing the engine (and where Aurora fits)

RDS runs five engines. For new open-source workloads it comes down to PostgreSQL vs MySQL vs MariaDB; SQL Server and Oracle are for when you are bound to those ecosystems and are billed with commercial licensing (Oracle can be License Included or Bring-Your-Own-License). Pick the engine first, because it constrains everything after it — parameter names, options, replica behavior, and the migration cost of ever changing your mind.

The open-source three, compared

Dimension PostgreSQL MySQL MariaDB
Default port 5432 3306 3306
RDS major versions (typical) 12–17 8.0, 8.4 10.6, 10.11, 11.4
Best at Complex queries, JSON/JSONB, GIS (PostGIS), extensions Simple high-throughput OLTP, huge ecosystem MySQL-compatible, community-driven
Extensibility Very high (CREATE EXTENSION, hundreds supported) Moderate Moderate
Replication Logical + physical, read replicas Read replicas, binlog Read replicas
Licensing Open source (PostgreSQL license) Open source (GPL, Oracle-owned) Open source (GPL)
Reach for it when You want the most capable open-source RDBMS You want maximum compatibility with existing MySQL apps You want a MySQL drop-in outside Oracle’s stewardship

Where Aurora fits (a pointer, not a detour)

Amazon Aurora is a separate AWS-built engine that is wire-compatible with MySQL and PostgreSQL but re-architects the storage layer into a distributed, self-healing, auto-scaling volume shared across up to 15 low-latency read replicas. It is not an RDS “engine option” in the same sense — it is its own service family (Aurora Standard, Aurora I/O-Optimized, Aurora Serverless v2). You reach for Aurora, not RDS, when you need its specific superpowers; otherwise plain RDS is simpler and cheaper to start.

If you need… Choose Why
A standard MySQL/PostgreSQL, lowest starting cost, full control of the engine RDS (this article) Simple, cheap, predictable, familiar
Storage that auto-grows to 128 TiB with 6-way replication across 3 AZs Aurora Distributed storage layer, no storage-full
Up to 15 read replicas sharing one volume, ~sub-second replica lag Aurora Shared-storage replicas, not per-replica copies
Scale-to-near-zero / bursty dev + prod on the same engine Aurora Serverless v2 Capacity in ACUs, fine-grained autoscale
A specific PostgreSQL extension or exact engine version RDS has and Aurora lags RDS Aurora tracks community versions on its own cadence
Cheapest possible small always-on database RDS db.t4g.micro Aurora’s minimum footprint costs more

The full decision is in AWS Databases: RDS, DynamoDB and Aurora — Choose the Right Store. For the rest of this article we launch RDS PostgreSQL, and every command has a MySQL equivalent noted where it differs.

Sizing the instance class

The instance class is the VM behind your database: its vCPU count, memory, network bandwidth and EBS throughput. RDS classes mirror EC2 families with a db. prefix. The three families you will actually choose between for open-source engines are db.t (burstable), db.m (balanced) and db.r (memory-optimized). Databases are memory-hungry — they want to cache the working set in RAM — so the general rule is balanced for general apps, memory-optimized for real databases, burstable only for dev or genuinely bursty low-traffic work.

The families, side by side

Family Profile vCPU : memory (approx) Reach for it when The caveat
db.t3 / db.t4g Burstable 1 : 2–4 GiB Dev/test, low steady traffic, spiky toy workloads Runs on CPU credits — sustained load throttles it
db.m5 / db.m6i / db.m6g / db.m7g Balanced (general purpose) 1 : 4 GiB Most steady general-purpose production apps No burst caveat; higher floor cost than db.t
db.r5 / db.r6i / db.r6g / db.r7g Memory-optimized 1 : 8 GiB Real databases, large working sets, heavy caching/joins Costs more per vCPU; you’re paying for RAM
db.x2g / db.z1d High-memory / high-freq 1 : 16+ GiB In-memory-heavy or single-thread-bound engines Niche; expensive

g-suffixed classes (t4g, m6g, r6g, m7g, r7g) are AWS Graviton (ARM) — typically ~10–20% cheaper for similar or better performance on open-source engines. Prefer Graviton unless you have a specific x86 dependency.

The burstable-credit trap (why db.t is not “small production”)

A db.t3/db.t4g instance is allotted a baseline CPU percentage and earns CPU credits while below it; when it bursts above baseline it spends credits, and when the bucket empties on standard mode it is throttled to baseline. RDS launches burstable instances in Unlimited mode by default, which means instead of throttling it will spend beyond the bucket and bill you for the surplus vCPU-hours — so a mis-sized db.t under steady load does not just get slow, it silently runs up a bill. For anything with a steady CPU floor, db.m is both faster and more predictable.

Burstable concept What it means Consequence
Baseline % The CPU level you can sustain forever on credits Below it you bank credits
CPU credit 1 credit = 1 vCPU held at 100% for 1 minute The currency of bursting
Standard mode Empty bucket → throttled to baseline Queries crawl under sustained load
Unlimited mode (RDS default) Empty bucket → keep bursting, get billed Surprise cost instead of a slowdown
CPUCreditBalance metric CloudWatch gauge of remaining credits Watch it trend to zero = you’ve outgrown db.t
CPUSurplusCreditsCharged metric Credits spent beyond the bucket in Unlimited mode Non-zero = you are being billed for the burst

Some concrete sizes

Class vCPU Memory Typical use Free tier?
db.t4g.micro 2 1 GiB Free-tier learning, tiny dev DB Yes (750 hrs/mo, 12 mo)
db.t4g.small 2 2 GiB Small dev/test on Graviton No
db.t3.small 2 2 GiB Small dev/test No
db.m6g.large 2 8 GiB Small steady production No
db.r6g.large 2 16 GiB Cache-heavy production DB No
db.m6g.2xlarge 8 32 GiB Mid-size production OLTP No
db.r6g.4xlarge 16 128 GiB Large working-set database No

You set the class at create and can change it later with a modify (a brief outage on single-AZ, near-zero on Multi-AZ):

# Change instance class (applies in the maintenance window unless --apply-immediately)
aws rds modify-db-instance \
  --db-instance-identifier kv-lab-pg \
  --db-instance-class db.m6g.large \
  --apply-immediately
resource "aws_db_instance" "pg" {
  # ...
  instance_class = "db.t4g.micro" # start here; bump to db.m6g.large for steady load
}

Storage: gp3, io1/io2 and autoscaling

RDS storage is EBS underneath, and the choice is the same shape as EBS: General Purpose (gp3, gp2) for almost everything, Provisioned IOPS (io1, io2 Block Express) for latency-critical databases that need guaranteed IOPS, and magnetic only for legacy compatibility. Modern default: gp3. It decouples IOPS and throughput from capacity, so you no longer over-buy terabytes just to earn IOPS the way gp2 forced.

Storage types compared

Type Baseline / range IOPS Throughput Reach for it when Gotcha
gp3 (default) 20 GiB – 64 TiB 3,000 baseline, up to 64,000 (≥400 GiB) 125 MiBps baseline, up to 4,000 MiBps Almost every workload Below 400 GiB you can’t provision extra IOPS/throughput
gp2 (legacy) 20 GiB – 64 TiB 3 IOPS/GiB (burst to 3,000) Scales with size Only for compatibility IOPS tied to size; burst-bucket surprises
io1 100 GiB – 64 TiB Up to 256,000 (class-dependent) High Consistent high IOPS, older classes Pay per provisioned IOPS
io2 Block Express 100 GiB – 64 TiB Up to 256,000, sub-ms latency, 99.999% durability Very high Latency-critical, mission-critical DBs Only on supported instance classes
magnetic (deprecated) 5 GiB – 3 TiB ~100–1,000 Low Never for new work Legacy only; hard caps

Allocated storage and the autoscaling toggle

You set allocated storage (the starting size) at create. You can grow it later — but never shrink it (to go smaller you migrate to a new instance via dump/restore). The setting that saves you from a 2 a.m. page is storage autoscaling: give RDS a maximum allocated storage cap and it grows the volume automatically when free space runs low, so a filling disk expands instead of dropping the instance into the read-only storage-full state.

Storage setting Values Default When to change Trade-off / gotcha
AllocatedStorage 20 GiB – 64 TiB (engine min ~20) 20 GiB Size to your real data + headroom Can grow, never shrink
MaxAllocatedStorage (autoscaling) ≥ AllocatedStorage, up to 64 TiB Off (unset) Always set this in prod Off = storage-full outage risk
StorageType gp3 / gp2 / io1 / io2 / standard gp3 Latency-critical → io2 Changing type may trigger optimization window
Iops (gp3 ≥400 GiB, io1/io2) Provisioned IOPS baseline IOPS-bound workload Costs per IOPS on io1/io2
StorageThroughput (gp3 ≥400 GiB) 125–4,000 MiBps 125 Throughput-bound Only tunable ≥400 GiB

Storage autoscaling has rules worth knowing so you are not surprised by when it fires:

Autoscaling behavior Rule
Trigger Free space < 10% of allocated for ≥ 5 minutes
Cooldown At least 6 hours since the last autoscale event
Increment The larger of 10 GiB, 10% of current allocated, or forecast for 7 hours of growth
Ceiling Never exceeds your MaxAllocatedStorage cap
Direction Grows only — it never shrinks the volume
# Turn on autoscaling by setting a max cap (here: start 20 GiB, autoscale to 100 GiB)
aws rds modify-db-instance \
  --db-instance-identifier kv-lab-pg \
  --max-allocated-storage 100 \
  --apply-immediately
resource "aws_db_instance" "pg" {
  allocated_storage     = 20
  max_allocated_storage = 100      # <-- autoscaling ON, cap at 100 GiB
  storage_type          = "gp3"
  # iops / storage_throughput only settable at >= 400 GiB for gp3
}

Networking: VPC, DB subnet group, security group, PubliclyAccessible

This is the section that keeps your database off the internet. There are four pieces and they interlock.

The DB subnet group (≥2 AZs, private)

A DB subnet group is a named collection of subnets that tells RDS which subnets — and therefore which AZs — the instance may occupy. The rules are strict:

Requirement Rule Why
AZ span Must cover at least two AZs RDS needs a home for a Multi-AZ standby, even on single-AZ
Subnet count ≥ 2 subnets, one per AZ minimum Same reason
Subnet type Use private subnets for a private DB No route to an IGW = never internet-reachable
VPC scope All subnets in the same VPC A subnet group can’t span VPCs
Overlap Reuse across instances is fine It’s just a placement pool
aws rds create-db-subnet-group \
  --db-subnet-group-name kv-lab-db-subnets \
  --db-subnet-group-description "Private subnets for the lab DB" \
  --subnet-ids subnet-0aaa11112222 subnet-0bbb33334444
resource "aws_db_subnet_group" "db" {
  name       = "kv-lab-db-subnets"
  subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id] # two AZs
  tags       = { Project = "kv-rds-lab" }
}

The security group (the real firewall)

RDS reachability is entirely the attached security group. The correct pattern is source = the application’s security group id, not a CIDR — it survives IP changes and scales with your fleet. Reference-by-SG means “any instance wearing the app SG may reach the DB on 5432,” full stop.

Direction Port Protocol Source / dest Purpose
Inbound 5432 (PG) / 3306 (MySQL) TCP App SG id (e.g. sg-app) App → DB only
Inbound 5432 / 3306 TCP Bastion/SSM host SG (lab only) Admin connect
Outbound all all default (SG is stateful) Return traffic
NEVER 5432 / 3306 TCP 0.0.0.0/0 Database on the internet
# Create the DB SG and allow 5432 only from the app SG
DB_SG=$(aws ec2 create-security-group --group-name kv-lab-db-sg \
  --description "RDS lab DB SG" --vpc-id vpc-0abc123 --query GroupId --output text)

aws ec2 authorize-security-group-ingress \
  --group-id "$DB_SG" --protocol tcp --port 5432 \
  --source-group sg-app0000appsg
resource "aws_security_group" "db" {
  name   = "kv-lab-db-sg"
  vpc_id = var.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
  referenced_security_group_id = var.app_sg_id   # <-- source is an SG, not a CIDR
}

PubliclyAccessible = false (the single most important toggle)

PubliclyAccessible controls whether the endpoint’s DNS name resolves to a public or private address. Set it to false (the safe default) and the endpoint only ever resolves to a private VPC IP — nothing outside the VPC can even route to it, regardless of the security group.

PubliclyAccessible Endpoint resolves to Reachable from Use when
false (recommended) Private VPC IP only Inside the VPC / peered / VPN / SSM tunnel Almost always — the safe default
true Public IP (needs public subnet + IGW route) The internet (still gated by the SG) Rare — a genuinely public DB, tightly SG-scoped

Even with false, you reach the DB from your laptop without a public IP or an open bastion by tunnelling through SSM Session Manager port forwarding — the hands-on lab does exactly this.

aws rds create-db-instance --publicly-accessible false ...   # false is also the API default
# To fix one created public by mistake:
aws rds modify-db-instance --db-instance-identifier kv-lab-pg --no-publicly-accessible --apply-immediately
resource "aws_db_instance" "pg" {
  publicly_accessible    = false            # keep it private
  db_subnet_group_name   = aws_db_subnet_group.db.name
  vpc_security_group_ids  = [aws_security_group.db.id]
}

Parameter groups and option groups

Because you have no shell on an RDS host, engine configuration happens through a DB parameter group (the managed stand-in for postgresql.conf / my.cnf) and add-ons happen through an option group.

Parameter groups: static vs dynamic (the reboot trap)

Every instance uses a parameter group; the built-in default.postgres16 (or default.mysql8.0) is read-only, so to change anything you create a custom parameter group, edit it, and attach it. The trap: parameters are either dynamic (apply immediately) or static (apply only after a reboot). Change a static parameter and it sits in pending-reboot — silently not in effect — until you reboot the instance.

Apply type When it takes effect Examples (PostgreSQL) The gotcha
dynamic Immediately (immediate) work_mem, log_min_duration_statement None — live
static Only after a reboot shared_buffers, max_connections, rds.force_ssl Shows pending-reboot; not active until reboot
Parameter-group task Note
default group Read-only; you can’t edit it
custom group Create per engine family (e.g. postgres16)
Attach Set at create or via modify-db-instance
Attaching a new group Itself requires a reboot to associate
ApplyMethod immediate (dynamic) or pending-reboot (static)

Some parameters worth knowing on a PostgreSQL launch:

Parameter Default (RDS) Tune toward Effect
max_connections LEAST({DBInstanceClassMemory/9531392},5000) Match app pool + headroom Too low → “too many clients already”; too high → memory pressure
shared_buffers ~25% of instance memory Leave near default Postgres page cache; static
work_mem 4 MB Raise for big sorts/joins Per-operation; multiplies by concurrency
rds.force_ssl 0 (Postgres) 1 Forces TLS on every connection; static
log_min_duration_statement -1 (off) e.g. 1000 (ms) Logs slow queries; dynamic
rds.logical_replication 0 1 for CDC/logical replicas Enables logical decoding; static
effective_cache_size ~50% of instance memory Leave near default Planner hint for OS+DB cache; dynamic
random_page_cost 4 (HDD-era) ~1.1 on SSD/gp3 Makes the planner favor index scans; dynamic
# Create a custom parameter group, force SSL, and set a slow-query log threshold
aws rds create-db-parameter-group \
  --db-parameter-group-name kv-pg16 \
  --db-parameter-group-family postgres16 \
  --description "KloudVin lab PG16 params"

aws rds modify-db-parameter-group --db-parameter-group-name kv-pg16 \
  --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=pending-reboot" \
               "ParameterName=log_min_duration_statement,ParameterValue=1000,ApplyMethod=immediate"
resource "aws_db_parameter_group" "pg16" {
  name   = "kv-pg16"
  family = "postgres16"

  parameter {
    name         = "rds.force_ssl"
    value        = "1"
    apply_method = "pending-reboot"   # static — needs a reboot
  }
  parameter {
    name         = "log_min_duration_statement"
    value        = "1000"
    apply_method = "immediate"        # dynamic
  }
}

Option groups

An option group enables engine features that ship as add-ons. For basic MySQL/PostgreSQL you often need none; they matter more for SQL Server (native audit, TDE) and Oracle (APEX, OEM). MySQL uses them for things like MARIADB_AUDIT_PLUGIN and memcached.

Engine Common options You need one when…
PostgreSQL (Most features are extensions via CREATE EXTENSION, not options) Rarely — extensions cover most needs
MySQL / MariaDB MARIADB_AUDIT_PLUGIN, MEMCACHED You want the audit plugin or memcached
SQL Server SQLSERVER_AUDIT, TDE, native backup/restore Compliance/audit/TDE required
Oracle APEX, OEM, Timezone, S3_INTEGRATION Those features are required

Identity: the master user & the Secrets Manager-managed password

At create you name a master user — the bootstrap account with broad (but, on RDS, not full OS-level superuser) privileges you use to create your real application roles. The question is how its password is stored. Three approaches, in ascending order of safety:

Approach How it works Rotation Reach for it when
Plaintext at create You pass --master-user-password 'hunter2' You, manually Never in prod — it lands in state, logs, history
Secrets Manager managed --manage-master-user-password — RDS creates & rotates a KMS-encrypted secret Automatic (default 7 days) The default choice — no plaintext anywhere
IAM database authentication App gets a 15-min IAM auth token instead of a password Token expiry (15 min) App-tier auth via IAM roles, no stored password

Secrets Manager managed master password

Passing --manage-master-user-password (Terraform manage_master_user_password = true) tells RDS to generate the master password, store it in an AWS-managed Secrets Manager secret encrypted with a KMS key, and rotate it on a schedule — so the password exists in exactly one controlled place and never in your Terraform state or shell history. Your application reads the secret at connect time.

Secrets Manager managed-password setting Value / behavior
Who creates the password RDS, at instance creation
Where it’s stored A Secrets Manager secret RDS owns
Encryption KMS — aws/secretsmanager default, or your CMK via --master-user-secret-kms-key-id
Default rotation Every 7 days, managed by RDS
Secret ARN On describe-db-instancesMasterUserSecret.SecretArn
App reads it secretsmanager get-secret-value at runtime
Gotcha Cache the password and you’ll auth-fail after a rotation — read it fresh
# Read the managed master password (JSON with username/password)
SECRET_ARN=$(aws rds describe-db-instances --db-instance-identifier kv-lab-pg \
  --query 'DBInstances[0].MasterUserSecret.SecretArn' --output text)

aws secretsmanager get-secret-value --secret-id "$SECRET_ARN" \
  --query SecretString --output text | jq -r '.password'
resource "aws_db_instance" "pg" {
  username                     = "kvadmin"
  manage_master_user_password  = true          # RDS creates + rotates the secret
  master_user_secret_kms_key_id = aws_kms_key.db.arn  # optional: your CMK
  # DO NOT set `password` — that's the whole point
}

Backups, snapshots & the maintenance window

Recoverability is not optional, and RDS gives you two independent mechanisms plus a patch window.

Automated backups vs manual snapshots

Attribute Automated backups Manual snapshots
Trigger Daily in the backup window + continuous logs You, on demand (or IaC)
Enables PITR Yes — any second in the retention window No — restores to that snapshot’s instant
Retention 1–35 days (0 = disabled) Until you delete it
Deleted with instance? Yes (unless you retain automated backups) No — survives the instance
Cross-Region / cross-account Copy to create a manual copy Yes, directly copyable/shareable
Cost Free up to DB size; overage billed Billed per GiB stored
Use it for Operational recovery (bad migration, oops-DELETE) Long-term keep, pre-change safety net, DR copies

Backup and PITR settings

Setting Values Default Notes
BackupRetentionPeriod 0–35 days 1 (CLI) / 7 (console) 0 disables PITR and automated backups
PreferredBackupWindow 30-min UTC block AWS-assigned Pick low-traffic hours; must not overlap maintenance
Latest restorable time ~5 minutes behind now PITR granularity is to the second within the window
CopyTagsToSnapshot true/false false Propagate tags for cost allocation
DeleteAutomatedBackups true/false true Set false to retain backups after instance delete
# Set retention to 7 days and a backup window
aws rds modify-db-instance --db-instance-identifier kv-lab-pg \
  --backup-retention-period 7 --preferred-backup-window "17:00-17:30" --apply-immediately

# Take a manual snapshot
aws rds create-db-snapshot \
  --db-instance-identifier kv-lab-pg \
  --db-snapshot-identifier kv-lab-pg-manual-2026-07-14
resource "aws_db_instance" "pg" {
  backup_retention_period = 7
  backup_window           = "17:00-17:30"
  copy_tags_to_snapshot   = true
}

resource "aws_db_snapshot" "manual" {
  db_instance_identifier = aws_db_instance.pg.identifier
  db_snapshot_identifier = "kv-lab-pg-manual-2026-07-14"
}

Maintenance window & minor-version auto-upgrade

RDS applies OS and engine patches in a weekly maintenance window. Minor version upgrades can be applied automatically (AutoMinorVersionUpgrade); major version upgrades are always manual and are a deliberate, tested operation.

Setting Values Default Notes
PreferredMaintenanceWindow Weekly 30-min block (e.g. sun:04:00-sun:04:30) AWS-assigned Pick a low-traffic hour; don’t overlap the backup window
AutoMinorVersionUpgrade true/false true Auto-applies minor patches (e.g. 16.3 → 16.4) in the window
Major version upgrade Manual modify with new EngineVersion Test first; can be irreversible; may need a new param group
Apply pending immediately --apply-immediately Off Otherwise changes wait for the window

Deletion protection, final snapshot, encryption & TLS

The last cluster of launch decisions is about not losing the data and keeping it secret.

Deletion protection and the final snapshot

Deletion protection blocks the DeleteDBInstance call entirely until you turn it off — the guardrail against a fat-fingered terraform destroy or console click. Separately, when you do delete, RDS offers to take a final snapshot; skip it (SkipFinalSnapshot=true) and the data is gone the instant the delete completes.

Setting Default Effect Gotcha
DeletionProtection false true blocks delete until toggled off Terraform destroy fails until you set it false first
SkipFinalSnapshot false (a snapshot is required unless you skip) true = delete with no backup copy The classic “I lost the DB on delete” — always take the final snapshot in prod
FinalDBSnapshotIdentifier Name of the snapshot taken on delete Required unless SkipFinalSnapshot=true
DeleteAutomatedBackups true Also purges automated backups Set false to keep PITR history post-delete

Encryption at rest (KMS) — create-time only

Encryption at rest encrypts the storage volume, all automated backups, snapshots and read replicas transparently, using a KMS key (aws/rds default or your own CMK). The hard rule: it is a create-time-only property. You cannot enable it on an existing unencrypted instance and cannot disable it on an encrypted one. To encrypt an existing database: snapshot → copy the snapshot with --kms-key-id (the copy applies encryption) → restore the encrypted copy → cut over.

Aspect Rule
When set At create only — no in-place enable/disable
KMS key aws/rds managed key, or your CMK for key-policy control + cross-account
Covers Volume, automated backups, manual snapshots, read replicas
Performance Transparent; no measurable overhead
Encrypted → unencrypted Impossible; snapshots of an encrypted DB are always encrypted
Fix an unencrypted DB Snapshot → copy-db-snapshot --kms-key-idrestore-db-instance-from-db-snapshot

What you can (and cannot) change after create

Property Changeable after create? How
Instance class modify-db-instance
Allocated storage (grow) modify-db-instance (never shrink)
Storage type (gp3↔io2) modify-db-instance (optimization window)
Backup retention / windows modify-db-instance
Security groups / parameter group modify-db-instance
PubliclyAccessible modify-db-instance
Multi-AZ modify-db-instance
Encryption at rest Snapshot → copy w/ KMS → restore
KMS key of an encrypted DB Copy snapshot to a new key → restore
DB subnet group ⚠️ Limited Often needs recreate; plan the VPC first
Master username Fixed at create

TLS in transit

Encryption at rest protects stored bytes; TLS protects bytes on the wire. RDS presents a certificate from an AWS CA; your client verifies it against the RDS CA bundle (global-bundle.pem). Force it on with a parameter so no client can accidentally connect in cleartext.

Engine Force-TLS mechanism Client flag
PostgreSQL rds.force_ssl = 1 (parameter, static) sslmode=verify-full sslrootcert=global-bundle.pem
MySQL / MariaDB require_secure_transport = ON --ssl-ca=global-bundle.pem --ssl-mode=VERIFY_IDENTITY
# Download the global RDS CA bundle (verify the endpoint's certificate against it)
curl -sO https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
psql "host=$ENDPOINT port=5432 dbname=appdb user=kvadmin sslmode=verify-full \
      sslrootcert=global-bundle.pem"

Architecture at a glance

Read the diagram left to right as the real, private request path you are about to build. A client — an application server in a private subnet, or your own laptop tunnelling in through SSM Session Manager port forwarding to localhost:5432 — opens a connection to the RDS endpoint, a DNS name that resolves to a private VPC address because PubliclyAccessible is false. The security group admits port 5432 only from the application’s own SG. Behind the endpoint, a single primary PostgreSQL instance lives in a DB subnet group spanning two private subnets in different Availability Zones. The master password is generated and rotated in Secrets Manager; the storage is encrypted at rest by a KMS key (a decision locked in at create time); and automated backups plus a manual snapshot make the data recoverable. Each numbered badge marks a launch decision or gotcha, and the legend narrates each as symptom, how to confirm, and the fix.

Amazon RDS private launch and secure-connect path: a private app or an SSM Session Manager port-forward from a laptop connects on TCP 5432 through a security group that only allows the app's SG, to an RDS endpoint that resolves to a private IP, in front of a primary PostgreSQL instance placed by a DB subnet group across two private Availability Zones, with the master password managed and rotated in Secrets Manager, KMS encryption at rest set at creation, and automated backups plus a manual snapshot for recovery

Real-world scenario

Northwind Learning is a fictional ed-tech startup running a Django app on ECS Fargate. For their first year they ran PostgreSQL on a single db.t3.micro that the founding engineer launched through the console wizard, accepting every default: Public access: Yes, encryption off, backup retention 1 day, a plaintext master password pasted into a .env file, storage 20 GiB with autoscaling off. It worked — until it did not, three times in one quarter.

Incident one — the internet found it. GuardDuty flagged thousands of failed Postgres auth attempts from a botnet. Because the wizard had set PubliclyAccessible = true and the security group had a 0.0.0.0/0 rule someone added “just to test from home,” the database was answering the world on 5432. Nobody had breached it, but they were one weak password away. The fix was a scramble: flip --no-publicly-accessible, replace the CIDR rule with --source-group referencing the Fargate service’s SG, and move connectivity for admins to SSM port forwarding.

Incident two — the disk filled. A viral course dumped 40 GiB of submission data overnight. With autoscaling off, the 20 GiB volume filled and RDS dropped the instance to storage-full: every write failed, the app returned 500s, and students could not submit. Recovery meant a manual modify-db-instance --allocated-storage 100, which triggered a storage-optimization window during which performance was degraded. The lasting fix was one line — --max-allocated-storage 200 — that would have grown the disk silently.

Incident three — the credit cliff. Enrollment season pushed steady CPU on the db.t3.micro past baseline; because RDS defaults burstable instances to Unlimited mode, it did not throttle — it kept bursting and quietly tripled the instance’s CPU bill while p95 query latency still climbed because 1 GiB of RAM could not cache the working set. The bill, not a slowdown, is what got noticed.

The rebuild took an afternoon and is exactly the article’s lab: a db.m6g.large (2 vCPU, 8 GiB — memory for the cache, no credit cliff), gp3 storage starting at 50 GiB with autoscaling to 500 GiB, a DB subnet group across two private AZs, PubliclyAccessible = false, a security group sourced from the Fargate SG, the master password managed and rotated in Secrets Manager (the app now reads it at boot), encryption at rest with a customer CMK, 7-day backups, deletion protection on, and Multi-AZ added later (the RDS Multi-AZ and Read Replicas sibling). Cost went up about $90/month for the bigger instance and Multi-AZ — and their outage count went to zero. The lesson the founder repeats to every new hire: the RDS wizard’s defaults optimize for a database appearing, not for a database you can safely run.

Advantages and disadvantages

Advantages Disadvantages
AWS runs OS/engine patching, backups, replication, failover You give up OS/root access — no custom binaries or postgresql.conf edits
Point-in-time recovery and one-click snapshots Encryption at rest is create-time only — retrofitting means snapshot-copy-restore
Multi-AZ failover and read replicas are toggles, not projects Managed convenience costs more than raw EC2+self-managed
Secrets Manager-managed, auto-rotating master password Major version upgrades and some changes still need planned downtime
Storage autoscaling prevents storage-full outages A stopped instance still bills for storage; RDS auto-starts it after 7 days
Private-by-default networking (PubliclyAccessible=false) Instance-class and IOPS mis-sizing is easy and can be expensive
Familiar wire protocol — apps connect with no code change Not as elastic as Aurora Serverless / DynamoDB for spiky or huge scale

RDS wins whenever you want a standard relational database without owning its operations — which is most of the time. It loses when you need OS-level control (run it on EC2), extreme elasticity or 15 read replicas on shared storage (Aurora), or a non-relational access pattern at massive scale (DynamoDB). The managed premium is real but almost always cheaper than an engineer’s on-call time.

Hands-on lab

You will launch a private, encrypted, Secrets-Manager-authenticated PostgreSQL 16 instance in two private subnets, connect to it through SSM port forwarding with no public exposure, tune a parameter group and reboot into it, and take a manual snapshot — then tear it all down. Everything is aws CLI first, with a complete Terraform stack after. This is free-tier-friendly if you use db.t4g.micro and 20 GiB, but ⚠️ RDS, Secrets Manager, KMS and the NAT/SSM path can incur charges — do the teardown.

Assumptions: AWS CLI v2 configured, a VPC (vpc-...) with two private subnets in different AZs, and an EC2 instance in that VPC with the SSM agent running and an instance profile granting AmazonSSMManagedInstanceCore (your SSM tunnel host — see Systems Manager Hands-On). Export a few variables:

export AWS_REGION=us-east-1
export VPC_ID=vpc-0abc123def456
export SUBNET_A=subnet-0aaa11112222   # private, AZ a
export SUBNET_B=subnet-0bbb33334444   # private, AZ b
export SSM_HOST_SG=sg-0ssm00hostsg    # SG of your SSM tunnel EC2 host

Step 1 — DB subnet group (≥2 private AZs)

aws rds create-db-subnet-group \
  --db-subnet-group-name kv-lab-db-subnets \
  --db-subnet-group-description "Private subnets for lab DB" \
  --subnet-ids "$SUBNET_A" "$SUBNET_B"

Expected: JSON with "SubnetGroupStatus": "Complete" and two SubnetAvailabilityZone entries in different AZs.

Step 2 — DB security group, 5432 from the SSM host only

DB_SG=$(aws ec2 create-security-group --group-name kv-lab-db-sg \
  --description "RDS lab DB SG" --vpc-id "$VPC_ID" --query GroupId --output text)

aws ec2 authorize-security-group-ingress \
  --group-id "$DB_SG" --protocol tcp --port 5432 \
  --source-group "$SSM_HOST_SG"
echo "DB_SG=$DB_SG"

Expected: a sg-... id and an ingress rule allowing TCP 5432 from the SSM host SG only. No 0.0.0.0/0 anywhere.

Step 3 — a custom parameter group that forces TLS

aws rds create-db-parameter-group \
  --db-parameter-group-name kv-pg16 \
  --db-parameter-group-family postgres16 \
  --description "KloudVin lab PG16 params"

aws rds modify-db-parameter-group --db-parameter-group-name kv-pg16 \
  --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=pending-reboot" \
               "ParameterName=log_min_duration_statement,ParameterValue=1000,ApplyMethod=immediate"

Expected: the group is created and the two parameters are set (rds.force_ssl will show pending-reboot).

Step 4 — launch a private, encrypted PostgreSQL instance with Secrets Manager auth

aws rds create-db-instance \
  --db-instance-identifier kv-lab-pg \
  --engine postgres --engine-version 16 \
  --db-instance-class db.t4g.micro \
  --allocated-storage 20 --max-allocated-storage 100 \
  --storage-type gp3 \
  --storage-encrypted \
  --master-username kvadmin \
  --manage-master-user-password \
  --db-subnet-group-name kv-lab-db-subnets \
  --vpc-security-group-ids "$DB_SG" \
  --no-publicly-accessible \
  --db-parameter-group-name kv-pg16 \
  --backup-retention-period 7 \
  --preferred-backup-window "17:00-17:30" \
  --preferred-maintenance-window "sun:04:00-sun:04:30" \
  --auto-minor-version-upgrade \
  --deletion-protection \
  --db-name appdb

Expected: JSON with "DBInstanceStatus": "creating". Note the flags doing the real work: --storage-encrypted (create-time only), --manage-master-user-password (no plaintext), --no-publicly-accessible (private), --max-allocated-storage (autoscaling), --deletion-protection.

Wait for it to come up (5–10 minutes) and grab the endpoint + secret ARN:

aws rds wait db-instance-available --db-instance-identifier kv-lab-pg

ENDPOINT=$(aws rds describe-db-instances --db-instance-identifier kv-lab-pg \
  --query 'DBInstances[0].Endpoint.Address' --output text)
SECRET_ARN=$(aws rds describe-db-instances --db-instance-identifier kv-lab-pg \
  --query 'DBInstances[0].MasterUserSecret.SecretArn' --output text)
echo "ENDPOINT=$ENDPOINT"; echo "SECRET_ARN=$SECRET_ARN"

Step 5 — connect through SSM port forwarding (no public IP)

Start a port-forwarding session from the SSM host to the RDS endpoint, mapping the DB’s 5432 to your local 5432. Replace i-0ssmhost... with your SSM tunnel instance id:

aws ssm start-session \
  --target i-0ssmhost0000000 \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters "{\"host\":[\"$ENDPOINT\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"5432\"]}"

Expected: Starting session ... Port 5432 opened for sessionId .... Leave it running. In a second terminal, fetch the password from Secrets Manager and connect to localhost:

PGPASSWORD=$(aws secretsmanager get-secret-value --secret-id "$SECRET_ARN" \
  --query SecretString --output text | jq -r '.password')

curl -sO https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem

PGPASSWORD="$PGPASSWORD" psql \
  "host=127.0.0.1 port=5432 dbname=appdb user=kvadmin sslmode=verify-full sslrootcert=global-bundle.pem" \
  -c "select version();"

Expected: the PostgreSQL 16 version string. You are connected to a database that has no public endpoint, over TLS, with a password you never typed. (MySQL equivalent: mysql -h 127.0.0.1 -P 3306 -u kvadmin -p"$PW" --ssl-ca=global-bundle.pem --ssl-mode=VERIFY_IDENTITY.)

Step 6 — change a parameter and reboot into it

rds.force_ssl is static, so it sits in pending-reboot. Reboot to activate it:

aws rds reboot-db-instance --db-instance-identifier kv-lab-pg
aws rds wait db-instance-available --db-instance-identifier kv-lab-pg
# Confirm the parameter is now in effect:
PGPASSWORD="$PGPASSWORD" psql "host=127.0.0.1 port=5432 dbname=appdb user=kvadmin \
  sslmode=verify-full sslrootcert=global-bundle.pem" -c "show rds.force_ssl;"

Expected: rds.force_ssl returns on. (Restart the SSM session from Step 5 first if the reboot dropped it.)

Step 7 — take a manual snapshot

aws rds create-db-snapshot \
  --db-instance-identifier kv-lab-pg \
  --db-snapshot-identifier kv-lab-pg-manual-01
aws rds wait db-snapshot-available --db-snapshot-identifier kv-lab-pg-manual-01

Expected: a snapshot in available state. This copy survives even if you delete the instance.

Step 8 — teardown (⚠️ removes everything)

Deletion protection is on, so you must turn it off first, then delete (taking a final snapshot):

aws rds modify-db-instance --db-instance-identifier kv-lab-pg \
  --no-deletion-protection --apply-immediately
aws rds wait db-instance-available --db-instance-identifier kv-lab-pg

aws rds delete-db-instance --db-instance-identifier kv-lab-pg \
  --final-db-snapshot-identifier kv-lab-pg-final-01
aws rds wait db-instance-deleted --db-instance-identifier kv-lab-pg

# Clean up the leftovers (snapshots persist and bill until deleted)
aws rds delete-db-snapshot --db-snapshot-identifier kv-lab-pg-manual-01
aws rds delete-db-snapshot --db-snapshot-identifier kv-lab-pg-final-01
aws rds delete-db-parameter-group --db-parameter-group-name kv-pg16
aws rds delete-db-subnet-group --db-subnet-group-name kv-lab-db-subnets
aws ec2 delete-security-group --group-id "$DB_SG"

Expected: each resource deletes. Manual and final snapshots are NOT deleted with the instance — you must delete them explicitly or they bill forever.

The whole thing as Terraform

terraform {
  required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }
}
provider "aws" { region = "us-east-1" }

variable "vpc_id"        { type = string }
variable "private_subnet_ids" { type = list(string) } # two, different AZs
variable "ssm_host_sg_id"     { type = string }

resource "aws_db_subnet_group" "db" {
  name       = "kv-lab-db-subnets"
  subnet_ids = var.private_subnet_ids
}

resource "aws_security_group" "db" {
  name   = "kv-lab-db-sg"
  vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "db_from_ssm" {
  security_group_id            = aws_security_group.db.id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
  referenced_security_group_id = var.ssm_host_sg_id
}

resource "aws_db_parameter_group" "pg16" {
  name   = "kv-pg16"
  family = "postgres16"
  parameter {
    name         = "rds.force_ssl"
    value        = "1"
    apply_method = "pending-reboot"
  }
  parameter {
    name         = "log_min_duration_statement"
    value        = "1000"
    apply_method = "immediate"
  }
}

resource "aws_db_instance" "pg" {
  identifier     = "kv-lab-pg"
  engine         = "postgres"
  engine_version = "16"
  instance_class = "db.t4g.micro"

  allocated_storage     = 20
  max_allocated_storage = 100          # storage autoscaling
  storage_type          = "gp3"
  storage_encrypted     = true         # create-time only

  db_name                     = "appdb"
  username                    = "kvadmin"
  manage_master_user_password = true   # Secrets Manager, no plaintext

  db_subnet_group_name   = aws_db_subnet_group.db.name
  vpc_security_group_ids = [aws_security_group.db.id]
  publicly_accessible    = false
  parameter_group_name   = aws_db_parameter_group.pg16.name

  backup_retention_period    = 7
  backup_window              = "17:00-17:30"
  maintenance_window         = "sun:04:00-sun:04:30"
  auto_minor_version_upgrade = true

  deletion_protection       = true
  final_snapshot_identifier = "kv-lab-pg-final-01"
  # skip_final_snapshot     = false   # keep a final snapshot (default)
}

output "endpoint"   { value = aws_db_instance.pg.endpoint }
output "secret_arn" { value = aws_db_instance.pg.master_user_secret[0].secret_arn }

terraform apply builds the identical stack; terraform destroy will fail until you set deletion_protection = false and apply once — that failure is the guardrail working.

Common mistakes & troubleshooting

The launch playbook. Symptom → root cause → the exact command or console path to confirm → the fix. Connection timeouts specifically are a deep topic of their own — the sibling RDS Connection Timeouts Troubleshooting Playbook drills them — but the launch-time causes are all here.

# Symptom Root cause Confirm (exact command / path) Fix
1 psql hangs then times out from your laptop DB is private (PubliclyAccessible=false) — correct! You just can’t route to it directly nslookup $ENDPOINT returns a 10.x/172.x private IP Don’t make it public — tunnel via SSM AWS-StartPortForwardingSessionToRemoteHost, connect to 127.0.0.1
2 Connection refused / timeout from the app Security group doesn’t allow the app on the engine port aws ec2 describe-security-groups --group-ids $DB_SG shows no 5432 ingress authorize-security-group-ingress --port 5432 --source-group <app-sg>
3 FATAL: password authentication failed after it worked Secrets Manager rotated the master password; app cached the old one aws secretsmanager get-secret-value --secret-id $SECRET_ARN → compare Read the secret on each connect, don’t cache; or widen your pool’s re-auth
4 DB is in storage-full, all writes fail Disk filled; storage autoscaling was off aws rds describe-db-instances --query '..DBInstanceStatus' = storage-full; FreeStorageSpace≈0 in CloudWatch modify-db-instance --allocated-storage <bigger>; then set --max-allocated-storage so it never recurs
5 “I need to encrypt this DB” but there’s no toggle Encryption at rest is create-time only describe-db-instances --query '..StorageEncrypted' = false Snapshot → copy-db-snapshot --kms-key-idrestore-db-instance-from-db-snapshot → cut over
6 A parameter change had no effect It’s a static parameter sitting in pending-reboot describe-db-parameters shows ApplyMethod=pending-reboot; describe-db-instancesDBParameterGroups[].ParameterApplyStatus reboot-db-instance to apply static parameters
7 create-db-subnet-group fails Subnets don’t span ≥2 AZs, or aren’t in the VPC aws ec2 describe-subnets --subnet-ids ... → check AvailabilityZone Provide ≥2 subnets in different AZs in the same VPC
8 terraform destroy / delete refused Deletion protection is on (working as intended) describe-db-instances --query '..DeletionProtection' = true modify-db-instance --no-deletion-protection first, then delete
9 Deleted the DB and the data is gone SkipFinalSnapshot=true (or no final snapshot named) Check CloudTrail DeleteDBInstance event params Restore from an automated backup/PITR if within retention; else it’s lost — always take a final snapshot
10 Bill spiked on a tiny db.t3 DB Burstable Unlimited mode kept bursting past credits and billed the surplus CPUCreditBalance→0 and CPUSurplusCreditsCharged>0 in CloudWatch Move to db.m/db.r, or set the instance to standard credit mode
11 Endpoint stopped resolving to the old IP after a failover You cached the resolved IP instead of using the DNS name App logs show a hard-coded IP; nslookup now returns a different one Always connect by the endpoint DNS name; lower client DNS TTL caching
12 too many clients already / remaining connection slots reserved max_connections exceeded (scales with instance memory) describe-db-parameters for max_connections; DB error log Add a pooler (PgBouncer/RDS Proxy), or raise max_connections (bigger class or param)
13 Instance stuck in incompatible-parameters A parameter value is invalid for the class (e.g. max_connections too high for the RAM) describe-db-instancesDBInstanceStatus; check events Fix the parameter value in the group, reboot
14 DBInstanceAlreadyExists on create The identifier is reused (or a recently-deleted one lingers) aws rds describe-db-instances for the id Use a unique --db-instance-identifier
15 Restore/replica fails with KMS AccessDenied Restoring principal lacks kms:Decrypt/CreateGrant on the CMK Check the CMK key policy and the caller’s IAM Grant kms:Decrypt, kms:CreateGrant on the key to the restoring role
16 “Where’s my standby endpoint?” / expected zero-downtime but got a blip Multi-AZ vs single confusion — the standby is not a readable/second endpoint (that’s a read replica) describe-db-instances --query '..MultiAZ' and ..SecondaryAvailabilityZone Multi-AZ = one endpoint, automatic failover only; for readable copies add read replicas (see the HA sibling)

RDS instance states you’ll see

State Meaning Can you connect?
creating Being provisioned No
available Healthy and serving Yes
modifying Applying a change (class, storage, etc.) Usually yes
backing-up Taking an automated backup Yes
rebooting Restarting (e.g. to apply static params) Briefly no
storage-full Disk exhausted Read-only / writes fail
storage-optimization Post-resize background optimization Yes (may be slower)
incompatible-parameters A parameter value is invalid for the class No
incompatible-network Subnet/AZ/networking problem No
stopped Manually stopped (still billing storage) No
starting Coming back from stopped No
upgrading Applying an engine version upgrade Briefly no
renaming Identifier change in progress No
maintenance Applying a maintenance action Usually briefly
deleting Being torn down No
failed Provisioning/health failure No

The nastiest three, in prose

The public-by-default trap. The single-page “Easy create” console flow, in a default VPC, can land you with PubliclyAccessible = true and a database answering on the internet. Always verify after create: aws rds describe-db-instances --db-instance-identifier <id> --query 'DBInstances[0].PubliclyAccessible' must return false. If it says true, modify-db-instance --no-publicly-accessible --apply-immediately and audit the security group for any 0.0.0.0/0 rule on 5432/3306. Private networking is defense in depth: even a leaked password is useless if the endpoint has no route from the internet.

The encryption one-way door. Teams routinely launch unencrypted “to move fast” and plan to “add encryption later.” There is no later. The only path is snapshot → copy-db-snapshot --kms-key-id <cmk> (this copy is where the data becomes encrypted) → restore-db-instance-from-db-snapshot into a new instance → repoint the app → delete the old one. That is a cutover with downtime and risk. Ticking --storage-encrypted at create costs nothing and avoids all of it. Decide encryption on day zero, every time.

The snapshot-on-delete gotcha. delete-db-instance with --skip-final-snapshot (or a terraform destroy where skip_final_snapshot = true) deletes the data and its automated backups immediately and irreversibly. People do this in “cleanup” scripts and destroy production. Guardrails: keep deletion_protection = true on anything real, never default skip_final_snapshot to true in modules, and set delete_automated_backups = false if you want the PITR history to survive the instance for a grace period.

Best practices

Security notes

RDS security is layered, and the launch is where most of it is decided:

Cost & sizing

RDS billing has more moving parts than a single instance-hour. Know the drivers so nothing surprises you:

Cost driver How it’s billed Notes / how to control
Instance hours Per second, by class, running or available Biggest lever; right-size; Graviton is cheaper; Reserved Instances save up to ~60%
Multi-AZ ~2× the instance cost (standby) Only enable where you need HA; a toggle, so add it when ready
Storage (GiB-month) gp3/gp2/io per GiB allocated You pay for allocated, not used; autoscaling grows the bill
Provisioned IOPS io1/io2 per IOPS-month Only pay this on io1/io2, or gp3 above baseline
Backup storage Free up to total DB size; overage per GiB Long retention + many snapshots add up
Snapshots Per GiB of snapshot storage Manual snapshots persist until deleted — including after instance delete
Data transfer Cross-AZ and egress charges Keep app and DB in the same AZ where sensible; cross-AZ replication has a cost
Secrets Manager Per secret-month + per 10k API calls The managed master password is one secret
KMS Per key-month + per request aws/rds is cheaper; CMK adds key-month cost
A stopped instance Still bills storage + backups; auto-starts after 7 days Stopping saves only compute, and not for long

Rough sizing to anchor expectations (on-demand, us-east-1, order-of-magnitude — confirm in the calculator):

Workload Class Storage Multi-AZ Rough USD/mo Rough INR/mo
Free-tier learning db.t4g.micro 20 GiB gp3 No ~$0 (12 mo) ~₹0
Small dev DB db.t4g.micro 20 GiB gp3 No ~$15 ~₹1,300
Small steady prod db.m6g.large 50 GiB gp3 No ~$130 ~₹11,000
Small steady prod, HA db.m6g.large 50 GiB gp3 Yes ~$260 ~₹22,000
Cache-heavy prod db.r6g.large 100 GiB gp3 Yes ~$400 ~₹34,000

Free-tier (first 12 months): 750 hours/month of db.t2/t3/t4g.micro (single-AZ), 20 GiB of gp2/gp3 storage, and 20 GiB of backup storage. Stay in those bounds and the lab costs nothing — but delete snapshots, which are not covered indefinitely.

Interview & exam questions

1. Why must a DB subnet group span at least two Availability Zones even for a single-AZ instance? So RDS has a place to put a standby if Multi-AZ is ever enabled, and to allow failover/placement flexibility. Creation fails without ≥2 AZs. (SAA-C03)

2. You need to encrypt an existing unencrypted RDS instance. What’s the procedure? Encryption at rest is create-time only. Take a snapshot, copy-db-snapshot with a --kms-key-id (the copy applies encryption), then restore-db-instance-from-db-snapshot into a new encrypted instance and cut the app over. (SAA-C03, SCS)

3. What does PubliclyAccessible=false actually change? It makes the endpoint’s DNS name resolve only to a private VPC IP, so nothing outside the VPC can route to it — independent of the security group, which is a second layer. (SAA-C03)

4. Why is db.t3.micro a poor choice for a steady-load production database? Burstable classes run on CPU credits; under sustained load they exhaust credits and either throttle (standard mode) or, in RDS’s default Unlimited mode, keep bursting and bill the surplus. Use db.m/db.r for steady load. (SAA-C03)

5. Automated backups vs manual snapshots — key difference? Automated backups enable point-in-time recovery within a 1–35 day retention and are deleted with the instance; manual snapshots restore only to their instant, live until you delete them, and survive the instance (and can be copied cross-Region/account). (CLF-C02, SAA-C03)

6. How does the Secrets Manager-managed master password improve security? RDS generates the password, stores it in a KMS-encrypted secret, and rotates it automatically — so it never appears in plaintext in Terraform state, CLI history or logs, and the app reads it at runtime. (DVA-C02, SCS)

7. A parameter change isn’t taking effect. Why? It’s a static parameter and is sitting in pending-reboot; static parameters only apply after a reboot-db-instance. (SOA-C02)

8. What is storage autoscaling and why turn it on? Setting MaxAllocatedStorage lets RDS grow the volume automatically (free < 10% for 5 min, 6-hour cooldown, grows only) so a filling disk expands instead of dropping the instance to read-only storage-full. (SOA-C02)

9. When would you choose Aurora over RDS? When you need the distributed auto-growing storage layer (to 128 TiB), up to 15 shared-storage read replicas with minimal lag, faster failover, or Serverless v2 autoscaling — otherwise RDS is simpler and cheaper. (SAA-C03, SAP)

10. How do you connect to a private RDS instance from your laptop without exposing it? Use SSM Session Manager port forwarding (AWS-StartPortForwardingSessionToRemoteHost) through an SSM-managed host to map the DB’s port to localhost — no public IP, no open bastion port. (SOA-C02, SCS)

11. What prevents an accidental terraform destroy from wiping the DB? Deletion protection blocks the delete until it’s toggled off, and a final snapshot (unless skip_final_snapshot=true) preserves the data on a deliberate delete. (SOA-C02)

12. Why connect by the endpoint DNS name and never a cached IP? On failover or storage changes the IP behind the endpoint changes while the DNS name stays stable; caching the IP breaks reconnection after any such event. (SAA-C03)

Quick check

  1. True/false: you can enable encryption at rest on an existing unencrypted RDS instance with a modify call.
  2. What one setting turns on storage autoscaling, and what does it prevent?
  3. Which instance family should a steady-load production database avoid, and why?
  4. You changed rds.force_ssl to 1 but connections still work without SSL. What did you forget?
  5. Name the two things that keep an RDS instance from being reachable on the internet.

Answers

  1. False. Encryption at rest is create-time only; you must snapshot, copy the snapshot with a KMS key, and restore.
  2. Setting MaxAllocatedStorage (a cap above allocated storage) turns on autoscaling; it prevents the read-only storage-full outage by growing the disk automatically.
  3. db.t (burstable) — under sustained load it exhausts CPU credits and either throttles or (RDS default Unlimited mode) bills the surplus. Use db.m/db.r.
  4. You forgot to rebootrds.force_ssl is a static parameter and stays in pending-reboot until a reboot applies it.
  5. PubliclyAccessible=false (endpoint resolves to a private IP only) and the security group (allow the engine port only from the app SG, never 0.0.0.0/0); place the DB in private subnets as well.

Glossary

Term Definition
RDS instance A managed database server (VM + storage) you access only via its endpoint and API, never a shell.
Endpoint The stable DNS name (…rds.amazonaws.com) you connect to; resolves to a private or public IP per PubliclyAccessible.
DB subnet group A named set of subnets across ≥2 AZs that tells RDS where the instance (and any standby) may live.
Instance class The compute size (db.t/db.m/db.r families) — vCPU, memory, network and EBS bandwidth.
Burstable (db.t) A class that runs on CPU credits; fine for spiky/dev load, wrong for steady load.
gp3 / io2 General-purpose SSD (default) vs Provisioned IOPS SSD for latency-critical, high-IOPS databases.
Storage autoscaling Auto-grows the volume up to MaxAllocatedStorage so it never hits storage-full.
PubliclyAccessible Whether the endpoint resolves to a public IP; false keeps the DB private.
Security group The stateful firewall attached to the DB; the real control over who can reach the port.
Parameter group The managed replacement for postgresql.conf/my.cnf; parameters are dynamic or static (reboot).
Option group Enables engine add-ons (audit plugins, TDE, etc.); often empty for basic MySQL/Postgres.
Master user The bootstrap admin account created with the instance; use it only to create app roles.
Managed master password A Secrets Manager secret RDS creates and rotates so the password is never in plaintext.
Automated backup / PITR Daily backups + transaction logs enabling point-in-time recovery within the retention (1–35 days).
Snapshot A manual point-in-time copy that survives the instance and can be copied across Regions/accounts.
Encryption at rest KMS-backed volume/backup/replica encryption, set at create only and never toggled after.
Multi-AZ A synchronous standby (or readable standbys) in another AZ for automatic failover — a create/modify toggle.
Deletion protection A flag that blocks DeleteDBInstance until it’s turned off.

Next steps

AWSRDSPostgreSQLMySQLSecrets Managergp3Multi-AZDatabases
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading