IaC AWS

Terraform Module: AWS RDS Instance — Production-Grade Managed Databases Without the Footguns

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_db_instance covering encryption, multi-AZ, Secrets Manager passwords, parameter groups, and Performance Insights — production defaults baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "rds" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-rds?ref=v1.0.0"

  identifier             = "..."           # Unique RDS instance identifier; becomes the DNS name.
  engine                 = "..."           # Database engine (postgres, mysql, mariadb, oracle-se2, …
  engine_version         = "..."           # Engine version; pin a minor version to avoid surprise u…
  parameter_group_family = "..."           # Parameter group family matching the engine (e.g. postgr…
  instance_class         = "..."           # Instance class, e.g. db.t3.medium or db.m6g.large.
  db_subnet_group_name   = "..."           # Existing DB subnet group over private subnets.
  vpc_security_group_ids = ["...", "..."]  # Security groups controlling ingress to the DB port.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon RDS (aws_db_instance) is a managed relational database service that runs engines like PostgreSQL, MySQL, MariaDB, Oracle, and SQL Server while AWS handles patching, backups, failover, and host-level maintenance. A single aws_db_instance resource exposes more than forty arguments, and the unsafe defaults are exactly the ones that bite you in production: storage is unencrypted unless you ask for it, publicly_accessible is easy to flip on by accident, deletion_protection is off, skip_final_snapshot defaults in a way that can destroy your only recovery point, and an inline password lands in plaintext in your Terraform state.

Wrapping the resource in a reusable module lets you encode the correct defaults once — KMS encryption on, no public IP, deletion protection on, a master password generated and stored in Secrets Manager instead of state — and then hand teams a small, opinionated input surface. Instead of every squad copy-pasting a 60-line resource block and silently dropping the encryption argument, they call the module with an engine, a size, and a subnet group, and they inherit a database that will pass a security review. This module provisions the instance, an associated DB parameter group for engine tuning, a managed master-password secret, and CloudWatch log exports, with validations that stop obviously wrong inputs at plan time.

When to use it

Reach for Aurora (aws_rds_cluster) instead when you need a clustered, auto-scaling storage engine, reader endpoints, or Serverless v2. For a fleet of read-heavy reporting replicas, layer aws_db_instance replicas on top — but this module focuses on the primary instance pattern that covers the majority of line-of-business databases.

Module structure

terraform-module-aws-rds/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # parameter group, db instance, master secret wiring
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, endpoint, secret ARN, and key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # RDS-managed master secrets require KMS; default to the account's RDS key
  # unless the caller supplies an explicit CMK for the secret payload.
  secret_kms_key_id = coalesce(var.master_secret_kms_key_id, var.kms_key_id)

  tags = merge(
    {
      "Name"      = var.identifier
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-rds"
    },
    var.tags,
  )
}

# Engine tuning lives in a dedicated parameter group so changes are explicit
# and versioned rather than hidden on the default group.
resource "aws_db_parameter_group" "this" {
  name_prefix = "${var.identifier}-"
  family      = var.parameter_group_family
  description = "Parameter group for ${var.identifier} managed by Terraform"

  dynamic "parameter" {
    for_each = var.parameters
    content {
      name         = parameter.value.name
      value        = parameter.value.value
      apply_method = lookup(parameter.value, "apply_method", "immediate")
    }
  }

  tags = local.tags

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_db_instance" "this" {
  identifier = var.identifier

  # ---- Engine ----
  engine         = var.engine
  engine_version = var.engine_version
  instance_class = var.instance_class

  # ---- Storage ----
  allocated_storage     = var.allocated_storage
  max_allocated_storage = var.max_allocated_storage
  storage_type          = var.storage_type
  iops                  = var.storage_type == "io1" || var.storage_type == "io2" ? var.iops : null
  storage_encrypted     = true
  kms_key_id            = var.kms_key_id

  # ---- Credentials (master password managed by AWS in Secrets Manager) ----
  db_name                       = var.db_name
  username                      = var.master_username
  manage_master_user_password   = true
  master_user_secret_kms_key_id = local.secret_kms_key_id

  # ---- Networking (private by default) ----
  db_subnet_group_name   = var.db_subnet_group_name
  vpc_security_group_ids = var.vpc_security_group_ids
  port                   = var.port
  publicly_accessible    = false
  multi_az               = var.multi_az
  ca_cert_identifier     = var.ca_cert_identifier

  # ---- Backup & maintenance ----
  backup_retention_period  = var.backup_retention_period
  backup_window            = var.backup_window
  maintenance_window       = var.maintenance_window
  copy_tags_to_snapshot    = true
  delete_automated_backups = var.delete_automated_backups
  apply_immediately        = var.apply_immediately
  auto_minor_version_upgrade = var.auto_minor_version_upgrade

  # ---- Observability ----
  performance_insights_enabled          = var.performance_insights_enabled
  performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null
  monitoring_interval                   = var.monitoring_interval
  monitoring_role_arn                   = var.monitoring_interval > 0 ? var.monitoring_role_arn : null
  enabled_cloudwatch_logs_exports       = var.enabled_cloudwatch_logs_exports

  # ---- Tuning & safety ----
  parameter_group_name        = aws_db_parameter_group.this.name
  deletion_protection         = var.deletion_protection
  skip_final_snapshot         = var.skip_final_snapshot
  final_snapshot_identifier   = var.skip_final_snapshot ? null : "${var.identifier}-final-${formatdate("YYYYMMDDhhmmss", timestamp())}"
  allow_major_version_upgrade = var.allow_major_version_upgrade

  tags = local.tags

  lifecycle {
    # The timestamp() in final_snapshot_identifier changes every plan; ignore it
    # so the instance is not perpetually marked for an in-place update.
    ignore_changes = [final_snapshot_identifier]
  }
}

variables.tf

variable "identifier" {
  description = "Unique RDS instance identifier (lowercase, hyphenated, becomes the DNS name)."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,62}$", var.identifier)) && !can(regex("--|-$", var.identifier))
    error_message = "identifier must be 1-63 chars, start with a letter, be lowercase alphanumeric/hyphen, and not end in or contain consecutive hyphens."
  }
}

variable "engine" {
  description = "Database engine."
  type        = string

  validation {
    condition     = contains(["postgres", "mysql", "mariadb", "oracle-se2", "sqlserver-se", "sqlserver-ex", "sqlserver-web"], var.engine)
    error_message = "engine must be one of: postgres, mysql, mariadb, oracle-se2, sqlserver-se, sqlserver-ex, sqlserver-web."
  }
}

variable "engine_version" {
  description = "Engine version (e.g. '16.4' for PostgreSQL). Pin a minor version to avoid surprise upgrades."
  type        = string
}

variable "parameter_group_family" {
  description = "DB parameter group family, e.g. 'postgres16' or 'mysql8.0'. Must match the engine version."
  type        = string
}

variable "instance_class" {
  description = "Instance class, e.g. 'db.t3.medium' or 'db.m6g.large'."
  type        = string

  validation {
    condition     = can(regex("^db\\.", var.instance_class))
    error_message = "instance_class must start with 'db.' (e.g. db.t3.medium)."
  }
}

variable "allocated_storage" {
  description = "Initial allocated storage in GiB."
  type        = number
  default     = 50

  validation {
    condition     = var.allocated_storage >= 20 && var.allocated_storage <= 65536
    error_message = "allocated_storage must be between 20 and 65536 GiB."
  }
}

variable "max_allocated_storage" {
  description = "Upper limit in GiB for storage autoscaling. Set to 0 to disable autoscaling."
  type        = number
  default     = 200

  validation {
    condition     = var.max_allocated_storage == 0 || var.max_allocated_storage >= var.allocated_storage
    error_message = "max_allocated_storage must be 0 (disabled) or >= allocated_storage."
  }
}

variable "storage_type" {
  description = "Storage type: gp3, gp2, io1, or io2."
  type        = string
  default     = "gp3"

  validation {
    condition     = contains(["gp2", "gp3", "io1", "io2"], var.storage_type)
    error_message = "storage_type must be one of: gp2, gp3, io1, io2."
  }
}

variable "iops" {
  description = "Provisioned IOPS. Only used when storage_type is io1 or io2."
  type        = number
  default     = 3000
}

variable "kms_key_id" {
  description = "KMS key ARN for storage encryption. Leave null to use the default aws/rds key."
  type        = string
  default     = null
}

variable "master_secret_kms_key_id" {
  description = "Optional KMS key ARN for the managed master-user secret. Falls back to kms_key_id, then the default Secrets Manager key."
  type        = string
  default     = null
}

variable "db_name" {
  description = "Name of the initial database to create. Null for engines (e.g. SQL Server) that do not support it."
  type        = string
  default     = null
}

variable "master_username" {
  description = "Master user name. The password is generated by RDS and stored in Secrets Manager."
  type        = string
  default     = "dbadmin"

  validation {
    condition     = !contains(["admin", "root", "rdsadmin", "postgres"], lower(var.master_username))
    error_message = "master_username must not be a reserved name (admin, root, rdsadmin, postgres)."
  }
}

variable "db_subnet_group_name" {
  description = "Name of an existing DB subnet group (must reference private subnets)."
  type        = string
}

variable "vpc_security_group_ids" {
  description = "Security group IDs controlling ingress to the database port."
  type        = list(string)

  validation {
    condition     = length(var.vpc_security_group_ids) > 0
    error_message = "At least one security group ID is required."
  }
}

variable "port" {
  description = "Port the database listens on. Defaults per engine if null."
  type        = number
  default     = 5432
}

variable "multi_az" {
  description = "Deploy a synchronous standby in a second AZ for automatic failover."
  type        = bool
  default     = true
}

variable "ca_cert_identifier" {
  description = "Certificate authority for the server certificate (e.g. rds-ca-rsa2048-g1)."
  type        = string
  default     = "rds-ca-rsa2048-g1"
}

variable "backup_retention_period" {
  description = "Days to retain automated backups (1-35). 0 disables backups — not recommended."
  type        = number
  default     = 7

  validation {
    condition     = var.backup_retention_period >= 1 && var.backup_retention_period <= 35
    error_message = "backup_retention_period must be between 1 and 35 to keep point-in-time recovery enabled."
  }
}

variable "backup_window" {
  description = "Daily UTC window for automated backups, e.g. '03:00-04:00'."
  type        = string
  default     = "03:00-04:00"
}

variable "maintenance_window" {
  description = "Weekly UTC maintenance window, e.g. 'sun:04:30-sun:05:30'."
  type        = string
  default     = "sun:04:30-sun:05:30"
}

variable "delete_automated_backups" {
  description = "Whether to remove automated backups when the instance is deleted."
  type        = bool
  default     = false
}

variable "apply_immediately" {
  description = "Apply modifications immediately instead of during the next maintenance window."
  type        = bool
  default     = false
}

variable "auto_minor_version_upgrade" {
  description = "Allow automatic minor engine version upgrades during the maintenance window."
  type        = bool
  default     = true
}

variable "allow_major_version_upgrade" {
  description = "Permit major version upgrades (requires a matching engine_version change)."
  type        = bool
  default     = false
}

variable "performance_insights_enabled" {
  description = "Enable Performance Insights for query-level diagnostics."
  type        = bool
  default     = true
}

variable "performance_insights_retention_period" {
  description = "Days to retain Performance Insights data (7 = free tier, then 31..731 in multiples)."
  type        = number
  default     = 7
}

variable "monitoring_interval" {
  description = "Enhanced Monitoring granularity in seconds (0, 1, 5, 10, 15, 30, 60). 0 disables it."
  type        = number
  default     = 60

  validation {
    condition     = contains([0, 1, 5, 10, 15, 30, 60], var.monitoring_interval)
    error_message = "monitoring_interval must be one of: 0, 1, 5, 10, 15, 30, 60."
  }
}

variable "monitoring_role_arn" {
  description = "IAM role ARN for Enhanced Monitoring. Required when monitoring_interval > 0."
  type        = string
  default     = null
}

variable "enabled_cloudwatch_logs_exports" {
  description = "Log types to export to CloudWatch Logs, e.g. ['postgresql','upgrade'] or ['error','slowquery','general']."
  type        = list(string)
  default     = ["postgresql", "upgrade"]
}

variable "parameters" {
  description = "List of DB parameter group parameters to set."
  type = list(object({
    name         = string
    value        = string
    apply_method = optional(string, "immediate")
  }))
  default = []
}

variable "deletion_protection" {
  description = "Block accidental deletion of the instance."
  type        = bool
  default     = true
}

variable "skip_final_snapshot" {
  description = "Skip the final snapshot on destroy. Keep false in production to preserve a recovery point."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Additional tags merged onto every resource."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "RDS instance identifier."
  value       = aws_db_instance.this.id
}

output "arn" {
  description = "ARN of the RDS instance."
  value       = aws_db_instance.this.arn
}

output "endpoint" {
  description = "Connection endpoint in host:port format."
  value       = aws_db_instance.this.endpoint
}

output "address" {
  description = "DNS hostname of the instance (without the port)."
  value       = aws_db_instance.this.address
}

output "port" {
  description = "Port the database is listening on."
  value       = aws_db_instance.this.port
}

output "db_name" {
  description = "Name of the initial database, if created."
  value       = aws_db_instance.this.db_name
}

output "master_username" {
  description = "Master user name."
  value       = aws_db_instance.this.username
}

output "master_user_secret_arn" {
  description = "ARN of the Secrets Manager secret holding the RDS-managed master credential."
  value       = try(aws_db_instance.this.master_user_secret[0].secret_arn, null)
}

output "parameter_group_name" {
  description = "Name of the associated DB parameter group."
  value       = aws_db_parameter_group.this.name
}

output "resource_id" {
  description = "Region-unique, immutable resource ID (used for IAM database auth policies)."
  value       = aws_db_instance.this.resource_id
}

How to use it

module "rds_instance" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-rds?ref=v1.0.0"

  identifier             = "orders-prod"
  engine                 = "postgres"
  engine_version         = "16.4"
  parameter_group_family = "postgres16"
  instance_class         = "db.m6g.large"

  allocated_storage     = 100
  max_allocated_storage = 500
  storage_type          = "gp3"
  kms_key_id            = aws_kms_key.rds.arn

  db_name         = "orders"
  master_username = "ordersadmin"

  db_subnet_group_name   = aws_db_subnet_group.private.name
  vpc_security_group_ids = [aws_security_group.orders_db.id]
  port                   = 5432

  multi_az                = true
  backup_retention_period = 14
  deletion_protection     = true

  enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
  monitoring_interval             = 60
  monitoring_role_arn             = aws_iam_role.rds_enhanced_monitoring.arn

  parameters = [
    { name = "log_min_duration_statement", value = "500" },
    { name = "max_connections", value = "200", apply_method = "pending-reboot" },
  ]

  tags = {
    Environment = "prod"
    Team        = "fulfillment"
    CostCenter  = "ORD-7741"
  }
}

# Downstream: hand the connection details and secret ARN to an ECS task so the
# app reads its password from Secrets Manager at boot rather than from env vars.
resource "aws_ssm_parameter" "orders_db_endpoint" {
  name  = "/orders/prod/db/endpoint"
  type  = "String"
  value = module.rds_instance.endpoint
}

resource "aws_iam_role_policy" "app_read_db_secret" {
  name = "orders-read-db-secret"
  role = aws_iam_role.orders_task.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = module.rds_instance.master_user_secret_arn
    }]
  })
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/rds/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-rds?ref=v1.0.0"
}

inputs = {
  identifier = "..."
  engine = "..."
  engine_version = "..."
  parameter_group_family = "..."
  instance_class = "..."
  db_subnet_group_name = "..."
  vpc_security_group_ids = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/rds && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
identifier string Yes Unique RDS instance identifier; becomes the DNS name.
engine string Yes Database engine (postgres, mysql, mariadb, oracle-se2, sqlserver-*).
engine_version string Yes Engine version; pin a minor version to avoid surprise upgrades.
parameter_group_family string Yes Parameter group family matching the engine (e.g. postgres16).
instance_class string Yes Instance class, e.g. db.t3.medium or db.m6g.large.
db_subnet_group_name string Yes Existing DB subnet group over private subnets.
vpc_security_group_ids list(string) Yes Security groups controlling ingress to the DB port.
allocated_storage number 50 No Initial storage in GiB (20–65536).
max_allocated_storage number 200 No Storage autoscaling ceiling in GiB; 0 disables it.
storage_type string gp3 No gp2, gp3, io1, or io2.
iops number 3000 No Provisioned IOPS; used only for io1/io2.
kms_key_id string null No KMS key ARN for storage encryption (default aws/rds key if null).
master_secret_kms_key_id string null No KMS key for the managed master secret; falls back to kms_key_id.
db_name string null No Initial database to create.
master_username string dbadmin No Master user name (reserved names rejected).
port number 5432 No Database listening port.
multi_az bool true No Provision a synchronous standby for failover.
ca_cert_identifier string rds-ca-rsa2048-g1 No CA for the server TLS certificate.
backup_retention_period number 7 No Automated backup retention in days (1–35).
backup_window string 03:00-04:00 No Daily UTC backup window.
maintenance_window string sun:04:30-sun:05:30 No Weekly UTC maintenance window.
delete_automated_backups bool false No Remove automated backups on instance deletion.
apply_immediately bool false No Apply changes now instead of in the maintenance window.
auto_minor_version_upgrade bool true No Allow automatic minor version upgrades.
allow_major_version_upgrade bool false No Permit major version upgrades.
performance_insights_enabled bool true No Enable Performance Insights.
performance_insights_retention_period number 7 No PI retention in days (7 free, then 31–731).
monitoring_interval number 60 No Enhanced Monitoring interval in seconds; 0 disables.
monitoring_role_arn string null No IAM role for Enhanced Monitoring (required when interval > 0).
enabled_cloudwatch_logs_exports list(string) [“postgresql”,“upgrade”] No Log types exported to CloudWatch.
parameters list(object) [] No DB parameter group parameters (name/value/apply_method).
deletion_protection bool true No Block accidental instance deletion.
skip_final_snapshot bool false No Skip final snapshot on destroy.
tags map(string) {} No Additional tags merged onto every resource.

Outputs

Name Description
id RDS instance identifier.
arn ARN of the RDS instance.
endpoint Connection endpoint in host:port format.
address DNS hostname of the instance (without the port).
port Port the database is listening on.
db_name Name of the initial database, if created.
master_username Master user name.
master_user_secret_arn ARN of the Secrets Manager secret holding the master credential.
parameter_group_name Name of the associated DB parameter group.
resource_id Region-unique, immutable resource ID for IAM database auth policies.

Enterprise scenario

A fulfillment platform runs roughly thirty PostgreSQL databases — one per bounded-context microservice — across dev, staging, and prod. The platform team publishes this module at v1.0.0 so each service team provisions its own orders-prod, inventory-prod, and shipping-prod instance with Multi-AZ, 14-day backups, KMS encryption, and deletion protection enforced by default. Because the master password is generated by RDS into Secrets Manager, no credential ever lands in state or the service repos; the application task role is granted secretsmanager:GetSecretValue on the module’s master_user_secret_arn output, and a quarterly security audit confirms zero publicly accessible or unencrypted databases across the fleet.

Best practices

TerraformAWSRDS InstanceModuleIaC
Need this built for real?

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

Work with me

Comments

Keep Reading