Production-Grade PostgreSQL on AWS RDS — Multi-AZ, Bastion Hosts & CloudWatch Alarms
Deploy a managed, highly available PostgreSQL database on Amazon RDS with Multi-AZ failover, private subnet isolation, SG-to-SG chaining, bastion host access, automated backups, and CloudWatch monitoring. Covers both manual console setup and full bash automation.
Running your own PostgreSQL server on an EC2 instance means managing OS patches, backup schedules, replication, and failover yourself. Amazon RDS eliminates all of that — for the price of a few extra dollars per hour, you get Multi-AZ automatic failover, automated backups, encryption at rest, and minor version upgrades on a schedule.
Here's a complete breakdown of how to deploy a production-grade RDS PostgreSQL instance with defense-in-depth networking.
Architecture
The design follows a defense-in-depth networking model: the database has no internet route whatsoever. The only way to reach it is through a bastion host in the public subnet.
Your Workstation
│ SSH (port 22)
▼
Bastion Host (EC2 t3.micro) ← Public Subnet (10.0.10.0/24)
│ PostgreSQL (port 5432)
▼
RDS PostgreSQL (db.t3.micro) ← Private Subnets (10.0.1.0/24, 10.0.2.0/24)
│ across two Availability Zones (Multi-AZ)
└── Standby replica in AZ-1b (automatic failover)
---
Why Two Private Subnets in Different AZs?
RDS Multi-AZ requires a subnet group spanning at least two Availability Zones. AWS places the primary instance in one AZ and the synchronous standby replica in another. If the primary AZ has an outage, RDS automatically fails over to the standby — typically in under 2 minutes — and updates the DNS endpoint to point to the new primary.
You don't manage this failover; RDS does. Your application just reconnects to the same endpoint.
---
Security Group Chaining (SG-to-SG References)
The most important networking detail in this architecture is the RDS security group rule. Instead of allowing PostgreSQL traffic from a specific IP address, we allow it from the bastion's security group ID.
Bastion SG → RDS SG (port 5432 from bastion-sg)
This is more powerful than IP-based rules because:
bastion-sg can reach the database — no need to update rules if the bastion is replaced.---
Choosing the Right RDS Template
The AWS console offers three templates: Production, Dev/Test, and Free Tier.
| Template | Multi-AZ | Cost | |---|---|---| | Free Tier | ❌ Disabled (greyed out) | Free for 12 months | | Dev/Test | ✅ Available | ~$0.036/hr for db.t3.micro | | Production | ✅ Enabled by default | Same pricing, stricter defaults |
Important: The Free Tier template disables Multi-AZ entirely — you can't even toggle it on. To practice the full high-availability architecture, select Dev/Test and expect ~$0.06/hour for the practice session. Always clean up when done.
---
Step-by-Step: What Gets Provisioned
1. VPC with DNS Hostnames Enabled
RDS gives you a DNS endpoint like my-postgres-db.abc123.us-east-1.rds.amazonaws.com. For instances inside your VPC to resolve this, you must enable DNS hostnames on the VPC.
aws ec2 modify-vpc-attribute \
--vpc-id "$VPC_ID" \
--enable-dns-hostnames
This is a common gotcha — skipping it means psql -h times out with no obvious error.
2. DB Subnet Group
A DB Subnet Group tells RDS which subnets it can place the primary and standby instances in. You must include subnets from at least two AZs.
3. RDS Instance Configuration
Key settings for a production-grade instance:
aws rds create-db-instance \
--db-instance-identifier "my-postgres-db" \
--db-instance-class "db.t3.micro" \
--engine "postgres" \
--engine-version "18.x" \
--master-username "dbadmin" \
--master-user-password "$DB_PASSWORD" \
--allocated-storage 20 \
--storage-type "gp3" \
--storage-encrypted \
--multi-az \
--backup-retention-period 7 \
--db-subnet-group-name "A04-rds-subnet-group" \
--vpc-security-group-ids "$RDS_SG_ID" \
--no-publicly-accessible
Critical flags:
--storage-encrypted — Encrypts data at rest (free on RDS)--multi-az — Deploys synchronous standby in a second AZ--backup-retention-period 7 — 7-day automated backups with point-in-time recovery--no-publicly-accessible — No public internet route to the DB4. Custom DB Parameter Group
For performance tuning, create a custom parameter group instead of using defaults:
aws rds create-db-parameter-group \
--db-parameter-group-name "A04-postgres-params" \
--db-parameter-group-family "postgres18" \
--description "Custom PostgreSQL parameters"
aws rds modify-db-parameter-group \
--db-parameter-group-name "A04-postgres-params" \
--parameters \
"ParameterName=max_connections,ParameterValue=200,ApplyMethod=pending-reboot" \
"ParameterName=shared_buffers,ParameterValue=262144,ApplyMethod=pending-reboot"
---
Accessing the Database via Bastion Host
The bastion is a small EC2 instance (t3.micro) in the public subnet. It acts as a jump box — you SSH into the bastion, then connect from there to the private RDS endpoint.
# 1. SSH into the bastion
ssh -i bastion-key.pem ec2-user@<BASTION_PUBLIC_IP>
2. Install the PostgreSQL client
sudo yum install postgresql18 -y
3. Connect to RDS
psql -h my-postgres-db.abc123.ap-south-1.rds.amazonaws.com -U dbadmin -d postgres
Once connected, verify with a quick smoke test:
CREATE DATABASE testdb;
\c testdb
CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100), email VARCHAR(100));
INSERT INTO users (name, email) VALUES ('Test User', '[email protected]');
SELECT * FROM users;
---
CloudWatch Alarms
Two essential alarms for production monitoring:
CPU Utilization (> 80%):
aws cloudwatch put-metric-alarm \
--alarm-name "rds-high-cpu" \
--metric-name "CPUUtilization" \
--namespace "AWS/RDS" \
--dimensions "Name=DBInstanceIdentifier,Value=my-postgres-db" \
--statistic "Average" \
--period 300 \
--threshold 80 \
--comparison-operator "GreaterThanThreshold" \
--evaluation-periods 2
Free Storage Space (< 2 GB):
aws cloudwatch put-metric-alarm \
--alarm-name "rds-low-storage" \
--metric-name "FreeStorageSpace" \
--namespace "AWS/RDS" \
--dimensions "Name=DBInstanceIdentifier,Value=my-postgres-db" \
--statistic "Average" \
--period 300 \
--threshold 2000000000 \
--comparison-operator "LessThanThreshold" \
--evaluation-periods 1
---
Cleanup Order Matters
RDS cleanup has strict dependency ordering. Getting it wrong results in errors like "resource in use":
1. Delete RDS instance (wait for full deletion — this takes 5–10 minutes)
2. Terminate bastion host (wait for termination)
3. Delete DB subnet group and parameter group
4. Delete CloudWatch alarms
5. Delete security groups (RDS SG first, then bastion SG)
6. Delete VPC resources (IGW, subnets, route table, VPC)
7. Delete key pair and local .pem file
The RDS instance must be fully deleted before you can remove the subnet group. Security groups can't be deleted while attached to running instances or RDS. Always wait for instance termination before proceeding.
---
Key Takeaways
DB_PASSWORD="secret" ./deploy-rds.sh is the pattern — secrets never live in scripts checked into version control.