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
- You are standing up PostgreSQL or MySQL workloads that need a managed single-instance (or Multi-AZ) database, and you want consistent encryption, backup, and networking posture across every environment.
- You run many databases across teams (microservices each owning a schema/instance) and want a paved-road module so app teams do not hand-roll RDS and miss security controls.
- You need compliance-friendly defaults — encryption at rest, no public exposure, audit log export to CloudWatch, enforced backup retention — without writing a Sentinel/OPA policy for every property.
- You want the master credential to live in AWS Secrets Manager, rotated by RDS-managed secrets, never appearing in
terraform.tfstateor version control.
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 config — live/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 config — live/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
- Never expose RDS publicly. This module hard-codes
publicly_accessible = falseand forcesstorage_encrypted = true; keep the database in private subnets and restrict the security group to the application tier’s SG, not a CIDR like0.0.0.0/0. - Let RDS own the master password.
manage_master_user_password = truekeeps the credential out ofterraform.tfstateand enables managed rotation. Read it frommaster_user_secret_arnat runtime rather than passing a plaintextpassword. - Pin
engine_versionand alignparameter_group_family. Floating versions cause unexpected reboots and drift; an explicit minor version plus a matching family (e.g.16.4withpostgres16) makes upgrades a deliberate, reviewed change. - Protect your recovery point. Keep
deletion_protection = trueandskip_final_snapshot = falsein production, and setbackup_retention_periodto at least 7–14 days so point-in-time recovery covers a long weekend incident. - Right-size storage and instance class for cost. Prefer
gp3overgp2for predictable IOPS at lower cost, enable storage autoscaling with a sanemax_allocated_storageceiling, and use Graviton classes (db.m6g,db.r6g) which are cheaper per vCPU than the equivalent Intel instances. - Tag for ownership and chargeback, and standardize naming. Use a
<service>-<env>identifier convention and carryEnvironment,Team, andCostCentertags so the database fleet is attributable in Cost Explorer and on-call rotations know who owns each instance.