IaC AWS

Terraform Module: AWS Database Migration Service — Encrypted, Private Replication With Secrets Manager Endpoints

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

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 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/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

TerraformAWSDMSModuleIaC
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