Deploy RDS PostgreSQL with Read Replica

Use Terraform modules and GitLab CI/CD to deploy a PostgreSQL primary and read replica on AWS.

Introduction

30 Second Summary

Every time you scroll through social media or search for a product online, the app is reading from a database. High-traffic applications spread that load across multiple copies of the database called replicas, so no single instance gets overwhelmed.

In this project, you will use Terraform community modules to provision a PostgreSQL database with a read replica on AWS, all deployed automatically through a GitLab CI/CD pipeline.

What You'll Build

A complete infrastructure-as-code project using Terraform modules from the Terraform Registry to deploy an RDS PostgreSQL primary instance with an asynchronous read replica, managed through a GitLab CI/CD pipeline with manual approval gates.

By the end of this project, you'll have:

  • A production-grade Terraform configuration using three community modules (VPC, Security Group, RDS) to provision a master-replica PostgreSQL setup.
  • A working GitLab CI/CD pipeline with validate, plan, apply, and destroy stages using GitLab-managed Terraform state.
  • A deployed RDS PostgreSQL primary instance with an asynchronous read replica demonstrating replication on AWS.
  • Secret Mission: Add Enhanced Monitoring and CloudWatch log exports for production-grade database observability.

Are there any prerequisites?

This project assumes you have an AWS account (Free Tier works), a GitLab account, and Terraform, the AWS CLI, VS Code, and Git installed on your machine. You should also have your AWS CLI credentials configured.

If you have not set up these tools yet, complete the installation and configuration before starting Step 1.

Set Up AWS Credentials and GitLab Repository

Before you can write any Terraform code, you need two things: a connection to AWS so Terraform can provision resources, and a GitLab repository to store your code and run your CI/CD pipeline.

In this step, you will configure your AWS credentials, create the GitLab project that will host your pipeline, and scaffold the project directory with empty files ready for Terraform configuration.

In this step, get ready to:
  • Configure AWS CLI credentials and verify access.
  • Create a GitLab repository and set up CI/CD variables.
  • Initialize the project directory structure with Terraform files.
Configure AWS CLI credentials and verify access

The AWS CLI needs your access credentials to authenticate API calls on your behalf. Terraform uses these same credentials when provisioning infrastructure.

  • Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Git Bash (Windows) and press Enter to open it.
  • Configure your AWS credentials by running this command:
aws configure

What does this command do?

This command launches an interactive prompt that asks for four values. Enter each one when prompted:

  • AWS Access Key ID - paste your IAM user's access key.
  • AWS Secret Access Key - paste your IAM user's secret key.
  • Default region name - type us-east-1.
  • Default output format - press Enter to accept the default (json).
  • Verify your credentials are working by running this command:
aws sts get-caller-identity

You should see JSON output containing your Account ID, UserId, and Arn. This confirms your terminal can authenticate with AWS.

Seeing an error instead of JSON?

  • Double-check that you pasted your access key and secret key correctly. Run aws configure again to re-enter them.
  • Make sure your IAM user has programmatic access enabled in the AWS Console.

Help me troubleshoot my AWS CLI credentials

  • Confirm Terraform is available by running this command:
terraform version

You should see a version number in the output. This project requires Terraform 1.11.1 or higher.

Create a new GitLab repository and configure CI/CD variables

Your GitLab repository will hold the Terraform code and run the CI/CD pipeline that provisions your AWS infrastructure. You also need to store your AWS credentials as CI/CD variables so the pipeline can authenticate with AWS.

  • Open your browser and navigate to https://gitlab.com.
  • Click New project (or the + button in the top navigation).
  • Select Create blank project.
  • Enter terraform-rds-postgresql as the project name.
  • Leave the visibility as Private and check Initialize repository with a README.
  • Click Create project.

Now you need to add your AWS credentials as CI/CD variables so the pipeline can access your AWS account.

  • In the left sidebar of your new project, click Settings.
  • Click CI/CD.
  • Expand the Variables section.
  • Click Add variable and create the first variable with these settings:
  • Key: AWS_ACCESS_KEY_ID.
  • Value: paste your AWS access key ID.
  • Visibility: select Masked.
  • Check the Protect variable checkbox.
  • Click Add variable to save.
  • Repeat the process to add a second variable:
  • Key: AWS_SECRET_ACCESS_KEY.
  • Value: paste your AWS secret access key.
  • Visibility: select Masked.
  • Check the Protect variable checkbox.
  • Click Add variable to save.
  • Add a third variable for the AWS region:
  • Key: AWS_DEFAULT_REGION.
  • Value: us-east-1.
  • Visibility: you can leave this as Visible (the region is not sensitive).
  • Check the Protect variable checkbox.
  • Click Add variable to save.

Why mask and protect these variables?

Masking prevents the variable values from appearing in pipeline job logs. Protecting ensures they are only available in pipelines running on protected branches (like main). Together, these settings prevent accidental credential leakage.

You should now see three variables listed in the Variables section of your CI/CD settings.

  • Now clone your repository locally. Switch back to your terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository by running this command:
git clone [[GITLAB_REPO_URL="https://gitlab.com/your-username/terraform-rds-postgresql.git"]]

Where do I find my clone URL?

On your GitLab project page, click the blue Code button near the top right. Copy the URL under Clone with HTTPS.

  • Move into the cloned project folder by running:
cd terraform-rds-postgresql
  • Open this folder in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the terraform-rds-postgresql folder on your Desktop, then click Open.
Initialize the project directory structure

Terraform projects follow a standard file layout. Each file has a specific purpose: provider configuration, variable definitions, resource definitions, and outputs.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Create all the project files by running this command:
touch main.tf variables.tf outputs.tf versions.tf terraform.tfvars .gitlab-ci.yml .gitignore

What are these files for?

  • versions.tf - declares Terraform and provider version requirements.
  • variables.tf - defines input variables for the configuration.
  • main.tf - contains the main resource and module definitions.
  • outputs.tf - exposes values after Terraform applies (like endpoints).
  • terraform.tfvars - sets values for the declared variables.
  • .gitlab-ci.yml - defines the GitLab CI/CD pipeline stages and jobs.
  • .gitignore - tells Git which files to exclude from version control.
  • Confirm the files exist by running:
ls -a

You should see all seven files listed in the output, including the hidden files starting with a dot.

  • Now add the contents of your .gitignore file. Open .gitignore in the VS Code Explorer sidebar and paste the following content:
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc

What does this file do?

The .gitignore prevents Terraform's local working files from being committed to your repository. The .terraform/ directory contains downloaded provider plugins (large binary files). State files (*.tfstate) contain sensitive information and are managed remotely via GitLab.

  • Save .gitignore by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Don't see the files in the Explorer sidebar?

  • Files starting with a dot are hidden by default on some systems. In VS Code, they should appear in the Explorer automatically. If they do not, check that you opened the correct folder (terraform-rds-postgresql on your Desktop).

Help me find my project files in VS Code

✔️ Awesome, I've got everything!

Great. Make sure you have saved .gitignore and all other files are present in your project folder.

ⓧ I'd like to double check the full code

Your .gitignore should contain exactly this:

.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
.terraformrc
terraform.rc

All other files (main.tf, variables.tf, outputs.tf, versions.tf, terraform.tfvars, .gitlab-ci.yml) should be empty for now.

Your environment is ready. Next up, you will write the Terraform configuration using community modules to define your VPC, security group, and RDS instances.

Write the Terraform Configuration with Community Modules

Your project structure is ready with all the files in place. Now it's time to write the Terraform configuration that defines your entire database infrastructure.

Instead of writing every AWS resource from scratch, you'll use community modules from the Terraform Registry. These are pre-built, battle-tested configurations maintained by the Terraform community. They handle the complex details of VPC networking, security groups, and RDS instances so you can focus on the architecture.

In this step, get ready to:
  • Configure Terraform version requirements and the AWS provider.
  • Write VPC and security group modules for network infrastructure.
  • Write RDS primary and read replica modules for the PostgreSQL database.
Configure the Terraform and provider requirements

Every Terraform project starts with a configuration block that pins the Terraform version, declares which providers it needs, and specifies where to store state. This prevents version drift between team members and CI/CD environments.

  • In VS Code, open versions.tf and paste the following configuration:
terraform {
  required_version = ">= 1.11.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.28"
    }
  }

  backend "http" {}
}

provider "aws" {
  region = var.region
}

What does this code do?

This file locks down three things for your project:

  • required_version = ">= 1.11.1" ensures everyone running this code has a compatible Terraform version.
  • The required_providers block pins the AWS provider to version ~> 6.28 (any 6.x release from 6.28 onwards).
  • backend "http" {} is an empty block that GitLab CI populates automatically at runtime with its managed state backend credentials.
  • The provider block configures AWS to use a region variable you'll define in the next step.
  • Save versions.tf.

Why an empty HTTP backend?

GitLab-managed Terraform state uses the HTTP backend protocol. When your pipeline runs, GitLab automatically injects environment variables like TF_HTTP_ADDRESS and TF_HTTP_PASSWORD that tell Terraform where to store and lock state.

By leaving the block empty here, you avoid hardcoding any credentials in your repository.

✔️ Awesome, I've got everything!

Great. Double check you've saved versions.tf before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.11.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.28"
    }
  }

  backend "http" {}
}

provider "aws" {
  region = var.region
}
Write the VPC and security group module configurations

Your database needs a network to live in and firewall rules to control access. You'll use two community modules to set this up: one for the VPC (the virtual network) and one for the security group (the firewall).

  • In VS Code, open main.tf and paste the following data source and locals block at the top:
data "aws_availability_zones" "available" {}

locals {
  name     = var.project_name
  region   = var.region
  vpc_cidr = "10.0.0.0/16"
  azs      = slice(data.aws_availability_zones.available.names, 0, 2)

  tags = {
    Project   = local.name
    Terraform = "true"
  }
}

What does this code do?

This foundation block sets up shared values that every module below will reference:

  • The data source dynamically fetches available availability zones in your AWS region.
  • locals defines reusable values: the VPC CIDR block (10.0.0.0/16), the first 2 availability zones, and consistent tags for all resources.
  • Using slice(..., 0, 2) limits subnets to 2 AZs, which is enough for a primary + replica setup without overspending.

You won't see a change yet. The next chunk adds the VPC module that uses these values.

  • Below the locals block in main.tf, paste the VPC module configuration:
################################################################################
# VPC Module
################################################################################

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.6.1"

  name = local.name
  cidr = local.vpc_cidr

  azs              = local.azs
  public_subnets   = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k)]
  private_subnets  = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 3)]
  database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 6)]

  create_database_subnet_group = true

  tags = local.tags
}

What does this code do?

The VPC module from the Terraform Registry creates your entire network in one block:

  • It creates three subnet tiers (public, private, database) across 2 availability zones using the cidrsubnet function to calculate non-overlapping IP ranges.
  • create_database_subnet_group = true generates the DB subnet group that RDS requires for placing instances in private subnets.
  • Version 6.6.1 is pinned so your infrastructure stays reproducible even if the module publishes a new release.

You won't see a change yet. The next chunk adds the security group that controls database access.

  • Below the VPC module in main.tf, paste the security group module configuration:
################################################################################
# Security Group Module
################################################################################

module "security_group" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "6.0.0"

  name        = "${local.name}-postgresql"
  description = "Security group for PostgreSQL RDS"
  vpc_id      = module.vpc.vpc_id

  ingress_rules = {
    postgresql_vpc = {
      from_port   = 5432
      to_port     = 5432
      ip_protocol = "tcp"
      description = "PostgreSQL access from within VPC"
      cidr_ipv4   = module.vpc.vpc_cidr_block
    }
  }

  egress_rules = {
    all_outbound = {
      from_port   = 0
      to_port     = 0
      ip_protocol = "-1"
      cidr_ipv4   = "0.0.0.0/0"
      description = "Allow all outbound traffic"
    }
  }

  tags = local.tags
}

What does this code do?

This security group acts as the firewall for your PostgreSQL database:

  • The ingress_rules block allows TCP traffic on port 5432 (the PostgreSQL port) only from within the VPC CIDR. Nothing outside the VPC can reach the database directly.
  • The egress_rules block allows all outbound traffic so the database can reach AWS services for backups and monitoring.
  • Version 6.0.0 of this module uses a map-of-objects format for rules, which is the current API.
  • Save main.tf.

Seeing red underlines in VS Code?

If VS Code highlights var.project_name or var.region as errors, that's expected. You haven't defined variables.tf yet. Those warnings disappear in the next step.

help me understand why my Terraform variables are showing as undefined

Write the RDS primary and replica module configurations

Now for the core of the project: the database modules. You'll configure a primary RDS instance for writes and a read replica that receives data through asynchronous replication. This is the same pattern production teams use to scale read-heavy workloads.

  • Below the security group module in main.tf, paste the RDS primary instance module:
################################################################################
# RDS Primary Instance
################################################################################

module "primary" {
  source  = "terraform-aws-modules/rds/aws"
  version = "7.2.0"

  identifier = "${local.name}-primary"

  engine               = "postgres"
  engine_version       = "17"
  family               = "postgres17"
  major_engine_version = "17"
  instance_class       = "db.t4g.micro"

  allocated_storage     = 20
  max_allocated_storage = 100

  db_name  = "appdb"
  username = var.db_username
  port     = 5432

  password_wo         = var.db_password
  password_wo_version = 1

  manage_master_user_password = false

  multi_az               = false
  db_subnet_group_name   = module.vpc.database_subnet_group_name
  vpc_security_group_ids = [module.security_group.id]

  maintenance_window = "Mon:00:00-Mon:03:00"
  backup_window      = "03:00-06:00"

  backup_retention_period = 1
  skip_final_snapshot     = true
  deletion_protection     = false
  storage_encrypted       = false

  tags = local.tags
}

What does this code do?

This module provisions the primary PostgreSQL instance where all writes go:

  • engine_version = "17" uses PostgreSQL 17, the latest major version supported by RDS.
  • instance_class = "db.t4g.micro" is a Graviton2 ARM instance that's eligible for the AWS Free Tier.
  • password_wo is a write-only argument that prevents the password from being stored in Terraform state. This is a security best practice.
  • backup_retention_period = 1 enables automated backups. This is required before you can create a read replica.
  • skip_final_snapshot = true and deletion_protection = false make teardown easy for this learning project.

You won't see a change yet. The next chunk adds the replica that reads from this primary.

  • Below the primary module in main.tf, paste the RDS read replica module:
################################################################################
# RDS Read Replica
################################################################################

module "replica" {
  source  = "terraform-aws-modules/rds/aws"
  version = "7.2.0"

  identifier = "${local.name}-replica"

  replicate_source_db = module.primary.db_instance_identifier

  engine               = "postgres"
  engine_version       = "17"
  family               = "postgres17"
  major_engine_version = "17"
  instance_class       = "db.t4g.micro"

  allocated_storage     = 20
  max_allocated_storage = 100

  port = 5432

  multi_az               = false
  vpc_security_group_ids = [module.security_group.id]

  maintenance_window = "Tue:00:00-Tue:03:00"
  backup_window      = "03:00-06:00"

  backup_retention_period = 0
  skip_final_snapshot     = true
  deletion_protection     = false
  storage_encrypted       = false

  tags = local.tags
}

What does this code do?

This module creates a read replica that automatically receives data from the primary:

  • replicate_source_db = module.primary.db_instance_identifier tells AWS to create this instance as a replica of your primary. AWS handles all the replication plumbing.
  • The replica has no db_name, username, or password_wo because it inherits these from the primary automatically.
  • backup_retention_period = 0 disables backups on the replica. Only the primary needs backups since it's the source of truth.
  • The maintenance window is offset to Tuesday so the primary and replica don't restart at the same time.
  • Save main.tf.
  • Run terraform fmt in your terminal to verify formatting:
terraform fmt

If no filenames are printed, your formatting is correct. If Terraform prints a filename, it means it auto-fixed a spacing issue in that file.

Seeing an error from terraform fmt?

If you see a syntax error, double check that every opening brace { has a matching closing brace } and that all string values are wrapped in double quotes. Compare your code against the full file in the tab below.

help me fix a terraform fmt syntax error in my main.tf file

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf before moving on.

ⓧ I'd like to double check the full code

data "aws_availability_zones" "available" {}

locals {
  name     = var.project_name
  region   = var.region
  vpc_cidr = "10.0.0.0/16"
  azs      = slice(data.aws_availability_zones.available.names, 0, 2)

  tags = {
    Project   = local.name
    Terraform = "true"
  }
}

################################################################################
# VPC Module
################################################################################

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.6.1"

  name = local.name
  cidr = local.vpc_cidr

  azs              = local.azs
  public_subnets   = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k)]
  private_subnets  = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 3)]
  database_subnets = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 6)]

  create_database_subnet_group = true

  tags = local.tags
}

################################################################################
# Security Group Module
################################################################################

module "security_group" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "6.0.0"

  name        = "${local.name}-postgresql"
  description = "Security group for PostgreSQL RDS"
  vpc_id      = module.vpc.vpc_id

  ingress_rules = {
    postgresql_vpc = {
      from_port   = 5432
      to_port     = 5432
      ip_protocol = "tcp"
      description = "PostgreSQL access from within VPC"
      cidr_ipv4   = module.vpc.vpc_cidr_block
    }
  }

  egress_rules = {
    all_outbound = {
      from_port   = 0
      to_port     = 0
      ip_protocol = "-1"
      cidr_ipv4   = "0.0.0.0/0"
      description = "Allow all outbound traffic"
    }
  }

  tags = local.tags
}

################################################################################
# RDS Primary Instance
################################################################################

module "primary" {
  source  = "terraform-aws-modules/rds/aws"
  version = "7.2.0"

  identifier = "${local.name}-primary"

  engine               = "postgres"
  engine_version       = "17"
  family               = "postgres17"
  major_engine_version = "17"
  instance_class       = "db.t4g.micro"

  allocated_storage     = 20
  max_allocated_storage = 100

  db_name  = "appdb"
  username = var.db_username
  port     = 5432

  password_wo         = var.db_password
  password_wo_version = 1

  manage_master_user_password = false

  multi_az               = false
  db_subnet_group_name   = module.vpc.database_subnet_group_name
  vpc_security_group_ids = [module.security_group.id]

  maintenance_window = "Mon:00:00-Mon:03:00"
  backup_window      = "03:00-06:00"

  backup_retention_period = 1
  skip_final_snapshot     = true
  deletion_protection     = false
  storage_encrypted       = false

  tags = local.tags
}

################################################################################
# RDS Read Replica
################################################################################

module "replica" {
  source  = "terraform-aws-modules/rds/aws"
  version = "7.2.0"

  identifier = "${local.name}-replica"

  replicate_source_db = module.primary.db_instance_identifier

  engine               = "postgres"
  engine_version       = "17"
  family               = "postgres17"
  major_engine_version = "17"
  instance_class       = "db.t4g.micro"

  allocated_storage     = 20
  max_allocated_storage = 100

  port = 5432

  multi_az               = false
  vpc_security_group_ids = [module.security_group.id]

  maintenance_window = "Tue:00:00-Tue:03:00"
  backup_window      = "03:00-06:00"

  backup_retention_period = 0
  skip_final_snapshot     = true
  deletion_protection     = false
  storage_encrypted       = false

  tags = local.tags
}

Why community modules over raw resources?

The terraform-aws-modules organization maintains the most widely-used Terraform modules. Using these teaches you the pattern you'll encounter at companies using Terraform in production.

A single VPC module replaces 10+ individual resource blocks you would otherwise write and maintain yourself. The module handles route tables, NAT gateways, and subnet associations behind the scenes.

Your Terraform configuration now defines a complete infrastructure stack: a VPC with three subnet tiers, a security group limiting PostgreSQL access to the VPC, and two RDS instances connected by replication. Next up, you'll define the input variables and outputs that make this configuration flexible and inspectable.

Define Variables, Outputs, and tfvars

Your main.tf references several variables like var.region and var.db_password, but Terraform doesn't know what those variables are yet.

In this step, you'll define the input variables, create outputs so you can verify the deployed infrastructure, and set default values in a terraform.tfvars file. You'll also learn how to handle sensitive values like database passwords safely through CI/CD variables.

In this step, get ready to:
  • Define input variables for region, project name, database username, and password.
  • Create outputs to expose primary and replica endpoints after deployment.
  • Set non-sensitive defaults in a tfvars file and pass the password securely via CI/CD.
Define input variables in variables.tf

Terraform variables let you parameterize your configuration so the same code works across environments. Each variable needs a type, a description, and optionally a default value.

  • In VS Code, open the empty variables.tf file.
  • Add the first three variables by pasting this code:
variable "region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "rds-postgresql-demo"
}

variable "db_username" {
  description = "Master username for the RDS instance"
  type        = string
  default     = "dbadmin"
}

What do these variables do?

  • region controls which AWS region your infrastructure deploys to. The default is us-east-1.
  • project_name prefixes all resource names so you can identify what belongs to this project in the AWS console.
  • db_username sets the master database login name. Having a default means you don't need to supply it every time you run Terraform.

The database password needs special treatment. It should never appear in your code or state file.

  • Add the sensitive password variable below the db_username block:
variable "db_password" {
  description = "Master password for the RDS instance"
  type        = string
  sensitive   = true
}

Why is this variable marked sensitive?

Setting sensitive = true tells Terraform to redact the value from CLI output and plan logs. The variable has no default because you'll supply the password through a TF_VAR_db_password environment variable in the CI/CD pipeline.

Terraform automatically reads any environment variable prefixed with TF_VAR_ and maps it to the matching declared variable. This keeps secrets out of your repository entirely.

  • Save variables.tf.

Seeing a syntax error?

  • Make sure each variable block uses curly braces {} and that strings are wrapped in double quotes.
  • Check that there's no trailing comma after the last attribute in each block. HCL does not use commas between attributes.
  • Help me fix a syntax error in my variables.tf file.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

variable "region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "rds-postgresql-demo"
}

variable "db_username" {
  description = "Master username for the RDS instance"
  type        = string
  default     = "dbadmin"
}

variable "db_password" {
  description = "Master password for the RDS instance"
  type        = string
  sensitive   = true
}
Define outputs in outputs.tf

Outputs expose key information after Terraform finishes deploying. You'll use these to verify your primary and replica endpoints without digging through the AWS console.

  • Open the empty outputs.tf file in VS Code.
  • Paste the following output definitions:
output "primary_endpoint" {
  description = "The connection endpoint for the primary RDS instance"
  value       = module.primary.db_instance_endpoint
}

output "primary_identifier" {
  description = "The identifier of the primary RDS instance"
  value       = module.primary.db_instance_identifier
}

output "replica_endpoint" {
  description = "The connection endpoint for the read replica"
  value       = module.replica.db_instance_endpoint
}

output "replica_identifier" {
  description = "The identifier of the read replica RDS instance"
  value       = module.replica.db_instance_identifier
}

output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

output "database_subnet_group_name" {
  description = "The name of the database subnet group"
  value       = module.vpc.database_subnet_group_name
}

What do these outputs expose?

  • primary_endpoint and replica_endpoint give you the connection strings for each database instance. Applications use these to route writes to the primary and reads to the replica.
  • primary_identifier and replica_identifier are the AWS resource names you'll look for in the RDS console.
  • vpc_id and database_subnet_group_name confirm the networking resources that house your database.
  • Save outputs.tf.

Seeing a module reference error?

  • Confirm the module names match exactly: module.primary, module.replica, and module.vpc. These must match the module block names in your main.tf.
  • Make sure main.tf is saved. Terraform resolves module outputs from the configuration files in the same directory.
  • Help me fix a module reference error in my outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

output "primary_endpoint" {
  description = "The connection endpoint for the primary RDS instance"
  value       = module.primary.db_instance_endpoint
}

output "primary_identifier" {
  description = "The identifier of the primary RDS instance"
  value       = module.primary.db_instance_identifier
}

output "replica_endpoint" {
  description = "The connection endpoint for the read replica"
  value       = module.replica.db_instance_endpoint
}

output "replica_identifier" {
  description = "The identifier of the read replica RDS instance"
  value       = module.replica.db_instance_identifier
}

output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

output "database_subnet_group_name" {
  description = "The name of the database subnet group"
  value       = module.vpc.database_subnet_group_name
}
Create terraform.tfvars with non-sensitive values

A .tfvars file lets you set variable values without passing flags every time you run Terraform. You'll put non-sensitive defaults here and handle the password separately through the CI/CD pipeline.

  • Open the empty terraform.tfvars file in VS Code.
  • Paste the following values:
region       = "us-east-1"
project_name = "rds-postgresql-demo"
db_username  = "dbadmin"

Why no password in this file?

The db_password variable is deliberately excluded from this file. In the next step, you'll pass it as a masked CI/CD variable called TF_VAR_db_password in GitLab. Terraform automatically reads environment variables with the TF_VAR_ prefix and maps them to the matching declared variable.

This pattern keeps credentials out of version control entirely. The password never touches your repository.

  • Save terraform.tfvars.
  • Confirm all three files are saved by checking the Explorer sidebar in VS Code. None of the file names should have a dot indicator for unsaved changes.

File not appearing in the sidebar?

  • Make sure you created terraform.tfvars inside the terraform-rds-postgresql folder, not a subfolder.
  • Check the file extension. It must be exactly .tfvars with no extra characters.
  • Help me find my terraform.tfvars file in VS Code.

Your Terraform configuration now has all the inputs and outputs it needs. Next up, you'll write the GitLab CI/CD pipeline that validates, plans, and applies this infrastructure automatically.

Create the GitLab CI/CD Pipeline

Your Terraform configuration is complete, but running it manually on your machine is error-prone and hard to audit.

A CI/CD pipeline automates the plan-then-apply workflow that production teams use. In this step, you'll write a .gitlab-ci.yml file that validates your code, generates a plan, and waits for manual approval before deploying infrastructure.

In this step, get ready to:
  • Write the .gitlab-ci.yml pipeline configuration with validate, plan, apply, and destroy stages.
  • Configure GitLab-managed Terraform state with HTTP backend authentication.
  • Set the database password as a masked CI/CD variable.
Write the pipeline configuration

The .gitlab-ci.yml file tells GitLab how to run your Terraform commands inside a Docker container. The first half configures the execution environment, state backend, and shared setup.

  • In VS Code, open .gitlab-ci.yml (it should be empty) and paste the following pipeline header and state backend configuration:
image:
  name: hashicorp/terraform:1.15
  entrypoint: [""]

variables:
  TF_ROOT: ${CI_PROJECT_DIR}
  TF_STATE_NAME: default
  TF_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"

cache:
  key: terraform-providers
  paths:
    - ${TF_ROOT}/.terraform/

stages:
  - validate
  - plan
  - apply
  - destroy

before_script:
  - cd ${TF_ROOT}
  - export TF_HTTP_ADDRESS="${TF_ADDRESS}"
  - export TF_HTTP_LOCK_ADDRESS="${TF_ADDRESS}/lock"
  - export TF_HTTP_UNLOCK_ADDRESS="${TF_ADDRESS}/lock"
  - export TF_HTTP_USERNAME="gitlab-ci-token"
  - export TF_HTTP_PASSWORD="${CI_JOB_TOKEN}"
  - export TF_HTTP_LOCK_METHOD="POST"
  - export TF_HTTP_UNLOCK_METHOD="DELETE"
  - terraform init

What does this configuration do?

  • image uses the official hashicorp/terraform:1.15 Docker image so every pipeline job has Terraform pre-installed.
  • variables defines the project root and constructs the Terraform state API URL from GitLab's built-in CI variables.
  • cache preserves downloaded provider plugins between pipeline runs to speed up terraform init.
  • before_script runs before every job. It configures the HTTP backend credentials using the pipeline's CI_JOB_TOKEN and then initializes Terraform.

Now add the four pipeline jobs below the before_script section.

  • Paste the following job definitions at the bottom of .gitlab-ci.yml:
validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check -recursive
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

plan:
  stage: plan
  script:
    - terraform plan -out=plan.tfplan -input=false
  artifacts:
    paths:
      - ${TF_ROOT}/plan.tfplan
    expire_in: 7 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

apply:
  stage: apply
  script:
    - terraform apply -input=false plan.tfplan
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  environment:
    name: production

destroy:
  stage: destroy
  script:
    - terraform destroy -auto-approve -input=false
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
      allow_failure: true
  environment:
    name: production
    action: stop

What do these jobs do?

  • validate checks your syntax with terraform validate and enforces consistent formatting with terraform fmt -check -recursive.
  • plan generates an execution plan saved as an artifact. This lets you review exactly what Terraform will create before approving.
  • apply uses when: manual so it only runs when you explicitly click the play button. This is the approval gate.
  • destroy tears down all infrastructure with a single click. The allow_failure: true flag prevents a failed destroy from blocking the pipeline.
  • Save .gitlab-ci.yml.

Seeing YAML indentation errors?

YAML is sensitive to indentation. Make sure every nested key is indented with exactly 2 spaces (not tabs). The script list items should start with - (dash then space) at the correct indent level.

help me fix YAML indentation in my .gitlab-ci.yml file

✔️ Awesome, I've got everything!

Great. Double check you've saved .gitlab-ci.yml before moving on.

ⓧ I'd like to double check the full code

image:
  name: hashicorp/terraform:1.15
  entrypoint: [""]

variables:
  TF_ROOT: ${CI_PROJECT_DIR}
  TF_STATE_NAME: default
  TF_ADDRESS: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${TF_STATE_NAME}"

cache:
  key: terraform-providers
  paths:
    - ${TF_ROOT}/.terraform/

stages:
  - validate
  - plan
  - apply
  - destroy

before_script:
  - cd ${TF_ROOT}
  - export TF_HTTP_ADDRESS="${TF_ADDRESS}"
  - export TF_HTTP_LOCK_ADDRESS="${TF_ADDRESS}/lock"
  - export TF_HTTP_UNLOCK_ADDRESS="${TF_ADDRESS}/lock"
  - export TF_HTTP_USERNAME="gitlab-ci-token"
  - export TF_HTTP_PASSWORD="${CI_JOB_TOKEN}"
  - export TF_HTTP_LOCK_METHOD="POST"
  - export TF_HTTP_UNLOCK_METHOD="DELETE"
  - terraform init

validate:
  stage: validate
  script:
    - terraform validate
    - terraform fmt -check -recursive
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

plan:
  stage: plan
  script:
    - terraform plan -out=plan.tfplan -input=false
  artifacts:
    paths:
      - ${TF_ROOT}/plan.tfplan
    expire_in: 7 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

apply:
  stage: apply
  script:
    - terraform apply -input=false plan.tfplan
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  environment:
    name: production

destroy:
  stage: destroy
  script:
    - terraform destroy -auto-approve -input=false
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
      allow_failure: true
  environment:
    name: production
    action: stop
Understand GitLab-managed Terraform state

You configured the backend "http" {} block in versions.tf earlier. The pipeline's before_script fills in the connection details at runtime.

How does GitLab-managed state work?

GitLab provides a built-in HTTP backend for Terraform state. Instead of creating an S3 bucket and DynamoDB table yourself, GitLab stores your state file securely with automatic locking and encryption.

The TF_STATE_NAME variable (set to default) names the state file. The TF_HTTP_* environment variables tell Terraform where to read, lock, and unlock the state. CI_JOB_TOKEN authenticates automatically with no extra secrets needed.

Why GitLab-managed state instead of S3?

S3 with DynamoDB locking is the production gold standard for Terraform state. However, it creates a chicken-and-egg problem: you need infrastructure to store your infrastructure state.

GitLab-managed state eliminates that bootstrap complexity. It gives you the same benefits (locking, encryption, versioning) with zero setup cost.

Set the database password as a CI/CD variable

Your variables.tf declares db_password as a sensitive variable with no default. Terraform automatically reads environment variables prefixed with TF_VAR_ as input variables. You'll add TF_VAR_db_password in GitLab so the pipeline can supply the value securely.

  • Return to your terraform-rds-postgresql project in GitLab.
  • In the left sidebar, click **Settings**, then **CI/CD**.
  • Expand the **Variables** section.
  • Click **Add variable**.
  • In the **Key** field, enter TF_VAR_db_password.
  • In the **Value** field, enter a strong password (at least 8 characters, mix of letters and numbers).
  • Under **Visibility**, select **Masked**.
  • Check the **Protect variable** checkbox.
  • Click **Add variable** to save.

Why use TF_VAR_ prefix?

Terraform scans environment variables for the TF_VAR_ prefix and maps them to input variables. TF_VAR_db_password becomes the value for var.db_password in your configuration. This keeps secrets out of your code and state file.

  • Verify that you now see 4 variables listed: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, and TF_VAR_db_password.

Variable not showing up?

Make sure you clicked **Add variable** (not just filled in the fields). The variable should appear in the list immediately. If you chose **Masked and hidden**, the value will not be visible in the list, but the key name still appears.

help me troubleshoot my GitLab CI/CD variables

Your pipeline is configured and your secrets are secured. Next up, you'll push everything to GitLab and watch the pipeline validate, plan, and deploy your database infrastructure.

Deploy and Verify the Infrastructure

Your Terraform configuration is complete and your GitLab CI/CD pipeline is defined. Now it's time to push everything to GitLab and watch the automation take over.

The pipeline will validate your code, generate an execution plan, and wait for your manual approval before provisioning real AWS resources. This plan-then-apply workflow with approval gates is how production teams prevent accidental infrastructure changes.

In this step, get ready to:
  • Push your code to GitLab and trigger the CI/CD pipeline.
  • Approve the apply job and monitor RDS provisioning.
  • Verify asynchronous replication between the primary and replica in AWS.
Push to GitLab and run the pipeline

Your files are ready locally. Pushing them to the main branch triggers the pipeline automatically because the .gitlab-ci.yml rules match commits on main.

  • Commit all project files and push to GitLab by running these commands:
git add .
git commit -m "Add Terraform configuration and GitLab CI/CD pipeline"
git push origin main

What do these commands do?

  • git add . stages all files in the project directory for commit.
  • git commit saves a snapshot of your staged files to your local Git history.
  • git push origin main uploads your commits to the remote GitLab repository, which triggers the pipeline.
  • Switch to your browser and open your GitLab project.
  • In the left sidebar, click Build.
  • Click Pipelines.

You should see a pipeline running with the validate and plan stages executing automatically. The apply and destroy stages show a play button because they require manual triggering.

  • Wait for the validate job to complete with a green checkmark.
  • Wait for the plan job to complete with a green checkmark.
  • Click the plan job name to open its logs.

In the plan output, look for the summary line at the bottom. It should show resources to be created including the VPC, subnets, security group, RDS primary instance, and RDS replica.

What is a Terraform plan?

A Terraform plan is a dry run. It compares your configuration files against the current state (which is empty right now) and shows exactly what it will create, modify, or destroy.

This is the approval gate that production teams rely on. No infrastructure changes happen until a human reviews the plan and clicks apply.

Pipeline failed at validate or plan?

  • If the validate job failed with a formatting error, run terraform fmt -recursive locally, commit, and push again.
  • If the plan job failed with an authentication error, double-check that your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY CI/CD variables are set correctly and marked as protected.
  • If you see a state lock error, ensure the pipeline is not running another job simultaneously.

Help me debug my GitLab pipeline failure

Trigger the apply job and monitor deployment

The plan confirmed that Terraform will create the right resources. Now you approve the deployment by triggering the manual apply job.

  • Navigate back to the pipeline view by clicking Pipelines in the left sidebar under Build.
  • Click on the pipeline number to see all the stages.
  • Find the apply job (it shows a play button icon).
  • Click the play button to trigger the apply job.

This deployment takes 10-20 minutes

The RDS primary instance takes approximately 5-10 minutes to create. The read replica takes another 5-10 minutes after the primary is ready. This is normal for database provisioning.

You can monitor progress by clicking on the apply job to watch the logs in real time. Do not close the browser tab or cancel the job.

  • Click on the apply job name to open its log output.
  • Watch the logs as Terraform creates each resource (VPC, subnets, security group, then the RDS instances).
  • Wait for the job to complete with a green checkmark.

Once the apply job finishes, scroll to the bottom of the log output. You should see the Terraform outputs printed, including the primary and replica endpoints.

What should I see in the outputs?

The job log shows six outputs at the end: primary_endpoint, primary_identifier, replica_endpoint, replica_identifier, vpc_id, and database_subnet_group_name.

The endpoints are the connection addresses your application would use to talk to the databases. The primary handles reads and writes. The replica handles read-only traffic.

Apply job failed?

  • If you see a password-related error, confirm that the TF_VAR_db_password CI/CD variable is set in your GitLab project settings.
  • If you see a quota or limit error, check that your AWS account allows creating RDS instances in us-east-1.
  • If the job timed out, GitLab's default job timeout may be too short. Go to Settings > CI/CD > General pipelines and increase the timeout to 30 minutes.

Help me debug my Terraform apply failure

Verify replication in the AWS Console

The pipeline says everything deployed. Now you verify directly in AWS that the primary and replica are communicating.

  • Open the AWS Management Console in your browser.
  • Make sure your region is set to us-east-1 (US East N. Virginia) in the top-right dropdown.
  • Search for RDS in the top search bar and click RDS.
  • Click Databases in the left sidebar.

You should see two database instances listed: rds-postgresql-demo-primary and rds-postgresql-demo-replica.

  • Confirm the primary instance shows a status of Available.
  • Check that the primary's Role column displays Source. This means it is the replication source.
  • Confirm the replica instance also shows Available status.
  • Check that the replica's Role column displays Replica.
  • Click on the replica instance name to open its details.
  • Scroll down to the Replication section.
  • Confirm the replication state shows replicating.

What is asynchronous replication?

RDS read replicas use asynchronous replication. Writes go to the primary first, then replicate to the replica with a small delay (usually milliseconds to seconds).

This pattern is used for read scaling. Your application sends write queries to the primary endpoint and read queries to the replica endpoint, spreading the load across both instances.

Don't see both instances?

  • Make sure your console is set to the us-east-1 region. Resources only appear in the region where they were created.
  • If the replica shows a status other than Available, it may still be provisioning. Wait a few minutes and refresh the page.
  • If neither instance appears, return to the GitLab apply job log and check for errors at the end of the output.

Help me find my RDS instances in the AWS Console

Your database infrastructure is live. A PostgreSQL primary is accepting writes, and an asynchronous read replica is keeping a near-real-time copy of your data. Next up, you will tear everything down cleanly using the destroy job in your pipeline.

Tear Down the Infrastructure

Your PostgreSQL primary and read replica are running, replication is confirmed, and your pipeline is proven. The infrastructure works exactly as designed.

The read replica is not free-tier eligible and costs money every hour it runs. In this step, you will use the destroy job you built into your pipeline to tear down every AWS resource cleanly. This is the same pattern production teams use to decommission environments.

In this step, get ready to:
  • Trigger the manual destroy job from the GitLab pipeline.
  • Verify all AWS resources are destroyed.
  • Confirm no lingering costs remain.
Run the destroy job from the pipeline

Your .gitlab-ci.yml already includes a destroy stage configured with when: manual. This means it only runs when you explicitly trigger it.

  • In GitLab, navigate to Build in the left sidebar.
  • Click Pipelines.
  • Click into the latest pipeline run (the one that deployed your infrastructure).
  • Find the destroy job in the pipeline visualization. It appears as a manual action with a play button.
  • Click the play button on the destroy job to trigger it.

What does the destroy job do?

The destroy job runs terraform destroy -auto-approve -input=false. This tells Terraform to remove every resource it tracks in state, without prompting for confirmation.

Terraform destroys resources in dependency order. It deletes the replica first (since it depends on the primary), then the primary, then the security group, and finally the VPC and subnets.

  • Click into the running destroy job to watch its logs.
  • Monitor the output. The RDS instances take several minutes each to delete. You will see Terraform report each resource as it is destroyed.
  • Wait for the job to complete with a green checkmark and a "passed" status.

How long does the destroy take?

Deleting RDS instances typically takes 5-10 minutes each. The full destroy (replica, primary, security group, VPC) can take 10-20 minutes total. Let it run to completion.

  • Confirm the job log ends with a message indicating all resources have been destroyed.

Destroy job failed?

If the destroy job fails partway through, trigger it again. Terraform is idempotent. It will skip already-deleted resources and continue with the remaining ones.

If you see a permissions error, verify your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY CI/CD variables are still set correctly in Settings > CI/CD > Variables.

Why is my destroy job failing in GitLab?

Verify no lingering costs

A successful destroy job does not guarantee every resource is gone. Cloud providers occasionally have delays, and orphaned resources can silently accumulate charges. Always verify manually.

  • Switch to the AWS Console in your browser.
  • Navigate to the RDS service.
  • Click Databases in the left sidebar.
  • Confirm that no instances named rds-postgresql-demo-primary or rds-postgresql-demo-replica appear in the list.

Check the correct region

Make sure you are viewing the us-east-1 region in the AWS Console (shown in the top-right corner). Resources only appear in the region where they were created.

  • Navigate to the VPC service in the AWS Console.
  • Click Your VPCs and confirm that no VPC named rds-postgresql-demo appears.

Now verify the Terraform state in GitLab reflects the empty infrastructure.

  • Return to your GitLab project.
  • In the left sidebar, click Operate.
  • Click Terraform states.
  • Confirm the default state exists and is empty (no resources tracked).

Why check billing?

The RDS read replica was costing approximately $0.02 per hour while running. Even a few hours of forgetting to destroy it adds up. Building the habit of verifying resource deletion now saves real money on future projects.

  • Navigate to Billing and Cost Management in the AWS Console to confirm no unexpected charges are accumulating.

Still seeing RDS instances in the console?

RDS instances sometimes show a "Deleting" status for several minutes after Terraform reports them as destroyed. Wait a few minutes and refresh the page.

If instances persist with an "Available" status after 15+ minutes, the destroy job may have failed silently. Check the job logs in GitLab for errors and re-trigger the destroy job.

Help me figure out why my RDS instances are still showing after a Terraform destroy.

Your infrastructure is fully decommissioned. Every resource that Terraform created has been torn down through the same pipeline that built it. Ready for a bonus challenge? The Secret Mission adds production-grade observability to your database setup.

Secret mission

Add Enhanced Monitoring and CloudWatch Log Exports

Your database is deployed and replicating. But in production, a database without observability is a database waiting to surprise you. Can you add Enhanced Monitoring and CloudWatch log exports to see what's happening inside your instances at the OS level?

Clean Up Your Resources

Clean Up Your Resources

Your AWS infrastructure was already destroyed via the pipeline's destroy job. The only remaining resources are your local project files and your GitLab repository with its stored CI/CD variables.

Decide whether to keep your local files and GitLab repository for reference, or delete them entirely.

Resources you used:

  • Local project folder (terraform-rds-postgresql on your Desktop).
  • GitLab repository (terraform-rds-postgresql) with CI/CD variables (AWS credentials, database password).
  • AWS infrastructure (VPC, security group, RDS primary, RDS replica, IAM monitoring role, CloudWatch Log Groups). Already destroyed.

Keep everything running

No action needed. Choose this if you want to keep your Terraform configuration files and GitLab repository as a reference for future projects.

  • Your local files remain in the terraform-rds-postgresql folder on your Desktop.
  • Your GitLab repository retains the pipeline configuration and Terraform state history.
  • Your CI/CD variables (AWS credentials) remain stored in GitLab. These are masked but still active. Consider rotating your AWS access keys if you do not plan to use this repository again.

Pause - I'll come back to this later

Since the AWS resources are already destroyed, there are no ongoing costs. You can leave your files and repository in place without incurring charges.

  • Your local project folder stays on your Desktop for future reference.
  • Your GitLab repository remains available. When you return, push a new commit to trigger the pipeline and redeploy the infrastructure.
  • Your CI/CD variables remain configured. No re-setup needed when you come back.

Delete - I don't want to use this again

Remove all project files and the GitLab repository to start fresh.

  • Delete the local project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf terraform-rds-postgresql
  • On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force terraform-rds-postgresql
  • Remove the CI/CD variables from GitLab. Navigate to your terraform-rds-postgresql project, then go to Settings > CI/CD > Variables.
  • Click the delete icon next to each variable (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, TF_VAR_db_password) to remove them.
  • Delete the GitLab repository. Navigate to Settings > General, expand Advanced, and select Delete in the Delete project section. Confirm by typing the project name.
  • Verify in the AWS Console that no RDS instances remain in the us-east-1 region. The destroy job should have removed everything, but a quick check confirms no lingering resources.

What about the Terraform state in GitLab?

The GitLab-managed Terraform state is already empty after the successful destroy job. Deleting the GitLab repository removes the state file along with everything else.

Nice Work!

Nice Work!

You just built and deployed a production-grade database infrastructure project using the same patterns senior DevOps engineers rely on every day.

You've learned how to:

  • Build a Terraform configuration using community modules from the Terraform Registry to provision a VPC, security group, and RDS instances.
  • Deploy an RDS PostgreSQL primary instance with an asynchronous read replica on AWS for read scaling.
  • Create a GitLab CI/CD pipeline with validate, plan, apply, and destroy stages using GitLab-managed Terraform state and manual approval gates.
  • Secret Mission: Added Enhanced Monitoring and CloudWatch log exports for production-grade database observability.

Ready to quiz yourself?