Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for AWS DMS covering a private Multi-AZ replication instance, KMS-encrypted source/target endpoints backed by Secrets Manager, and a full-load-and-CDC replication task — 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 "dms" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-dms?ref=v1.0.0"
name = "..." # Base name for the instance, endpoints, and task.
subnet_ids = ["...", "..."] # >= 2 private subnets across >= 2 AZs.
vpc_security_group_ids = ["..."] # Security groups allowing egress to source & target.
source_engine_name = "..." # e.g. postgres, mysql, oracle, sqlserver.
source_secrets_manager_arn = "..." # Secret holding source host/port/user/password.
target_engine_name = "..." # e.g. postgres, mysql, aurora-postgresql.
target_secrets_manager_arn = "..." # Secret holding target host/port/user/password.
secrets_manager_access_role_arn = "..." # IAM role DMS assumes to read the two secrets.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Database Migration Service (DMS) moves data between a source and a target database — homogeneous (Postgres→Postgres) or heterogeneous (Oracle→Aurora PostgreSQL) — using a managed replication instance that connects to two endpoints and runs a replication task. The task can do a one-time full load, ongoing change data capture (CDC), or both, which is the only safe pattern for a cutover with near-zero downtime. The moving parts are spread across four resources (aws_dms_replication_subnet_group, aws_dms_replication_instance, two aws_dms_endpoints, and aws_dms_replication_task), and the defaults across them are exactly the ones that fail a security review.
The replication instance defaults to publicly_accessible = true and a single-AZ deployment, so a careless apply puts a database-touching box on the public internet with no failover. Endpoints default to ssl_mode = "none", meaning credentials and rows travel in the clear, and the most common pattern people copy embeds a plaintext password directly in the resource — which then lands in terraform.tfstate. DMS also requires an account-level dms-vpc-role to manage subnets before the subnet group will even create, which trips up first-time users.
Wrapping all of this in a module lets you encode the correct posture once: a Multi-AZ instance with publicly_accessible = false, KMS encryption on the instance and both endpoints, ssl_mode = "require" enforced, and endpoint credentials sourced from Secrets Manager via secrets_manager_arn + secrets_manager_access_role_arn rather than inline strings — so no host, user, or password is ever written to state. The module provisions the subnet group, the instance, both endpoints, and the task, with validations that reject obviously wrong engines or undersized subnet lists at plan time.
When to use it
- You are running a database migration or continuous replication between RDS, Aurora, or self-managed engines and want the replication instance private, encrypted, and Multi-AZ by default.
- You need a heterogeneous migration (e.g. Oracle or SQL Server → Aurora PostgreSQL) with a repeatable full-load-and-CDC task you can tear down and recreate per environment.
- You want endpoint credentials to live in Secrets Manager, read by DMS at runtime, never appearing in
terraform.tfstateor a service repo. - You run many migrations across teams and want a paved-road module so nobody hand-rolls a publicly accessible, unencrypted replication instance.
For a serverless, pay-per-use migration that scales capacity automatically and needs no instance sizing, reach for DMS Serverless (aws_dms_replication_config) instead — it replaces the instance + task pair with a single capacity-managed config. This module targets the classic provisioned-instance pattern, which remains the right choice when you need predictable throughput, a long-running CDC pipeline, or fine control over replication_task_settings.
Module structure
terraform-module-aws-dms/
├── versions.tf # provider + Terraform version pins
├── main.tf # subnet group, instance, source/target endpoints, task
├── variables.tf # var-driven inputs with validations
└── outputs.tf # instance ARN, endpoint ARNs, task ARN, key attributes
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
tags = merge(
{
"Name" = var.name
"ManagedBy" = "terraform"
"Module" = "terraform-module-aws-dms"
},
var.tags,
)
}
# The replication instance lives in these subnets. DMS needs the account-level
# dms-vpc-role to manage them; supply at least two subnets across two AZs.
resource "aws_dms_replication_subnet_group" "this" {
replication_subnet_group_id = "${var.name}-subnet-group"
replication_subnet_group_description = "Subnet group for ${var.name} managed by Terraform"
subnet_ids = var.subnet_ids
tags = local.tags
}
resource "aws_dms_replication_instance" "this" {
replication_instance_id = var.name
replication_instance_class = var.replication_instance_class
# ---- Storage & engine ----
allocated_storage = var.allocated_storage
engine_version = var.engine_version
kms_key_arn = var.kms_key_arn
# ---- Networking (private by default) ----
replication_subnet_group_id = aws_dms_replication_subnet_group.this.id
vpc_security_group_ids = var.vpc_security_group_ids
publicly_accessible = false
multi_az = var.multi_az
# ---- Maintenance ----
preferred_maintenance_window = var.preferred_maintenance_window
auto_minor_version_upgrade = var.auto_minor_version_upgrade
allow_major_version_upgrade = var.allow_major_version_upgrade
apply_immediately = var.apply_immediately
tags = local.tags
}
# Source endpoint: credentials come from Secrets Manager, never inline.
resource "aws_dms_endpoint" "source" {
endpoint_id = "${var.name}-source"
endpoint_type = "source"
engine_name = var.source_engine_name
database_name = var.source_database_name
kms_key_arn = var.kms_key_arn
ssl_mode = var.ssl_mode
secrets_manager_arn = var.source_secrets_manager_arn
secrets_manager_access_role_arn = var.secrets_manager_access_role_arn
extra_connection_attributes = var.source_extra_connection_attributes
tags = local.tags
}
# Target endpoint: same secrets-driven pattern.
resource "aws_dms_endpoint" "target" {
endpoint_id = "${var.name}-target"
endpoint_type = "target"
engine_name = var.target_engine_name
database_name = var.target_database_name
kms_key_arn = var.kms_key_arn
ssl_mode = var.ssl_mode
secrets_manager_arn = var.target_secrets_manager_arn
secrets_manager_access_role_arn = var.secrets_manager_access_role_arn
extra_connection_attributes = var.target_extra_connection_attributes
tags = local.tags
}
resource "aws_dms_replication_task" "this" {
count = var.create_replication_task ? 1 : 0
replication_task_id = "${var.name}-task"
migration_type = var.migration_type
replication_instance_arn = aws_dms_replication_instance.this.replication_instance_arn
source_endpoint_arn = aws_dms_endpoint.source.endpoint_arn
target_endpoint_arn = aws_dms_endpoint.target.endpoint_arn
table_mappings = var.table_mappings
replication_task_settings = var.replication_task_settings
# Leave the task stopped on create; start it deliberately after validation.
start_replication_task = var.start_replication_task
tags = local.tags
}
variables.tf
variable "name" {
description = "Base name for the replication instance, endpoints, subnet group, and task."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,40}$", var.name)) && !can(regex("--|-$", var.name))
error_message = "name must start with a letter, be lowercase alphanumeric/hyphen, and not end in or contain consecutive hyphens."
}
}
variable "subnet_ids" {
description = "At least two private subnet IDs spanning two AZs for the replication subnet group."
type = list(string)
validation {
condition = length(var.subnet_ids) >= 2
error_message = "subnet_ids must contain at least two subnets across two availability zones."
}
}
variable "vpc_security_group_ids" {
description = "Security group IDs for the replication instance (must allow egress to source & target)."
type = list(string)
validation {
condition = length(var.vpc_security_group_ids) > 0
error_message = "At least one security group ID is required."
}
}
variable "replication_instance_class" {
description = "Replication instance class, e.g. dms.t3.medium or dms.c5.large."
type = string
default = "dms.t3.medium"
validation {
condition = can(regex("^dms\\.", var.replication_instance_class))
error_message = "replication_instance_class must start with 'dms.' (e.g. dms.t3.medium)."
}
}
variable "allocated_storage" {
description = "Storage in GiB allocated to the replication instance (5-6144)."
type = number
default = 50
validation {
condition = var.allocated_storage >= 5 && var.allocated_storage <= 6144
error_message = "allocated_storage must be between 5 and 6144 GiB."
}
}
variable "engine_version" {
description = "DMS engine version (e.g. '3.5.3'). Pin it to avoid surprise upgrades."
type = string
default = "3.5.3"
}
variable "kms_key_arn" {
description = "KMS key ARN used to encrypt the instance storage and endpoint connection parameters. Null uses the default DMS key."
type = string
default = null
}
variable "multi_az" {
description = "Deploy the replication instance across two AZs for automatic failover."
type = bool
default = true
}
variable "preferred_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 "auto_minor_version_upgrade" {
description = "Apply minor engine upgrades automatically 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 "apply_immediately" {
description = "Apply instance modifications immediately instead of in the maintenance window."
type = bool
default = false
}
variable "ssl_mode" {
description = "SSL mode for both endpoints: none, require, verify-ca, or verify-full."
type = string
default = "require"
validation {
condition = contains(["none", "require", "verify-ca", "verify-full"], var.ssl_mode)
error_message = "ssl_mode must be one of: none, require, verify-ca, verify-full."
}
}
variable "secrets_manager_access_role_arn" {
description = "IAM role ARN DMS assumes to read the source & target Secrets Manager secrets. Must allow iam:PassRole."
type = string
}
# ---- Source endpoint ----
variable "source_engine_name" {
description = "Source engine: postgres, mysql, mariadb, oracle, sqlserver, aurora, aurora-postgresql, db2, mongodb, etc."
type = string
validation {
condition = contains([
"postgres", "mysql", "mariadb", "oracle", "sqlserver",
"aurora", "aurora-postgresql", "db2", "mongodb", "sybase"
], var.source_engine_name)
error_message = "source_engine_name must be a supported DMS source engine."
}
}
variable "source_database_name" {
description = "Name of the source database to connect to. Null for engines that do not require it."
type = string
default = null
}
variable "source_secrets_manager_arn" {
description = "ARN of the Secrets Manager secret holding the source connection details (host, port, user, password)."
type = string
}
variable "source_extra_connection_attributes" {
description = "Extra connection attributes for the source endpoint (engine-specific tuning)."
type = string
default = ""
}
# ---- Target endpoint ----
variable "target_engine_name" {
description = "Target engine: postgres, mysql, mariadb, oracle, sqlserver, aurora, aurora-postgresql, redshift, etc."
type = string
validation {
condition = contains([
"postgres", "mysql", "mariadb", "oracle", "sqlserver",
"aurora", "aurora-postgresql", "redshift", "kinesis", "s3"
], var.target_engine_name)
error_message = "target_engine_name must be a supported DMS target engine."
}
}
variable "target_database_name" {
description = "Name of the target database to connect to. Null for engines that do not require it."
type = string
default = null
}
variable "target_secrets_manager_arn" {
description = "ARN of the Secrets Manager secret holding the target connection details (host, port, user, password)."
type = string
}
variable "target_extra_connection_attributes" {
description = "Extra connection attributes for the target endpoint (engine-specific tuning)."
type = string
default = ""
}
# ---- Replication task ----
variable "create_replication_task" {
description = "Whether to create the replication task. Set false to manage endpoints/instance only."
type = bool
default = true
}
variable "migration_type" {
description = "Migration type: full-load, cdc, or full-load-and-cdc."
type = string
default = "full-load-and-cdc"
validation {
condition = contains(["full-load", "cdc", "full-load-and-cdc"], var.migration_type)
error_message = "migration_type must be one of: full-load, cdc, full-load-and-cdc."
}
}
variable "table_mappings" {
description = "Escaped JSON string of table mapping rules. Defaults to include all schemas/tables."
type = string
default = "{\"rules\":[{\"rule-type\":\"selection\",\"rule-id\":\"1\",\"rule-name\":\"include-all\",\"object-locator\":{\"schema-name\":\"%\",\"table-name\":\"%\"},\"rule-action\":\"include\"}]}"
}
variable "replication_task_settings" {
description = "Escaped JSON string of task settings. Null lets DMS apply its defaults."
type = string
default = null
}
variable "start_replication_task" {
description = "Whether to start the task on create. Keep false to validate before running."
type = bool
default = false
}
variable "tags" {
description = "Additional tags merged onto every resource."
type = map(string)
default = {}
}
outputs.tf
output "replication_instance_arn" {
description = "ARN of the DMS replication instance."
value = aws_dms_replication_instance.this.replication_instance_arn
}
output "replication_instance_id" {
description = "Identifier of the replication instance."
value = aws_dms_replication_instance.this.replication_instance_id
}
output "replication_instance_private_ips" {
description = "Private IP addresses of the replication instance."
value = aws_dms_replication_instance.this.replication_instance_private_ips
}
output "replication_subnet_group_id" {
description = "ID of the replication subnet group."
value = aws_dms_replication_subnet_group.this.id
}
output "source_endpoint_arn" {
description = "ARN of the source DMS endpoint."
value = aws_dms_endpoint.source.endpoint_arn
}
output "target_endpoint_arn" {
description = "ARN of the target DMS endpoint."
value = aws_dms_endpoint.target.endpoint_arn
}
output "replication_task_arn" {
description = "ARN of the replication task, or null when not created."
value = try(aws_dms_replication_task.this[0].replication_task_arn, null)
}
output "replication_task_status" {
description = "Status of the replication task, or null when not created."
value = try(aws_dms_replication_task.this[0].status, null)
}
How to use it
module "dms_orders_migration" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-dms?ref=v1.0.0"
name = "orders-oracle-to-aurora"
subnet_ids = aws_db_subnet_group.private.subnet_ids
vpc_security_group_ids = [aws_security_group.dms.id]
replication_instance_class = "dms.c5.large"
allocated_storage = 100
engine_version = "3.5.3"
kms_key_arn = aws_kms_key.dms.arn
multi_az = true
ssl_mode = "require"
secrets_manager_access_role_arn = aws_iam_role.dms_secrets.arn
# Source: self-managed Oracle, target: Aurora PostgreSQL.
source_engine_name = "oracle"
source_database_name = "ORCL"
source_secrets_manager_arn = aws_secretsmanager_secret.oracle_source.arn
target_engine_name = "aurora-postgresql"
target_database_name = "orders"
target_secrets_manager_arn = aws_secretsmanager_secret.aurora_target.arn
migration_type = "full-load-and-cdc"
# Migrate only the SALES and ORDERS schemas, dropping a noisy audit table.
table_mappings = jsonencode({
rules = [
{
rule-type = "selection"
rule-id = "1"
rule-name = "include-sales-orders"
object-locator = {
schema-name = "SALES"
table-name = "%"
}
rule-action = "include"
},
{
rule-type = "selection"
rule-id = "2"
rule-name = "exclude-audit"
object-locator = {
schema-name = "SALES"
table-name = "AUDIT_LOG"
}
rule-action = "exclude"
}
]
})
start_replication_task = false
tags = {
Environment = "prod"
Team = "fulfillment"
CostCenter = "ORD-7741"
}
}
# Downstream: the IAM role DMS assumes to read both endpoint secrets. It must
# trust dms.amazonaws.com and be allowed to read the two Secrets Manager secrets.
resource "aws_iam_role" "dms_secrets" {
name = "dms-orders-secrets-access"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "dms.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "dms_read_secrets" {
name = "dms-read-endpoint-secrets"
role = aws_iam_role.dms_secrets.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = [
aws_secretsmanager_secret.oracle_source.arn,
aws_secretsmanager_secret.aurora_target.arn,
]
}]
})
}
Pin the module with
?ref=<tag>so a migration stack never silently picks up a breaking module change mid-cutover.
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/dms/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-dms?ref=v1.0.0"
}
inputs = {
name = "..."
subnet_ids = ["...", "..."]
vpc_security_group_ids = ["..."]
source_engine_name = "..."
source_secrets_manager_arn = "..."
target_engine_name = "..."
target_secrets_manager_arn = "..."
secrets_manager_access_role_arn = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/dms && 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 |
|---|---|---|---|---|
| name | string | — | Yes | Base name for the instance, endpoints, subnet group, and task. |
| subnet_ids | list(string) | — | Yes | >= 2 private subnets across >= 2 AZs. |
| vpc_security_group_ids | list(string) | — | Yes | Security groups for the replication instance. |
| secrets_manager_access_role_arn | string | — | Yes | IAM role DMS assumes to read endpoint secrets. |
| source_engine_name | string | — | Yes | Source engine (postgres, mysql, oracle, sqlserver, …). |
| source_secrets_manager_arn | string | — | Yes | Secret with source host/port/user/password. |
| target_engine_name | string | — | Yes | Target engine (postgres, aurora-postgresql, redshift, …). |
| target_secrets_manager_arn | string | — | Yes | Secret with target host/port/user/password. |
| replication_instance_class | string | dms.t3.medium | No | Replication instance class. |
| allocated_storage | number | 50 | No | Instance storage in GiB (5–6144). |
| engine_version | string | 3.5.3 | No | DMS engine version; pin it. |
| kms_key_arn | string | null | No | KMS key for instance & endpoint encryption. |
| multi_az | bool | true | No | Deploy the instance across two AZs. |
| preferred_maintenance_window | string | sun:04:30-sun:05:30 | No | Weekly UTC maintenance window. |
| auto_minor_version_upgrade | bool | true | No | Apply minor upgrades automatically. |
| allow_major_version_upgrade | bool | false | No | Permit major version upgrades. |
| apply_immediately | bool | false | No | Apply instance changes immediately. |
| ssl_mode | string | require | No | SSL mode for both endpoints. |
| source_database_name | string | null | No | Source database name. |
| source_extra_connection_attributes | string | “” | No | Source engine-specific tuning. |
| target_database_name | string | null | No | Target database name. |
| target_extra_connection_attributes | string | “” | No | Target engine-specific tuning. |
| create_replication_task | bool | true | No | Whether to create the replication task. |
| migration_type | string | full-load-and-cdc | No | full-load, cdc, or full-load-and-cdc. |
| table_mappings | string | include-all rule | No | Escaped JSON table mapping rules. |
| replication_task_settings | string | null | No | Escaped JSON task settings. |
| start_replication_task | bool | false | No | Start the task on create. |
| tags | map(string) | {} | No | Additional tags merged onto every resource. |
Outputs
| Name | Description |
|---|---|
| replication_instance_arn | ARN of the DMS replication instance. |
| replication_instance_id | Identifier of the replication instance. |
| replication_instance_private_ips | Private IP addresses of the instance. |
| replication_subnet_group_id | ID of the replication subnet group. |
| source_endpoint_arn | ARN of the source endpoint. |
| target_endpoint_arn | ARN of the target endpoint. |
| replication_task_arn | ARN of the replication task, or null. |
| replication_task_status | Status of the replication task, or null. |
Enterprise scenario
A retail platform is retiring a self-managed Oracle estate and consolidating onto Aurora PostgreSQL across dev, staging, and prod. The data team publishes this module at v1.0.0 so each schema migration — orders, inventory, pricing — gets an identical Multi-AZ dms.c5.large replication instance in private subnets, with both endpoints pulling credentials from Secrets Manager through a dedicated dms-secrets-access role. Because ssl_mode = "require" and KMS encryption are enforced by the module, every connection between the replication instance and both databases is encrypted in transit and at rest, and no Oracle or Aurora password ever appears in state or a pull request. Tasks are created stopped (start_replication_task = false); engineers validate the full-load row counts in staging, then start CDC for the cutover window, and a security audit confirms zero publicly accessible replication instances across the fleet.
Best practices
- Keep the replication instance private and Multi-AZ. This module hard-codes
publicly_accessible = falseand defaultsmulti_az = true; place it in private subnets and scope the security group to egress toward the source and target endpoints only — never0.0.0.0/0. - Drive endpoint credentials from Secrets Manager. Use
secrets_manager_arn+secrets_manager_access_role_arninstead of inlineusername/password, so no connection string ever lands interraform.tfstate; grant the rolesecretsmanager:GetSecretValueon exactly the two secrets. - Enforce TLS and KMS everywhere. Keep
ssl_mode = "require"(or stricterverify-fullwith a CA cert) and setkms_key_arnto a customer-managed key so both data in transit and the encrypted connection parameters are under your control. - Create tasks stopped, then start deliberately. Leave
start_replication_task = false, validate full-load counts and CDC latency in a lower environment, and only start the production task inside the planned cutover window. - Pin
engine_versionand pre-create the account roles. Floating versions cause surprise instance reboots; DMS also needs the account-leveldms-vpc-role,dms-cloudwatch-logs-role, anddms-access-for-endpointroles to exist before the subnet group will create — provision them once per account. - Consider DMS Serverless for spiky one-off migrations. When throughput is unpredictable and you do not want to size an instance,
aws_dms_replication_config(DMS Serverless) scales capacity automatically; reach for this provisioned module when you need a long-running CDC pipeline with predictable performance and fine-grained task settings.