Back to articles
AWS / DatabasesJuly 21, 20266 min read

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.

code read-only
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.

code read-only
Bastion SG  →  RDS SG (port 5432 from bastion-sg)

This is more powerful than IP-based rules because:

  • Dynamic: Any EC2 instance attached to bastion-sg can reach the database — no need to update rules if the bastion is replaced.
  • Principle of Least Privilege: Only the bastion can reach the DB, not any random IP.
  • ---

    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.

    bash read-only
    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:

    bash read-only
    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 DB
  • 4. Custom DB Parameter Group

    For performance tuning, create a custom parameter group instead of using defaults:

    bash read-only
    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.

    bash read-only
    # 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:

    sql read-only
    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%):

    bash read-only
    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):

    bash read-only
    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

  • Enable DNS hostnames on your VPC. Without it, the RDS DNS endpoint won't resolve inside the VPC and your connection will silently time out.
  • Security group references > IP allowlists. SG-to-SG chaining makes your rules dynamic and instance-agnostic.
  • Free Tier disables Multi-AZ. To practice high availability, use Dev/Test and budget a few cents for the session.
  • Cleanup order is critical. RDS → EC2 → SGs → Subnet Group → VPC. Going out of order leaves orphaned resources that block deletion.
  • Pass the DB password as an environment variable, never hardcode it. DB_PASSWORD="secret" ./deploy-rds.sh is the pattern — secrets never live in scripts checked into version control.
  • P
    Pratik Sangani
    Backend Developer & Architect
    Get in touch