Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🟧 AWS Database Migration Service (DMS) Terraform Module

Secure-by-default database migration compute, endpoints, and replication for AWS DMS — classic instance + task, DMS Serverless replication_config, generic + S3 endpoints, subnet group, event subscriptions, and imported certificates, all wired for encryption-at-rest, TLS-in-transit, and Secrets-Manager-backed credentials. Built for the AWS provider v6.x.

Terraform >= 1.12 aws 6.x module v1.0.0 type=composite resources=8


🧩 Overview

  • 🔁 Creates the DMS replication instance (aws_dms_replication_instance.this) — the keystone, classic instance-based replication compute — always encrypted at rest, private by default.
  • ⚡ Creates DMS Serverless replication (aws_dms_replication_config) — the modern, no-standing-instance migration path — side by side with the classic path.
  • 🔌 Creates source/target endpoints for ~20 database and streaming engines (aws_dms_endpoint) with polymorphic engine-specific settings (MySQL, PostgreSQL, Oracle, Kafka, Kinesis, MongoDB, Redshift, OpenSearch/Elasticsearch, Redis).
  • 🪣 Creates a dedicated S3 endpoint (aws_dms_s3_endpoint) — the modern replacement for extra_connection_attributes-based S3 endpoints.
  • 🗺️ Creates the replication subnet group (aws_dms_replication_subnet_group) the instance/serverless compute runs in, from private subnets you supply.
  • 🧵 Creates classic replication tasks (aws_dms_replication_task) running on the module's own instance.
  • 🔔 Creates opt-in event subscriptions (aws_dms_event_subscription) publishing lifecycle events to an SNS topic.
  • 📜 Imports SSL certificates (aws_dms_certificate, PEM or Oracle Wallet) and wires them into endpoints by key.
  • 🔒 Defaults every endpoint's ssl_mode to "require", every replication instance to private (publicly_accessible = false), and prefers Secrets-Manager-backed endpoint credentials over inline passwords.

💡 Why it matters: DMS moves live, often PII-bearing, database rows between systems — a single migration module that defaults to encrypted, private, TLS-required, Secrets-Manager-backed replication removes an entire class of "we migrated the data over an unencrypted, publicly-reachable pipe" incidents before a caller ever writes a line of HCL.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits in the family

flowchart LR
 VPC["terraform-aws-vpc"] --> SG["terraform-aws-security-group"]
 VPC --> DMS["terraform-aws-dms"]
 SG --> DMS
 KMS["terraform-aws-kms"] --> DMS
 SECRETS["terraform-aws-secrets-manager"] --> DMS
 IAMROLE["terraform-aws-iam-role"] --> DMS
 S3["terraform-aws-s3-bucket"] --> DMS
 SNS["terraform-aws-sns"] --> DMS
 RDS["terraform-aws-rds"] -.source data.-> DMS
 AURORA["terraform-aws-rds-aurora"] -.source data.-> DMS
 DMS --> CWALARM["terraform-aws-cloudwatch-alarm (Phase 4)"]
 DMS --> IAMPOLICY["terraform-aws-iam-policy"]

 style DMS fill:#FF9900,color:#fff
Loading

🧬 What this module builds

flowchart TB
 subgraph DMSMOD["terraform-aws-dms"]
 RSG["aws_dms_replication_subnet_group.this (supporting, optional)"]
 RI["aws_dms_replication_instance.this (KEYSTONE)"]
 CERT["aws_dms_certificate.this (for_each certificates)"]
 EP["aws_dms_endpoint.this (for_each endpoints)"]
 S3EP["aws_dms_s3_endpoint.this (for_each s3_endpoints)"]
 RC["aws_dms_replication_config.this (for_each replication_configs, Serverless)"]
 RT["aws_dms_replication_task.this (for_each replication_tasks, classic)"]
 EVT["aws_dms_event_subscription.this (for_each event_subscriptions)"]
 end

 RSG --> RI
 RSG -->|subnet group id| RC
 CERT -->|certificate arn| EP
 CERT -->|certificate arn| S3EP
 EP -->|endpoint arn| RC
 EP -->|endpoint arn| RT
 S3EP -->|endpoint arn| RC
 S3EP -->|endpoint arn| RT
 RI -->|instance arn| RT
 RI -->|source ids| EVT
 RT -->|source ids| EVT

 style RI fill:#FF9900,color:#fff
Loading

✅ Provider / Versions

Requirement Version
Terraform >= 1.12.0
hashicorp/aws provider >= 6.0, < 7.0

No provider {} block is declared inside this module — it inherits the caller's configured aws provider (credentials, region, default_tags, assume_role).


🔑 Required IAM Permissions

Least-privilege actions the Terraform executing principal needs (distinct from the DMS service roles DMS itself assumes at runtime — see AWS Prerequisites below):

Action Required for Notes
dms:CreateReplicationInstance, dms:DescribeReplicationInstances, dms:ModifyReplicationInstance, dms:DeleteReplicationInstance Replication instance lifecycle
dms:CreateReplicationSubnetGroup, dms:DescribeReplicationSubnetGroups, dms:ModifyReplicationSubnetGroup, dms:DeleteReplicationSubnetGroup Subnet group lifecycle Requires dms-vpc-role to already exist (see Prerequisites)
dms:CreateEndpoint, dms:DescribeEndpoints, dms:ModifyEndpoint, dms:DeleteEndpoint, dms:TestConnection Endpoint + S3 endpoint lifecycle
dms:CreateReplicationConfig, dms:DescribeReplicationConfigs, dms:ModifyReplicationConfig, dms:DeleteReplicationConfig Serverless replication config lifecycle
dms:CreateReplicationTask, dms:DescribeReplicationTasks, dms:ModifyReplicationTask, dms:DeleteReplicationTask, dms:StartReplicationTask, dms:StopReplicationTask Classic replication task lifecycle
dms:CreateEventSubscription, dms:DescribeEventSubscriptions, dms:ModifyEventSubscription, dms:DeleteEventSubscription Event subscription lifecycle
dms:ImportCertificate, dms:DescribeCertificates, dms:DeleteCertificate Certificate import/lifecycle Content is stored in state plaintext — protect the backend
dms:AddTagsToResource, dms:ListTagsForResource, dms:RemoveTagsFromResource Tag management on every DMS resource
iam:PassRole Passing dms-vpc-role, dms-cloudwatch-logs-role, endpoint service_access_role_arn/secrets_manager_access_role_arn to the DMS service No service-linked role is auto-created by this module — the three conventionally-named roles must pre-exist
kms:DescribeKey, kms:CreateGrant Customer-managed CMK on the instance, serverless compute, or S3 endpoint Only when a CMK ARN is supplied

📋 AWS Prerequisites

  • Three conventionally-named IAM roles must exist before a replication instance/subnet group can be created, and are not created by this module:
  • dms-vpc-role — trusts dms.amazonaws.com, attaches AmazonDMSVPCManagementRole. Required for aws_dms_replication_instance / aws_dms_replication_subnet_group.
  • dms-cloudwatch-logs-role — trusts dms.amazonaws.com, attaches AmazonDMSCloudWatchLogsRole. Required for CloudWatch logging of replication tasks.
  • dms-access-for-endpoint — trusts dms.amazonaws.com, attaches AmazonDMSRedshiftS3Role. Required only for Redshift-target endpoints.

Create them via terraform-aws-iam-role before running this module (the AWS Console auto-creates them on first console use, but a Terraform-only pipeline in a locked-down account must create them explicitly).

  • No region constraint — unlike CloudFront/WAFv2/ACM, DMS has no us-east-1 global-resource coupling; standard provider inheritance applies.
  • Service quotas: replication instances per account/Region (default 20, soft), allocated storage per instance (5-6144 GiB), DMS Serverless DCU ceiling (384 DCUs per replication config). Request an increase before a large migration wave.
  • Engine-side CDC prerequisites (not enforced by this module — verify against the source engine's own docs): binary logging for MySQL CDC, wal_level = logical + a replication slot for PostgreSQL CDC, supplemental logging for Oracle CDC, CDC enabled at the database level for SQL Server.

📁 Module Structure

terraform-aws-dms/
├── providers.tf
├── variables.tf
├── main.tf
├── outputs.tf
├── README.md
└── SCOPE.md

⚙️ Quick Start

module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "core-migration"
  replication_instance_class = "dms.t3.medium"

  subnet_ids             = module.vpc.private_subnet_ids
  vpc_security_group_ids = [module.dms_sg.id]
  kms_key_arn            = module.kms.arn

  endpoints = {
    source_pg = {
      endpoint_type                   = "source"
      engine_name                     = "postgres"
      secrets_manager_arn             = module.pg_secret.arn
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
      ssl_mode                        = "verify-full"
    }
  }

  s3_endpoints = {
    target_lake = {
      endpoint_type           = "target"
      bucket_name             = module.migration_bucket.id
      service_access_role_arn = module.dms_s3_role.arn
    }
  }

  replication_configs = {
    pg_to_lake = {
      replication_type    = "full-load-and-cdc"
      source_endpoint_key = "source_pg"
      target_endpoint_key = "target_lake"
      table_mappings = jsonencode({
        rules = [{
          "rule-type"      = "selection"
          "rule-id"        = "1"
          "rule-name"      = "1"
          "rule-action"    = "include"
          "object-locator" = { "schema-name" = "%", "table-name" = "%" }
        }]
      })
      start_replication = true
      compute_config = {
        max_capacity_units = 16
      }
    }
  }

  tags = {
    Environment = "prod"
    Application = "core-migration"
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Source module
subnet_ids list(string) terraform-aws-vpc (private subnets, >= 2 AZs)
vpc_security_group_ids list(string) terraform-aws-security-group
kms_key_arn string (ARN) terraform-aws-kms
endpoints[*].secrets_manager_arn string (ARN) terraform-aws-secrets-manager
endpoints[*].secrets_manager_access_role_arn string (ARN) terraform-aws-iam-role
s3_endpoints[*].bucket_name string terraform-aws-s3-bucket
s3_endpoints[*].service_access_role_arn string (ARN) terraform-aws-iam-role
event_subscriptions[*].sns_topic_arn string (ARN) terraform-aws-sns

Emits

Output Description Consumed by
id Replication instance id Reference by callers needing the instance identifier
arn Replication instance ARN terraform-aws-iam-policy, terraform-aws-cloudwatch-log-group
endpoints Map: key -> { id, arn, engine_name } In-module replication_configs/replication_tasks, terraform-aws-iam-policy
s3_endpoints Map: key -> { id, arn, engine_display_name, external_id, status } Same as above
replication_configs Map: key -> { id, arn } terraform-aws-cloudwatch-alarm (Phase 4)
replication_tasks Map: key -> { id, arn, status } terraform-aws-cloudwatch-alarm, event subscriptions
event_subscriptions Map: key -> { id, arn } Governance/audit
certificates Map: key -> { id, arn } Endpoint certificate_arn wiring
tags_all Merged tags on the replication instance Governance/cost reporting

📚 Example Library

1 · Minimal classic (instance-based) migration
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "poc-migration"
  replication_instance_class = "dms.t3.micro"

  subnet_ids             = module.vpc.private_subnet_ids
  vpc_security_group_ids = [module.dms_sg.id]

  endpoints = {
    source_mysql = {
      endpoint_type = "source"
      engine_name   = "mysql"
      server_name   = "onprem-mysql.internal"
      port          = 3306
      database_name = "app"
      username      = "dms_reader"
      password      = var.mysql_password # discouraged — see example 5
    }
    target_mysql = {
      endpoint_type                   = "target"
      engine_name                     = "mysql"
      secrets_manager_arn             = module.target_secret.arn
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
    }
  }

  replication_tasks = {
    full_load = {
      migration_type      = "full-load"
      source_endpoint_key = "source_mysql"
      target_endpoint_key = "target_mysql"
      table_mappings = jsonencode({
        rules = [{
          "rule-type"      = "selection", "rule-id" = "1", "rule-name" = "1"
          "rule-action"    = "include"
          "object-locator" = { "schema-name" = "app", "table-name" = "%" }
        }]
      })
    }
  }
}
2 · DMS Serverless replication (recommended for new migrations)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  # Serverless still needs a subnet group but no standing replication_instance_class
  # sizing — the module always declares replication_instance_id/class because the
  # keystone resource is required; size it minimally when only using Serverless.
  replication_instance_id    = "serverless-anchor"
  replication_instance_class = "dms.t3.micro"

  subnet_ids             = module.vpc.private_subnet_ids
  vpc_security_group_ids = [module.dms_sg.id]
  kms_key_arn            = module.kms.arn

  endpoints = {
    source_pg = {
      endpoint_type                   = "source"
      engine_name                     = "postgres"
      secrets_manager_arn             = module.pg_secret.arn
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
    }
  }

  s3_endpoints = {
    target_lake = {
      endpoint_type           = "target"
      bucket_name             = module.migration_bucket.id
      service_access_role_arn = module.dms_s3_role.arn
      data_format             = "parquet"
    }
  }

  replication_configs = {
    pg_to_lake = {
      replication_type    = "full-load-and-cdc"
      source_endpoint_key = "source_pg"
      target_endpoint_key = "target_lake"
      table_mappings      = file("${path.module}/table-mappings.json")
      start_replication   = true
      compute_config = {
        max_capacity_units = 32
        min_capacity_units = 2
        multi_az           = true
      }
    }
  }
}
3 · MySQL-to-S3 data lake migration (Parquet, Glue Catalog)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "mysql-to-lake"
  replication_instance_class = "dms.r5.large"

  subnet_ids             = module.vpc.private_subnet_ids
  vpc_security_group_ids = [module.dms_sg.id]
  kms_key_arn            = module.kms.arn

  endpoints = {
    source_mysql = {
      endpoint_type                   = "source"
      engine_name                     = "mysql"
      secrets_manager_arn             = module.mysql_secret.arn
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
      mysql_settings = {
        events_poll_interval = 5
      }
    }
  }

  s3_endpoints = {
    target_lake = {
      endpoint_type                     = "target"
      bucket_name                       = module.migration_bucket.id
      service_access_role_arn           = module.dms_s3_role.arn
      data_format                       = "parquet"
      parquet_version                   = "parquet-2-0"
      encryption_mode                   = "SSE_KMS"
      server_side_encryption_kms_key_id = module.kms.arn
      glue_catalog_generation           = true
      date_partition_enabled            = true
      date_partition_sequence           = "YYYYMMDD"
    }
  }

  replication_configs = {
    mysql_to_lake = {
      replication_type    = "full-load-and-cdc"
      source_endpoint_key = "source_mysql"
      target_endpoint_key = "target_lake"
      table_mappings = jsonencode({
        rules = [{
          "rule-type"      = "selection", "rule-id" = "1", "rule-name" = "1"
          "rule-action"    = "include"
          "object-locator" = { "schema-name" = "app", "table-name" = "%" }
        }]
      })
      compute_config = { max_capacity_units = 64 }
    }
  }
}
4 · Secrets-Manager-backed endpoint credentials (recommended)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "secure-migration"
  replication_instance_class = "dms.t3.medium"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  endpoints = {
    source_oracle = {
      endpoint_type                   = "source"
      engine_name                     = "oracle"
      secrets_manager_arn             = module.oracle_secret.arn    # terraform-aws-secrets-manager
      secrets_manager_access_role_arn = module.dms_secrets_role.arn # terraform-aws-iam-role
      ssl_mode                        = "verify-full"
    }
  }
}

Wire module.oracle_secret from terraform-aws-secrets-manager holding a JSON document with username, password, host, port, dbname/oracleSID — DMS reads it via secrets_manager_access_role_arn, never via the Terraform executing principal.

5 · Inline password (discouraged, explicit opt-in)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "legacy-migration"
  replication_instance_class = "dms.t3.medium"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  endpoints = {
    source_sqlserver = {
      endpoint_type = "source"
      engine_name   = "sqlserver"
      server_name   = "onprem-sqlserver.internal"
      port          = 1433
      database_name = "app"
      username      = "dms_reader"
      password      = var.sqlserver_password # sensitive input; still plaintext in state
      ssl_mode      = "require"
    }
  }
}

⚠️ password is marked sensitive = true at the root-module input level as a compensating control, but the provider stores it in Terraform state in plaintext regardless (a documented, unavoidable behavior of aws_dms_endpoint). Use an encrypted backend and least-privilege state access, and prefer example 4 wherever the target account allows it.

6 · SSL-required PostgreSQL CDC endpoint with imported CA certificate
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "pg-cdc-migration"
  replication_instance_class = "dms.r5.large"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  certificates = {
    onprem_ca = {
      certificate_pem = file("${path.module}/certs/onprem-ca.pem")
    }
  }

  endpoints = {
    source_pg = {
      endpoint_type                   = "source"
      engine_name                     = "postgres"
      secrets_manager_arn             = module.pg_secret.arn
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
      ssl_mode                        = "verify-full"
      certificate_key                 = "onprem_ca"
      postgres_settings = {
        plugin_name      = "pglogical"
        heartbeat_enable = true
        capture_ddls     = true
      }
    }
  }
}
7 · Kafka target endpoint with SASL-SSL
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "cdc-to-kafka"
  replication_instance_class = "dms.r5.large"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  endpoints = {
    target_kafka = {
      endpoint_type = "target"
      engine_name   = "kafka"
      kafka_settings = {
        broker            = "b-1.mymsk.kafka.us-east-1.amazonaws.com:9096"
        topic             = "cdc-events"
        security_protocol = "sasl-ssl"
        sasl_mechanism    = "scram-sha-512"
        sasl_username     = var.msk_username
        sasl_password     = var.msk_password
      }
    }
  }
}
8 · Event subscription on task failure (SNS)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "core-migration"
  replication_instance_class = "dms.t3.medium"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  event_subscriptions = {
    task_failures = {
      sns_topic_arn    = module.dms_alerts.arn # terraform-aws-sns
      source_type      = "replication-task"
      event_categories = ["failure"]
    }
    instance_lifecycle = {
      sns_topic_arn    = module.dms_alerts.arn
      source_type      = "replication-instance"
      event_categories = ["creation", "failure", "deletion"]
    }
  }
}
9 · Customer-managed KMS CMK across instance, S3 endpoint, and Serverless compute
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "cmk-migration"
  replication_instance_class = "dms.r5.large"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]
  kms_key_arn                = module.kms.arn # terraform-aws-kms

  s3_endpoints = {
    target_lake = {
      endpoint_type                     = "target"
      bucket_name                       = module.migration_bucket.id
      service_access_role_arn           = module.dms_s3_role.arn
      encryption_mode                   = "SSE_KMS"
      server_side_encryption_kms_key_id = module.kms.arn
    }
  }

  replication_configs = {
    pg_to_lake = {
      replication_type    = "full-load-and-cdc"
      source_endpoint_key = "source_pg"
      target_endpoint_key = "target_lake"
      table_mappings      = file("${path.module}/table-mappings.json")
      compute_config = {
        max_capacity_units = 32
        kms_key_id         = module.kms.arn # falls back to var.kms_key_arn if omitted
      }
    }
  }
}
10 · Secure-by-default opt-out — public replication instance (documented exception)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "internet-facing-migration"
  replication_instance_class = "dms.t3.medium"

  # DOCUMENTED EXCEPTION: source database is only reachable over the public
  # internet (no VPN/Direct Connect available). Requires PUBLIC subnets and a
  # security group locked to the source database's known egress IP range.
  publicly_accessible    = true
  subnet_ids             = module.vpc.public_subnet_ids
  vpc_security_group_ids = [module.dms_public_sg.id]
}
11 · `for_each` pattern — one module call, multiple isolated migrations
locals {
  migrations = {
    billing = {
      instance_class = "dms.t3.medium"
      source_secret  = module.billing_secret.arn
    }
    claims = {
      instance_class = "dms.r5.large"
      source_secret  = module.claims_secret.arn
    }
  }
}

module "dms" {
  for_each = local.migrations
  source   = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "${each.key}-migration"
  replication_instance_class = each.value.instance_class
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  endpoints = {
    source = {
      endpoint_type                   = "source"
      engine_name                     = "postgres"
      secrets_manager_arn             = each.value.source_secret
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
    }
  }

  tags = { MigrationDomain = each.key }
}
12 · Import an existing replication instance and endpoint
import {
  to = module.dms.aws_dms_replication_instance.this
  id = "legacy-migration-instance"
}

import {
  to = module.dms.aws_dms_endpoint.this["source_pg"]
  id = "legacy-source-pg-endpoint"
}

Match replication_instance_id, replication_instance_class, and the endpoints["source_pg"] object to the imported resource's live configuration before running terraform plan to avoid a disruptive diff.

13 · Kerberos-authenticated classic migration (requires the instance-based path)
module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "kerberos-migration"
  replication_instance_class = "dms.r5.large"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  kerberos_authentication_settings = {
    key_cache_secret_iam_arn = module.dms_kerberos_role.arn
    key_cache_secret_id      = module.kerberos_secret.arn
    krb5_file_contents       = file("${path.module}/krb5.conf")
  }

  endpoints = {
    source_sqlserver = {
      endpoint_type = "source"
      engine_name   = "sqlserver"
      server_name   = "corp-sqlserver.internal"
      port          = 1433
      database_name = "app"
    }
  }

  replication_tasks = {
    full_load_and_cdc = {
      migration_type      = "full-load-and-cdc"
      source_endpoint_key = "source_sqlserver"
      target_endpoint_key = "target_pg"
      table_mappings      = file("${path.module}/table-mappings.json")
    }
  }
}
14 · Tags merge with provider default_tags
# Root/provider block (outside this module):
# provider "aws" {
# default_tags {
# tags = { CostCenter = "-INFRA", ManagedBy = "terraform" }
# }
# }

module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "tagged-migration"
  replication_instance_class = "dms.t3.medium"
  subnet_ids                 = module.vpc.private_subnet_ids
  vpc_security_group_ids     = [module.dms_sg.id]

  tags = {
    Environment = "prod"
    Application = "claims-migration"
    # A key collision with default_tags (e.g. CostCenter) here would win —
    # resource-level tags always beat provider default_tags.
  }
}

output "dms_tags_all" {
  value = module.dms.tags_all # merged result: CostCenter, ManagedBy, Environment, Application
}
15 · End-to-end composition (VPC → SG → KMS → Secrets Manager → RDS source → S3 target → DMS)
module "vpc" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-vpc?ref=v1.0.0"
  #...
}

module "dms_sg" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-security-group?ref=v1.0.0"
  vpc_id = module.vpc.id
  # egress-only rules to the RDS source and S3 (via VPC endpoint), no ingress
}

module "kms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-kms?ref=v1.0.0"
  #...
}

module "dms_secrets_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  # trusts dms.amazonaws.com; allows secretsmanager:GetSecretValue on the secret below
}

module "pg_secret" {
  source      = "git::https://github.com/microsoftexpert/terraform-aws-secrets-manager?ref=v1.0.0"
  kms_key_arn = module.kms.arn
  # secret_string holds { username, password, host, port, dbname }
}

module "source_rds" {
  source      = "git::https://github.com/microsoftexpert/terraform-aws-rds?ref=v1.0.0"
  identifier  = "core-pg"
  engine      = "postgres"
  kms_key_arn = module.kms.arn
  subnet_ids  = module.vpc.private_subnet_ids
  #...
}

module "migration_bucket" {
  source      = "git::https://github.com/microsoftexpert/terraform-aws-s3-bucket?ref=v1.0.0"
  bucket      = "core-migration-lake"
  kms_key_arn = module.kms.arn
}

module "dms_s3_role" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
  # trusts dms.amazonaws.com; allows s3:PutObject/GetObject/ListBucket on migration_bucket.arn
}

module "dms_alerts" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-sns?ref=v1.0.0"
  # resource policy allows dms.amazonaws.com to Publish
}

module "dms" {
  source = "git::https://github.com/microsoftexpert/terraform-aws-dms?ref=v1.0.0"

  replication_instance_id    = "core-pg-to-lake"
  replication_instance_class = "dms.r5.large"

  subnet_ids             = module.vpc.private_subnet_ids
  vpc_security_group_ids = [module.dms_sg.id]
  kms_key_arn            = module.kms.arn

  endpoints = {
    source_pg = {
      endpoint_type                   = "source"
      engine_name                     = "postgres"
      server_name                     = module.source_rds.address
      port                            = module.source_rds.port
      database_name                   = "app"
      secrets_manager_arn             = module.pg_secret.arn
      secrets_manager_access_role_arn = module.dms_secrets_role.arn
      ssl_mode                        = "verify-full"
      postgres_settings = {
        plugin_name = "pglogical"
      }
    }
  }

  s3_endpoints = {
    target_lake = {
      endpoint_type                     = "target"
      bucket_name                       = module.migration_bucket.id
      service_access_role_arn           = module.dms_s3_role.arn
      data_format                       = "parquet"
      encryption_mode                   = "SSE_KMS"
      server_side_encryption_kms_key_id = module.kms.arn
      glue_catalog_generation           = true
    }
  }

  replication_configs = {
    core_pg_to_lake = {
      replication_type    = "full-load-and-cdc"
      source_endpoint_key = "source_pg"
      target_endpoint_key = "target_lake"
      table_mappings = jsonencode({
        rules = [{
          "rule-type"      = "selection", "rule-id" = "1", "rule-name" = "1"
          "rule-action"    = "include"
          "object-locator" = { "schema-name" = "app", "table-name" = "%" }
        }]
      })
      start_replication = true
      compute_config = {
        max_capacity_units = 32
        min_capacity_units = 2
        multi_az           = true
      }
    }
  }

  event_subscriptions = {
    failures = {
      sns_topic_arn    = module.dms_alerts.arn
      source_type      = "replication-task"
      event_categories = ["failure"]
    }
  }

  tags = {
    Environment = "prod"
    Application = "core-pg-to-lake"
  }
}

📥 Inputs

Core / Identity

  • replication_instance_id (required) — replication instance identifier
  • replication_instance_class (required) — e.g. "dms.t3.medium"

Storage & Engine

  • allocated_storage (default 50), engine_version, kms_key_arn

Networking

  • subnet_ids, create_replication_subnet_group (default true), replication_subnet_group_id, replication_subnet_group_description, vpc_security_group_ids, availability_zone, multi_az, publicly_accessible (default false), network_type, dns_name_servers

Maintenance

  • preferred_maintenance_window, auto_minor_version_upgrade (default true), allow_major_version_upgrade (default false), apply_immediately (default false)

Authentication

  • kerberos_authentication_settings (classic path only)

Endpoints

  • endpoints (map — every engine except S3), s3_endpoints (map — dedicated S3 endpoint), certificates (map — imported PEM/Wallet)

Migration

  • replication_configs (map — DMS Serverless), replication_tasks (map — classic)

Notifications

  • event_subscriptions (map)

Governance

  • tags (default {}), timeouts (replication instance only)

🧾 Outputs

  • id, arn — replication instance identifier and ARN (primary outputs)
  • replication_instance_id, replication_instance_private_ips, replication_instance_public_ips
  • replication_subnet_group_id, replication_subnet_group_vpc_id (conditional — try(..., null), null when an existing group is reused)
  • certificates — map of { id, arn }
  • endpoints — map of { id, arn, engine_name }
  • s3_endpoints — map of { id, arn, engine_display_name, external_id, status }
  • replication_configs — map of { id, arn }
  • replication_tasks — map of { id, arn, status }
  • event_subscriptions — map of { id, arn }
  • tags_all — merged tags on the replication instance

No output ever emits a secret value (endpoint password, certificate content). Those live only in the resource arguments/state, per the provider's documented plaintext-in-state limitation for aws_dms_endpoint/aws_dms_certificate.


🧠 Architecture Notes

  • ARN formats: replication instance — arn:aws:dms:<region>:<account>:rep:<uuid>; endpoint — arn:aws:dms:<region>:<account>:endpoint:<uuid>; replication config — arn:aws:dms:<region>:<account>:replication-config:<id>; replication task — arn:aws:dms:<region>:<account>:task:<uuid>.
  • Immutable/force-new fields: replication_instance_class / availability_zone / replication_subnet_group_id changes can force replacement of the replication instance depending on the change — always review terraform plan. kms_key_arn cannot be changed in place on an existing instance. S3 endpoint ignore_header_rows is FORCE-NEW.
  • tagstags_alldefault_tags: identical to every other terraform-aws-* module — resource tags win over provider default_tags on key conflict; tags_all on the replication instance reflects the merged result. aws_dms_replication_subnet_group has no arn attribute (only id/vpc_id/tags_all).
  • Eventual consistency / running-replication behavior: most in-place changes to a running aws_dms_replication_config or aws_dms_replication_task first stop the replication; use start_replication/start_replication_task to resume after the change lands.
  • Destroy ordering: endpoints/certificates referenced by a running replication_config/replication_task cannot be deleted until the task/config is deleted or stopped first. Terraform's implicit dependency graph handles this automatically as long as you reference endpoints and certificates through this module's own keys (source_endpoint_key, certificate_key) rather than hard-coded external ARNs.
  • No us-east-1 global-resource requirement — DMS is fully regional, unlike CloudFront/WAFv2/ACM.

🧱 Design Principles

  • Replication instance encryption cannot be disabled — there is no storage_encrypted toggle to defeat (unlike RDS); kms_key_arn = null simply falls back to the AWS-managed aws/dms key. Supply a CMK for auditable, revocable key control on PII/privacy-regulation workloads.
  • publicly_accessible defaults to false. Opt out with publicly_accessible = true only for a documented internet-facing exception (example 10) — this also requires public subnets and a permissive security group.
  • auto_minor_version_upgrade defaults to true (overrides the AWS API default of false) to keep the replication engine patched. Opt out with auto_minor_version_upgrade = false to pin an exact minor version.
  • Endpoint ssl_mode defaults to "require" (overrides the AWS API default of "none"). Opt out to "none" only for engines/environments that cannot terminate TLS — a documented exception; prefer "verify-full" wherever the engine and CA chain support it.
  • Secrets-Manager-backed endpoint credentials are the recommended path (example 4) over inline username/password (example 5, explicit opt-in). Inline password inputs are still stored in Terraform state in plaintext — a documented, unavoidable provider behavior — so an encrypted backend and least-privilege state access are the compensating control.
  • Event subscriptions are opt-in — no default subscription is created; callers wire an SNS topic explicitly (example 8).
  • Both migration paths are first-classreplication_configs (Serverless, recommended default) and replication_tasks (classic, instance-based) coexist so a single module call can run either or both.

🚀 Runbook

cd terraform-aws-dms
terraform init -backend=false
terraform validate
terraform fmt -check

⚠️ terraform plan/terraform apply require valid AWS credentials (AWS_PROFILE / SSO / OIDC role) and a region set in the caller's provider block. Pin the module source with ?ref=v1.0.0 — never a branch.

terraform plan
terraform apply
terraform output

🧪 Testing

  • terraform init -backend=false — provider plugin resolution only, no state backend required.
  • terraform validate — schema/type validation for all 8 resources, including the polymorphic aws_dms_endpoint *_settings dynamic blocks.
  • terraform fmt -check — canonical HCL formatting.
  • tflint (AWS ruleset) + checkov — run via the shared validate_all_modules.ps1 harness.
  • Live testing (plan/apply) should target a non-production AWS account with a least-privilege IAM role and a throwaway source database — never validate against a production PII-bearing source.

💬 Example Output

$ terraform apply

module.dms.aws_dms_replication_subnet_group.this["this"]: Creating...
module.dms.aws_dms_replication_subnet_group.this["this"]: Creation complete after 3s
module.dms.aws_dms_certificate.this["onprem_ca"]: Creating...
module.dms.aws_dms_certificate.this["onprem_ca"]: Creation complete after 2s
module.dms.aws_dms_replication_instance.this: Creating...
module.dms.aws_dms_replication_instance.this: Still creating... [10m elapsed]
module.dms.aws_dms_replication_instance.this: Creation complete after 14m32s
module.dms.aws_dms_endpoint.this["source_pg"]: Creating...
module.dms.aws_dms_endpoint.this["source_pg"]: Creation complete after 4s
module.dms.aws_dms_s3_endpoint.this["target_lake"]: Creating...
module.dms.aws_dms_s3_endpoint.this["target_lake"]: Creation complete after 3s
module.dms.aws_dms_replication_config.this["pg_to_lake"]: Creating...
module.dms.aws_dms_replication_config.this["pg_to_lake"]: Creation complete after 6s

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

arn = "arn:aws:dms:us-east-1:123456789012:rep:ABCD1234EFGH5678"
id = "core-pg-to-lake"
endpoints = {
 "source_pg" = {
 "arn" = "arn:aws:dms:us-east-1:123456789012:endpoint:WXYZ9876"
 "engine_name" = "postgres"
 "id" = "source_pg"
 }
}

🔍 Troubleshooting

  • AccessDeniedException creating the replication instance/subnet groupdms-vpc-role does not exist yet in the account. Create it via terraform-aws-iam-role first (see AWS Prerequisites); this is an ordering issue, not a Terraform bug.
  • Tag drift on every plan — a default_tags key collides with a key in var.tags; resource tags win, but if the value differs between the two you'll see perpetual drift. Keep colliding keys' values identical, or don't duplicate the key in both places.
  • Credential-chain failures on plan/apply — the AWS provider found no usable credentials (AWS_PROFILE/SSO session expired, OIDC role not assumed). This module never accepts credential variables; fix the caller's provider configuration or CI OIDC federation, not this module.
  • terraform validate passes but apply fails with AccessDenied on a specific dms:* action — the executing IAM role is missing an action from SCOPE.md's Required IAM permissions list. Add the specific action; do not broaden to dms:*.
  • A modified replication_config/replication_task appears to "hang" — most in-place changes to a running config/task stop it first; check status (via the replication_tasks/replication_configs outputs or the console) and set start_replication/start_replication_task = true to resume.
  • Destroy fails with a dependency error on an endpoint or certificate — a replication_config/replication_task is still referencing it. Ensure Terraform's dependency graph sees the reference (via source_endpoint_key/certificate_key, not a hard-coded ARN) so destroy ordering is automatic; otherwise destroy the config/task first.
  • aws_dms_endpoint apply fails with a settings-block error — a *_settings object was populated for the wrong engine_name (e.g. oracle_settings on a mysql endpoint). Terraform's type system accepts any combination; the DMS API rejects the mismatch at apply time. Only set the settings block matching engine_name.
  • CDC never starts producing changes — an engine-side prerequisite is missing (binary logging, logical replication slot, supplemental logging, etc. — see AWS Prerequisites). This module cannot detect or enforce these; verify on the source database directly.

🔗 Related Docs

  • AWS DMS User Guide — replication instance sizing and best practices
  • AWS DMS User Guide — Security (IAM roles required: dms-vpc-role, dms-cloudwatch-logs-role, dms-access-for-endpoint)
  • AWS DMS User Guide — Sources for data migration (per-engine settings and CDC prerequisites)
  • AWS DMS User Guide — Targets for data migration (per-engine settings)
  • AWS DMS User Guide — Table mapping selection and transformation rules
  • Terraform Registry — hashicorp/aws provider: aws_dms_replication_instance, aws_dms_endpoint, aws_dms_s3_endpoint, aws_dms_replication_config, aws_dms_replication_task, aws_dms_replication_subnet_group, aws_dms_event_subscription, aws_dms_certificate

🧡 "Infrastructure as Code should be standardized, consistent, and secure."