Skip to main content

AWS Relational Database Service (RDS) Aurora Postgres: Production VPC, Terraform Configuration

2260 words·
AWS AWS CLI Terraform RDS Aurora PostgreSQL PostgreSQL HA Cluster Virtual Private Cloud (VPC) EC2
Table of Contents
AWS-RDS-Postgres - This article is part of a series.
Part 3: This Article
GitHub Repository Available

Prerequisites
#

Install AWS CLI
#

# Update packages
sudo apt update

# Unstall zip tool
sudo apt install unzip -y

# Download AWS CLI zip file
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"

# Unzip
unzip awscliv2.zip

# Install
sudo ./aws/install
# Verify installation / check version
/usr/local/bin/aws --version

Configure AWS CLI
#

# Start AWS CLI configuration
aws configure

Install Terraform
#

# Install the HashiCorp GPG key
wget -O- https://apt.releases.hashicorp.com/gpg | gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null

# Verify the GPG key fingerprint
gpg --no-default-keyring --keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg --fingerprint

# Add the official HashiCorp repository 
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

# Install Terraform
sudo apt update && sudo apt-get install terraform
# Verify installation / check version
terraform version

List Available Aurora Postgres Versions
#

# List available PostgreSQL verions
aws rds describe-db-engine-versions \
    --engine aurora-postgresql \
    --query "DBEngineVersions[].EngineVersion" \
    --output table
  
# Shell output:
--------------------------
|DescribeDBEngineVersions|
+------------------------+
|  11.9                  |
|  11.21                 |
|  12.9                  |
|  12.15                 |
|  12.16                 |
|  12.17                 |
|  12.18                 |
|  12.19                 |
|  12.20                 |
|  12.22                 |
|  13.9                  |
|  13.11                 |
|  13.12                 |
|  13.13                 |
|  13.14                 |
|  13.15                 |
|  13.16                 |
|  13.18                 |
|  14.6                  |
|  14.8                  |
|  14.9                  |
|  14.10                 |
|  14.11                 |
|  14.12                 |
|  14.13                 |
|  14.15                 |
|  15.3                  |
|  15.4                  |
|  15.5                  |
|  15.6                  |
|  15.7                  |
|  15.8                  |
|  15.10                 |
|  16.1                  |
|  16.2                  |
|  16.3                  |
|  16.4                  |
|  16.4-limitless        |
|  16.6                  |
|  16.6-limitless        |
+------------------------+



Terraform Aurora Postgres Stack
#

File and Folder Structure
#

The file and folder structure of the Terraform project looks like this:

aws-rds-aurora-postgres-production-vpc
├── ec2.tf
├── outputs.tf
├── rds-aurora-postgres.tf
├── terraform.tf
├── variables.tf
└── vpc.tf

Terraform Configuration Files
#

Project Folder & Terraform Provider
#

# Create Terraform project folder
TF_PROJECT_NAME=aws-rds-aurora-postgres-production-vpc
mkdir $TF_PROJECT_NAME && cd $TF_PROJECT_NAME

  • terraform.tf
# Terraform Provider
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Provider AWS Region
provider "aws" {
  alias  = "aws_region"
  region = var.aws_region
}

Variables
#

  • variables.tf
# AWS Region
variable "aws_region" {
  description = "AWS Region"
  type        = string
  default     = "us-east-1"
}

# VPC Name
variable "vpc_name" {
  description = "The name of the VPC"
  type        = string
  default     = "Example-VPC"
}


## Postgres

# Postgres User
variable "postgres_user" {
  description = "Postgres default user"
  type        = string
  default     = "postgres"
}

# Postgres Password
variable "postgres_pw" {
  description = "Postgres default user"
  type        = string
  default     = "my-secure-pw"
}


## VPC & Subnets

# VPC CIDR
variable "vpc_cidr" {
  description = "VPC CIDR block"
  type        = string
  default     = "10.10.0.0/16"
}

# Public Subnets CIDR (Loop)
variable "subnets_public_cidr" {
  type    = list(string)
  default = ["10.10.0.0/24", "10.10.1.0/24"]
}

# Public Subnets AZ (Loop)
variable "subnets_public_azs" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b"]
}

# Private Subnets CIDR (Loop)
variable "subnets_private_cidr" {
  type    = list(string)
  default = ["10.10.2.0/24", "10.10.3.0/24"]
}

# Private Subnets AZ (Loop)
variable "subnets_private_azs" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b"]
}


## EC2 Instances

# SSH key pair name
variable "key_name" {
  default = "us-east-1-pc-le" # Define key pair name
}

# EC2 Image ID
variable "ami_id" {
  default = "ami-0e2c8caa4b6378d8c" # Define EC2 AMI ID
}

VPC and Subnets
#

  • vpc.tf
# VPC "10.10.0.0/16"
resource "aws_vpc" "vpc" {
  provider              = aws.aws_region
  cidr_block            = var.vpc_cidr
  enable_dns_support    = true
  enable_dns_hostnames  = true

  tags = {
    Name                = "${var.vpc_name}"
    Env                 = "Production"
  }
}

## Subnets

# Public Subnets (Loop)
resource "aws_subnet" "subnets_public" {
  provider                  = aws.aws_region
  count                     = length(var.subnets_public_cidr)
  vpc_id                    = aws_vpc.vpc.id
  cidr_block                = element(var.subnets_public_cidr, count.index)
  availability_zone         = element(var.subnets_public_azs, count.index)
  map_public_ip_on_launch   = true

  tags = {
    Name                    = "${var.vpc_name} Subnet-Public-${count.index + 1}"
    Env                     = "Production"
  }
}

# Private Subnets (Loop)
resource "aws_subnet" "subnets_private" {
  provider                  = aws.aws_region
  count                     = length(var.subnets_private_cidr)
  vpc_id                    = aws_vpc.vpc.id
  cidr_block                = element(var.subnets_private_cidr, count.index)
  availability_zone         = element(var.subnets_private_azs, count.index)

  tags = {
    Name                    = "${var.vpc_name} Subnet-Private-${count.index + 1}"
    Env                     = "Production"
  }
}

## Gateways

# Internet Gateway
resource "aws_internet_gateway" "vpc_igw" {
  provider      = aws.aws_region
  vpc_id        = aws_vpc.vpc.id

  depends_on = [aws_vpc.vpc]

  tags = {
    Name        = "${var.vpc_name} IGW"
    Env         = "Production"
  }
}

# Elastic IPs (EIP) for NAT Gateways (Loop)
resource "aws_eip" "nat_gw_eip" {
  provider  = aws.aws_region
  count     = length(var.subnets_public_cidr)
  domain    = "vpc"

  tags = {
    Name = "VPC NAT-GW EIP-${count.index + 1}"
    Env  = "Production"
  }
}

# NAT Gateways (Loop)
resource "aws_nat_gateway" "nat_gw" {
  provider      = aws.aws_region
  count         = length(var.subnets_public_cidr)
  allocation_id = element(aws_eip.nat_gw_eip[*].id, count.index)
  subnet_id     = aws_subnet.subnets_public[count.index].id

  depends_on = [aws_internet_gateway.vpc_igw]

  tags = {
    Name = "VPC NAT-GW-${count.index + 1}"
    Env  = "Production"
  }
}


## Routing

# Public Route Table
resource "aws_route_table" "public_rt" {
  provider = aws.aws_region
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.vpc_igw.id
  }

  depends_on = [
    aws_vpc.vpc,
    aws_internet_gateway.vpc_igw
    ]

  tags = {
    Name        = "${var.vpc_name} Public Route Table"
    Env         = "Production"
  }
}

# Private Route Tables (Loop)
resource "aws_route_table" "private_rt" {
  provider = aws.aws_region
  count    = length(var.subnets_private_cidr)
  vpc_id   = aws_vpc.vpc.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = element(aws_nat_gateway.nat_gw[*].id, count.index)
  }

  tags = {
    Name = "${var.vpc_name} Private Route Table-${count.index + 1}"
    Env  = "Production"
  }
}


# Associate Route Tables with Public Subnets
resource "aws_route_table_association" "subnets_public_ra" {
  provider       = aws.aws_region
  count          = length(var.subnets_public_cidr)
  subnet_id      = element(aws_subnet.subnets_public.*.id, count.index)
  route_table_id = aws_route_table.public_rt.id

  depends_on = [aws_route_table.public_rt]
}

# Associate Route Tables with Private Subnets
resource "aws_route_table_association" "private_rta" {
  provider       = aws.aws_region
  count          = length(var.subnets_private_cidr)
  subnet_id      = aws_subnet.subnets_private[count.index].id
  route_table_id = aws_route_table.private_rt[count.index].id
}

RDS Aurora PostgreSQL
#

  • rds-aurora-postgres.tf
# Aurora Subnet Group
resource "aws_db_subnet_group" "aurora_subnet_group" {
  provider      = aws.aws_region
  name          = "aurora-main"
  subnet_ids    = [aws_subnet.subnets_private[0].id, aws_subnet.subnets_private[1].id]

  depends_on    = [aws_subnet.subnets_private]

  tags = {
    Name        = "${var.vpc_name} Aurora Subnet Group"
    Env         = "Production"
  }
}

# Aurora Cluster
resource "aws_rds_cluster" "aurora_cluster" {
  provider                = aws.aws_region
  cluster_identifier      = "aurora-postgres-cluster"
  engine                  = "aurora-postgresql"
  engine_version          = "16.6"
  database_name           = "auroradb"
  master_username         = var.postgres_user
  master_password         = var.postgres_pw
  db_subnet_group_name    = aws_db_subnet_group.aurora_subnet_group.name
  vpc_security_group_ids  = [aws_security_group.sg_postgres.id]
  backup_retention_period = 7
  preferred_backup_window = "03:00-04:00"
  availability_zones      = [aws_subnet.subnets_private[0].availability_zone, aws_subnet.subnets_private[1].availability_zone]
  storage_encrypted       = true

  skip_final_snapshot     = true  # Ensure final snapshots are skipped during deletion
  final_snapshot_identifier = null  # Prevent Terraform from expecting a final snapshot identifier

  tags = {
    Name = "${var.vpc_name} Aurora Cluster"
    Env  = "Production"
  }

  depends_on = [aws_db_subnet_group.aurora_subnet_group]
}


## Aurora Instances

# Aurora Writer Instance
resource "aws_rds_cluster_instance" "aurora_writer_instance" {
  provider                = aws.aws_region
  identifier              = "aurora-postgres-writer"
  cluster_identifier      = aws_rds_cluster.aurora_cluster.id
  instance_class          = "db.t3.medium"
  engine                  = "aurora-postgresql"
  publicly_accessible     = false
  db_subnet_group_name    = aws_db_subnet_group.aurora_subnet_group.name

  tags = {
    Name = "${var.vpc_name} Aurora Writer Instance"
    Env  = "Production"
  }

  depends_on = [aws_rds_cluster.aurora_cluster]
}

# Aurora Reader Instance
resource "aws_rds_cluster_instance" "aurora_reader_instance" {
  provider                = aws.aws_region
  identifier              = "aurora-postgres-reader"
  cluster_identifier      = aws_rds_cluster.aurora_cluster.id
  instance_class          = "db.t3.medium"
  engine                  = "aurora-postgresql"
  publicly_accessible     = false
  db_subnet_group_name    = aws_db_subnet_group.aurora_subnet_group.name

  tags = {
    Name = "${var.vpc_name} Aurora Reader Instance"
    Env  = "Production"
  }

  depends_on = [aws_rds_cluster.aurora_cluster]
}


# Aurora Security Group
resource "aws_security_group" "sg_postgres" {
  provider      = aws.aws_region
  name          = "SG_Allow_Aurora_Postgres"
  description   = "Allow Aurora Postgres traffic"
  vpc_id        = aws_vpc.vpc.id

  ingress {
    description = "Allow Aurora Postgres from EC2 instances"
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    security_groups = [aws_security_group.sg_ec2.id] # EC2 security group
  }

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
  }

  depends_on = [aws_vpc.vpc]

  tags = {
    Name = "${var.vpc_name} Aurora Postgres SG"
    Env  = "Production"
  }
}

EC2 Instances
#

  • ec2.tf
# EC2 Instances in Public Subnets (Loop)
resource "aws_instance" "vm" {
  provider                  = aws.aws_region
  count                     = length(var.subnets_public_cidr) # Create as many instances as subnets
  ami                       = var.ami_id
  instance_type             = "t3.small"
  subnet_id                 = aws_subnet.subnets_public[count.index].id
  key_name                  = var.key_name
  vpc_security_group_ids    = [aws_security_group.sg_ec2.id]

  depends_on = [
    aws_vpc.vpc,
    aws_security_group.sg_ec2
  ]

  tags = {
    Name = "VM Public Subnet ${count.index + 1}"
    Env  = "Production"
  }
}


# Security Group for SSH Access and Ping
resource "aws_security_group" "sg_ec2" {
  provider = aws.aws_region
  name        = "SG"
  description = "Security group for SSH access and ping"
  vpc_id      = aws_vpc.vpc.id

  ingress {
    description = "Allow SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow ping"
    from_port   = 8
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "Allow all outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  depends_on = [aws_vpc.vpc]

  tags = {
    Name = "SG"
    Env  = "Production"
  }
}

Output
#

  • outputs.tf
# RDS Aurora Postgres Writer Endpoint
output "aurora_writer_endpoint" {
  value       = aws_rds_cluster.aurora_cluster.endpoint
}

# RDS Aurora Postgres Reader Endpoint
output "aurora_reader_endpoint" {
  value       = aws_rds_cluster.aurora_cluster.reader_endpoint
}

# RDS Aurora Postgres Cluster ID
output "aurora_cluster_id" {
  value       = aws_rds_cluster.aurora_cluster.id
  description = "The ID of the Aurora PostgreSQL cluster"
}


# EC2 Public IPs
output "EC2_Public_IPs" {
  description = "Public IPs of the EC2 instances"
  value       = aws_instance.vm[*].public_ip
}

# EC2 Private IPs
output "EC2_Private_IPs" {
  description = "Private IPs of the EC2 instances"
  value       = aws_instance.vm[*].private_ip
}



Configuration Deployment
#

Initialize Terraform Project
#

This will download and install the AWS Terraform provider defined in the terraform.tf file with “hashicorp/aws”, as well as setting up the configuration files in the project directory.

# Initialize the Terraform project
terraform init

Validate Configuration Files
#

# Validates the syntax and structure of Terraform configuration files
terraform validate

# Shell output:
Success! The configuration is valid.

Plan the Deployment
#

# Dry run / preview changes before applying them
terraform plan

Apply the Configuration
#

Note that it takes a while for the setup to complete:
aws_rds_cluster_instance.aurora_writer_instance: Creation complete after 10m6s [id=aurora-postgres-writer]

# Create network stack
terraform apply -auto-approve

# Shell output:
Apply complete! Resources: 25 added, 0 changed, 0 destroyed.

Outputs:

EC2_Private_IPs = [
  "10.10.0.204",
  "10.10.1.69",
]
EC2_Public_IPs = [
  "35.175.210.61",
  "44.193.28.10",
]
aurora_cluster_id = "aurora-postgres-cluster"
aurora_reader_endpoint = "aurora-postgres-cluster.cluster-ro-cpyy8k66ctzh.us-east-1.rds.amazonaws.com"
aurora_writer_endpoint = "aurora-postgres-cluster.cluster-cpyy8k66ctzh.us-east-1.rds.amazonaws.com"

RDS Endpoint:

  • RDS provides a single DNS endpoint regardless of whether the database is deployed in a Single-AZ or Multi-AZ configuration

  • This endpoint automatically resolves to the primary instance (writer) in your RDS setup.



Verify PostgreSQL Connectivity
#

SSH Into EC2 Instance
#

# SSH into EC2 instance 1
ssh -i /home/ubuntu/.ssh/us-east-1-pc-le.pem ubuntu@35.175.210.61

# SSH into EC2 instance 2
ssh -i /home/ubuntu/.ssh/us-east-1-pc-le.pem ubuntu@44.193.28.10

Install Postgres Client
#

# Install the "postgresql-common" package, used for the helper scripts
sudo apt install postgresql-common -y

# Add the PostgreSQL (PGDG) repository for latest PostgreSQL versions (bypass installation prompt)
yes "" | sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh

# Install Postgres client
sudo apt install postgresql-client -y

PostgreSQL Writer Endpoint
#

Connect to Endpoint
#

# Connect to PostgreSQL
psql -h aurora-postgres-cluster.cluster-cpyy8k66ctzh.us-east-1.rds.amazonaws.com -U postgres -d postgres

# Shell output
Password for user postgres: # Enter PW
  • -U postgres Username to authenticate with the PostgreSQL database

  • -d postgres Database name to connect to


Verify PostfreSQL Node Role
#

# Verify if the node is a recovery node:
SELECT pg_is_in_recovery();

# Shell output:
 pg_is_in_recovery
-------------------
 f
(1 row)

Create Example DB & Tables
#

# Create example database "example_database"
create database example_database;

# Connect to "example_database" database
\c example_database

# Create some tables
create table some_table_1(a varchar(100), b timestamp);
create table some_table_2(a varchar(100), b timestamp);
create table some_table_3(a varchar(100), b timestamp);

# Insert some values
insert into some_table_1 values('some value', '2024-01-01 01:01:01');
insert into some_table_2 values('some value', '2024-01-01 01:01:01');
insert into some_table_3 values('some value', '2024-01-01 01:01:01');
# Exit Postgres
\q

PostgreSQL Reader Endpoint
#

Connect to Endpoint
#

# Connect to PostgreSQL
psql -h aurora-postgres-cluster.cluster-ro-cpyy8k66ctzh.us-east-1.rds.amazonaws.com -U postgres -d postgres

# Shell output
Password for user postgres: # Enter PW
  • -U postgres Username to authenticate with the PostgreSQL database

  • -d postgres Database name to connect to


Verify PostfreSQL Node Role
#

# Verify if the node is a recovery node:
SELECT pg_is_in_recovery();

# Shell output:
 pg_is_in_recovery
-------------------
 t
(1 row)

List Databases
#

# List databases
\l

# Shell output:
       Name       |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | Locale | ICU Rules |   Access privileges
------------------+----------+----------+-----------------+-------------+-------------+--------+-----------+-----------------------
 auroradb         | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           |
 example_database | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           |
 postgres         | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           |
 rdsadmin         | rdsadmin | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | rdsadmin=CTc/rdsadmin
 template0        | rdsadmin | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | =c/rdsadmin          +
                  |          |          |                 |             |             |        |           | rdsadmin=CTc/rdsadmin
 template1        | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | =c/postgres          +
                  |          |          |                 |             |             |        |           | postgres=CTc/postgres
(6 rows)
# Exit Postgres
\q



Verify AWS Resources
#

List PostgreSQL Instances
#

# List RDS instances
aws rds describe-db-instances --query "DBInstances[?DBClusterIdentifier=='aurora-postgres-cluster'].{InstanceID:DBInstanceIdentifier, Subnets:DBSubnetGroup.Subnets[*].SubnetIdentifier}" --output json

# Shell output:
[
    {
        "InstanceID": "aurora-postgres-reader",
        "Subnets": [
            "subnet-02c2f0ecbc4add9c2",
            "subnet-023a32c426d08aa48"
        ]
    },
    {
        "InstanceID": "aurora-postgres-writer",
        "Subnets": [
            "subnet-02c2f0ecbc4add9c2",
            "subnet-023a32c426d08aa48"
        ]
    }
]

List Instance Status & Details
#

# List Master / Writer node details
aws rds describe-db-instances \
    --db-instance-identifier aurora-postgres-writer \
    --query "DBInstances[*].{InstanceID:DBInstanceIdentifier,Status:DBInstanceStatus,AZ:AvailabilityZone,Class:DBInstanceClass}" \
    --output table

# Shell output:
-----------------------------------------------------------------------
|                         DescribeDBInstances                         |
+------------+---------------+--------------------------+-------------+
|     AZ     |     Class     |       InstanceID         |   Status    |
+------------+---------------+--------------------------+-------------+
|  us-east-1a|  db.t3.medium |  aurora-postgres-writer  |  available  |
+------------+---------------+--------------------------+-------------+
# List Slave / Reader node details
aws rds describe-db-instances \
    --db-instance-identifier aurora-postgres-reader \
    --query "DBInstances[*].{InstanceID:DBInstanceIdentifier,Status:DBInstanceStatus,AZ:AvailabilityZone,Class:DBInstanceClass}" \
    --output table

# Shell output:
-----------------------------------------------------------------------
|                         DescribeDBInstances                         |
+------------+---------------+--------------------------+-------------+
|     AZ     |     Class     |       InstanceID         |   Status    |
+------------+---------------+--------------------------+-------------+
|  us-east-1b|  db.t3.medium |  aurora-postgres-reader  |  available  |
+------------+---------------+--------------------------+-------------+

Management Console
#

  • Verify the PostgreSQL cluster via the AWS Management Console



Links #

# GitHub Repository
https://github.com/jueklu/terraform-aws-rds-postgres
AWS-RDS-Postgres - This article is part of a series.
Part 3: This Article