A repeatable, zero-downtime method: replicate the database with DMS, containerize the app with App2Container, cut over on your schedule.
README
Format: AWS CloudFormation (single template) ยท Est. cost: ~$120/month during migration (NAT, ALB, Fargate ร2, Aurora Serverless v2 min 0.5 ACU, DMS t3.medium). Drop DMS after cutover.
A repeatable 3-phase migration method with the landing environment as code:
| Phase | Goal | Tooling |
|---|---|---|
| 1. Assess | Inventory, dependencies, 7 Rs decision | Migration Hub / Application Discovery Service |
| 2. Migrate data | Zero-downtime DB move with validation | AWS DMS full-load + CDC โ Aurora PostgreSQL (this template) |
| 3. Modernize app | VM โ container, no code rewrite | App2Container โ ECR โ ECS Fargate + ALB (this template) |
On-prem VM + DB โโVPN/DXโโโถ DMS (full load + CDC, validation on) โโโถ Aurora PostgreSQL Serverless v2
โ โฒ
โโ App2Container โโถ image โโถ ECR โโถ ECS Fargate (private) โโ ALB โโ users after cutover
Step 1 ยท Containerize with App2Container (on the source server)
sudo app2container init
sudo app2container inventory # finds java/.NET apps
sudo app2container analyze --application-id java-tomcat-xxxx
sudo app2container containerize --application-id java-tomcat-xxxx
sudo app2container generate app-deployment --application-id java-tomcat-xxxx --deploy # pushes to ECR
If the source DB is not MySQL, use the AWS Schema Conversion Tool (or DMS Schema Conversion) to convert the schema to PostgreSQL first.
Step 2 ยท Deploy the landing environment
# Without DMS (just the target platform)
aws cloudformation deploy --stack-name legacy-migration --template-file template.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides ContainerImage=<account>.dkr.ecr.<region>.amazonaws.com/legacy-app:latest ContainerPort=8080
# Add DMS once VPN/Direct Connect to the source DB is up
aws cloudformation deploy --stack-name legacy-migration --template-file template.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides SourceServer=10.1.2.3 SourcePort=3306 SourceEngine=mysql \
SourceUsername=dms_user SourcePassword='********' ContainerImage=... ContainerPort=8080
Add a route to OnPremCidr via your VPN/TGW in the private route table.
Step 3 ยท Replicate, validate, cut over
aws dms start-replication-task --replication-task-arn <ReplicationTaskArn> --start-replication-task-type start-replication
aws dms describe-table-statistics --replication-task-arn <ReplicationTaskArn> \
--query 'TableStatistics[].[TableName,FullLoadRows,Inserts,Updates,ValidationState]' --output table
Cutover runbook: freeze writes โ wait for CDC latency โ 0 โ validation = Validated โ switch DNS to AppUrl โ stop task.
Measure the outcome
| Metric | Before (VM) | After (Fargate + Aurora) |
|---|---|---|
| Monthly cost | ||
| p95 latency | ||
| Deploy time | ||
| Recovery time |
Clean up
aws cloudformation delete-stack --stack-name legacy-migration # Aurora leaves a final snapshot
Files (2)
# 10 ยท Legacy App Migration Playbook (On-Prem VM โ Containers)
**Format:** AWS CloudFormation (single template) ยท **Est. cost:** ~$120/month during migration (NAT, ALB, Fargate ร2, Aurora Serverless v2 min 0.5 ACU, DMS t3.medium). Drop DMS after cutover.
A repeatable **3-phase migration method** with the landing environment as code:
| Phase | Goal | Tooling |
|---|---|---|
| 1. Assess | Inventory, dependencies, 7 Rs decision | Migration Hub / Application Discovery Service |
| 2. Migrate data | Zero-downtime DB move with validation | **AWS DMS full-load + CDC** โ Aurora PostgreSQL (this template) |
| 3. Modernize app | VM โ container, no code rewrite | **App2Container** โ ECR โ **ECS Fargate + ALB** (this template) |
```
On-prem VM + DB โโVPN/DXโโโถ DMS (full load + CDC, validation on) โโโถ Aurora PostgreSQL Serverless v2
โ โฒ
โโ App2Container โโถ image โโถ ECR โโถ ECS Fargate (private) โโ ALB โโ users after cutover
```
## Step 1 ยท Containerize with App2Container (on the source server)
```bash
sudo app2container init
sudo app2container inventory # finds java/.NET apps
sudo app2container analyze --application-id java-tomcat-xxxx
sudo app2container containerize --application-id java-tomcat-xxxx
sudo app2container generate app-deployment --application-id java-tomcat-xxxx --deploy # pushes to ECR
```
If the source DB is not MySQL, use the AWS Schema Conversion Tool (or DMS Schema Conversion) to convert the schema to PostgreSQL first.
## Step 2 ยท Deploy the landing environment
```bash
# Without DMS (just the target platform)
aws cloudformation deploy --stack-name legacy-migration --template-file template.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides ContainerImage=<account>.dkr.ecr.<region>.amazonaws.com/legacy-app:latest ContainerPort=8080
# Add DMS once VPN/Direct Connect to the source DB is up
aws cloudformation deploy --stack-name legacy-migration --template-file template.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides SourceServer=10.1.2.3 SourcePort=3306 SourceEngine=mysql \
SourceUsername=dms_user SourcePassword='********' ContainerImage=... ContainerPort=8080
```
Add a route to `OnPremCidr` via your VPN/TGW in the private route table.
## Step 3 ยท Replicate, validate, cut over
```bash
aws dms start-replication-task --replication-task-arn <ReplicationTaskArn> --start-replication-task-type start-replication
aws dms describe-table-statistics --replication-task-arn <ReplicationTaskArn> \
--query 'TableStatistics[].[TableName,FullLoadRows,Inserts,Updates,ValidationState]' --output table
```
Cutover runbook: freeze writes โ wait for CDC latency โ 0 โ validation = *Validated* โ switch DNS to `AppUrl` โ stop task.
## Measure the outcome
| Metric | Before (VM) | After (Fargate + Aurora) |
|---|---|---|
| Monthly cost | | |
| p95 latency | | |
| Deploy time | | |
| Recovery time | | |
## Clean up
```bash
aws cloudformation delete-stack --stack-name legacy-migration # Aurora leaves a final snapshot
```
AWSTemplateFormatVersion: '2010-09-09'
Description: >-
Legacy application migration landing zone: VPC, Aurora PostgreSQL Serverless v2,
AWS DMS (full load + ongoing CDC from the on-prem database) and an ECS Fargate
service behind an ALB for the containerized application (App2Container output).
Parameters:
AppName:
Type: String
Default: legacy-app
AllowedPattern: '[a-z][a-z0-9-]*'
ContainerImage:
Type: String
Default: public.ecr.aws/nginx/nginx:stable
Description: Image produced by App2Container (or any image) - pushed to ECR
ContainerPort:
Type: Number
Default: 80
HealthCheckPath:
Type: String
Default: /
DesiredCount:
Type: Number
Default: 2
DatabaseName:
Type: String
Default: appdb
AuroraMinAcu:
Type: Number
Default: 0.5
AuroraMaxAcu:
Type: Number
Default: 4
# ---- DMS source (leave SourceServer blank to skip DMS until you are ready)
SourceEngine:
Type: String
Default: mysql
AllowedValues: [mysql, mariadb, postgres, sqlserver, oracle]
SourceServer:
Type: String
Default: ''
Description: On-prem DB hostname/IP reachable from the VPC (VPN / Direct Connect)
SourcePort:
Type: Number
Default: 3306
SourceDatabase:
Type: String
Default: appdb
SourceUsername:
Type: String
Default: dms_user
SourcePassword:
Type: String
NoEcho: true
Default: ''
OnPremCidr:
Type: String
Default: 10.0.0.0/8
Description: On-prem network range (for routing/security groups)
CreateDmsVpcRole:
Type: String
AllowedValues: ['true', 'false']
Default: 'true'
Description: DMS needs the account-wide role "dms-vpc-role". Set false if it already exists.
Conditions:
EnableDms: !Not [!Equals [!Ref SourceServer, '']]
CreateDmsRole: !And [!Condition EnableDms, !Equals [!Ref CreateDmsVpcRole, 'true']]
Resources:
# ================================================================ network
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.40.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags: [{ Key: Name, Value: !Sub '${AppName}-vpc' }]
Igw:
Type: AWS::EC2::InternetGateway
IgwAttach:
Type: AWS::EC2::VPCGatewayAttachment
Properties: { VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw }
PublicA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.40.0.0/24
AvailabilityZone: !Select [0, !GetAZs '']
MapPublicIpOnLaunch: false
Tags: [{ Key: Name, Value: !Sub '${AppName}-public-a' }]
PublicB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.40.1.0/24
AvailabilityZone: !Select [1, !GetAZs '']
MapPublicIpOnLaunch: false
Tags: [{ Key: Name, Value: !Sub '${AppName}-public-b' }]
PrivateA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.40.10.0/24
AvailabilityZone: !Select [0, !GetAZs '']
Tags: [{ Key: Name, Value: !Sub '${AppName}-private-a' }]
PrivateB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.40.11.0/24
AvailabilityZone: !Select [1, !GetAZs '']
Tags: [{ Key: Name, Value: !Sub '${AppName}-private-b' }]
PublicRt:
Type: AWS::EC2::RouteTable
Properties: { VpcId: !Ref Vpc }
PublicDefault:
Type: AWS::EC2::Route
DependsOn: IgwAttach
Properties: { RouteTableId: !Ref PublicRt, DestinationCidrBlock: 0.0.0.0/0, GatewayId: !Ref Igw }
PublicAAssoc:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PublicA, RouteTableId: !Ref PublicRt }
PublicBAssoc:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PublicB, RouteTableId: !Ref PublicRt }
NatEip:
Type: AWS::EC2::EIP
Properties: { Domain: vpc }
Nat:
Type: AWS::EC2::NatGateway
Properties: { AllocationId: !GetAtt NatEip.AllocationId, SubnetId: !Ref PublicA }
PrivateRt:
Type: AWS::EC2::RouteTable
Properties: { VpcId: !Ref Vpc }
PrivateDefault:
Type: AWS::EC2::Route
Properties: { RouteTableId: !Ref PrivateRt, DestinationCidrBlock: 0.0.0.0/0, NatGatewayId: !Ref Nat }
PrivateAAssoc:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PrivateA, RouteTableId: !Ref PrivateRt }
PrivateBAssoc:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { SubnetId: !Ref PrivateB, RouteTableId: !Ref PrivateRt }
# ================================================================ security groups
AlbSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Public HTTP to ALB
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0 }
AppSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: App tasks - only from ALB
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: !Ref ContainerPort, ToPort: !Ref ContainerPort, SourceSecurityGroupId: !Ref AlbSg }
DmsSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: DMS replication instance
VpcId: !Ref Vpc
DbSg:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Aurora - from app tasks and DMS only
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSg }
- { IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref DmsSg }
# ================================================================ database (target)
DbSubnets:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Private subnets for Aurora
SubnetIds: [!Ref PrivateA, !Ref PrivateB]
DbParams:
Type: AWS::RDS::DBClusterParameterGroup
Properties:
Description: Logical replication on (handy for future CDC out of Aurora)
Family: aurora-postgresql16
Parameters:
rds.logical_replication: '1'
rds.force_ssl: '1'
AuroraCluster:
Type: AWS::RDS::DBCluster
DeletionPolicy: Snapshot
UpdateReplacePolicy: Snapshot
Properties:
Engine: aurora-postgresql
EngineVersion: '16.4'
DatabaseName: !Ref DatabaseName
MasterUsername: app_admin
ManageMasterUserPassword: true
DBSubnetGroupName: !Ref DbSubnets
VpcSecurityGroupIds: [!Ref DbSg]
DBClusterParameterGroupName: !Ref DbParams
StorageEncrypted: true
BackupRetentionPeriod: 7
EnableCloudwatchLogsExports: [postgresql]
ServerlessV2ScalingConfiguration:
MinCapacity: !Ref AuroraMinAcu
MaxCapacity: !Ref AuroraMaxAcu
AuroraWriter:
Type: AWS::RDS::DBInstance
Properties:
DBClusterIdentifier: !Ref AuroraCluster
DBInstanceClass: db.serverless
Engine: aurora-postgresql
PubliclyAccessible: false
# ================================================================ DMS (replatform the data)
DmsVpcRole:
Type: AWS::IAM::Role
Condition: CreateDmsRole
Properties:
RoleName: dms-vpc-role
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: dms.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonDMSVPCManagementRole
DmsSecretRole:
Type: AWS::IAM::Role
Condition: EnableDms
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: !Sub 'dms.${AWS::Region}.amazonaws.com' }
Action: sts:AssumeRole
Policies:
- PolicyName: read-target-secret
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: [secretsmanager:GetSecretValue, secretsmanager:DescribeSecret]
Resource: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
DmsSubnets:
Type: AWS::DMS::ReplicationSubnetGroup
Condition: EnableDms
Properties:
ReplicationSubnetGroupDescription: Private subnets for DMS
SubnetIds: [!Ref PrivateA, !Ref PrivateB]
DmsInstance:
Type: AWS::DMS::ReplicationInstance
Condition: EnableDms
Properties:
ReplicationInstanceClass: dms.t3.medium
AllocatedStorage: 50
PubliclyAccessible: false
MultiAZ: false
ReplicationSubnetGroupIdentifier: !Ref DmsSubnets
VpcSecurityGroupIds: [!Ref DmsSg]
SourceEndpoint:
Type: AWS::DMS::Endpoint
Condition: EnableDms
Properties:
EndpointType: source
EngineName: !Ref SourceEngine
ServerName: !Ref SourceServer
Port: !Ref SourcePort
DatabaseName: !Ref SourceDatabase
Username: !Ref SourceUsername
Password: !Ref SourcePassword
SslMode: require
TargetEndpoint:
Type: AWS::DMS::Endpoint
Condition: EnableDms
Properties:
EndpointType: target
EngineName: aurora-postgresql
DatabaseName: !Ref DatabaseName
SslMode: require
PostgreSqlSettings:
SecretsManagerSecretId: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
SecretsManagerAccessRoleArn: !GetAtt DmsSecretRole.Arn
ReplicationTask:
Type: AWS::DMS::ReplicationTask
Condition: EnableDms
Properties:
ReplicationTaskIdentifier: !Sub '${AppName}-full-load-and-cdc'
MigrationType: full-load-and-cdc
ReplicationInstanceArn: !Ref DmsInstance
SourceEndpointArn: !Ref SourceEndpoint
TargetEndpointArn: !Ref TargetEndpoint
TableMappings: |
{
"rules": [
{
"rule-type": "selection", "rule-id": "1", "rule-name": "include-all",
"object-locator": { "schema-name": "%", "table-name": "%" },
"rule-action": "include"
},
{
"rule-type": "transformation", "rule-id": "2", "rule-name": "lowercase-tables",
"rule-target": "table", "object-locator": { "schema-name": "%", "table-name": "%" },
"rule-action": "convert-lowercase"
}
]
}
ReplicationTaskSettings: |
{
"TargetMetadata": { "TargetSchema": "", "SupportLobs": true, "LimitedSizeLobMode": true, "LobMaxSize": 64 },
"FullLoadSettings": { "TargetTablePrepMode": "DROP_AND_CREATE", "MaxFullLoadSubTasks": 8 },
"Logging": { "EnableLogging": true },
"ValidationSettings": { "EnableValidation": true, "ThreadCount": 5 }
}
# ================================================================ app (replatformed to containers)
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub '${AppName}-cluster'
ClusterSettings: [{ Name: containerInsights, Value: enabled }]
AppLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/ecs/${AppName}'
RetentionInDays: 30
ExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: ecs-tasks.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
Policies:
- PolicyName: db-secret
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
TaskRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: ecs-tasks.amazonaws.com }
Action: sts:AssumeRole
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Ref AppName
RequiresCompatibilities: [FARGATE]
NetworkMode: awsvpc
Cpu: '512'
Memory: '1024'
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
TaskRoleArn: !GetAtt TaskRole.Arn
RuntimePlatform: { CpuArchitecture: X86_64, OperatingSystemFamily: LINUX }
ContainerDefinitions:
- Name: app
Image: !Ref ContainerImage
Essential: true
PortMappings: [{ ContainerPort: !Ref ContainerPort }]
Environment:
- { Name: DB_HOST, Value: !GetAtt AuroraCluster.Endpoint.Address }
- { Name: DB_PORT, Value: '5432' }
- { Name: DB_NAME, Value: !Ref DatabaseName }
Secrets:
- Name: DB_USERNAME
ValueFrom: !Join ['', [!GetAtt AuroraCluster.MasterUserSecret.SecretArn, ':username::']]
- Name: DB_PASSWORD
ValueFrom: !Join ['', [!GetAtt AuroraCluster.MasterUserSecret.SecretArn, ':password::']]
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref AppLogs
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: app
Alb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: internet-facing
Subnets: [!Ref PublicA, !Ref PublicB]
SecurityGroups: [!Ref AlbSg]
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !Ref Vpc
TargetType: ip
Protocol: HTTP
Port: !Ref ContainerPort
HealthCheckPath: !Ref HealthCheckPath
Matcher: { HttpCode: '200-399' }
TargetGroupAttributes:
- { Key: deregistration_delay.timeout_seconds, Value: '15' }
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref Alb
Port: 80
Protocol: HTTP
DefaultActions: [{ Type: forward, TargetGroupArn: !Ref TargetGroup }]
Service:
Type: AWS::ECS::Service
DependsOn: [Listener, PrivateDefault]
Properties:
Cluster: !Ref Cluster
ServiceName: !Ref AppName
LaunchType: FARGATE
DesiredCount: !Ref DesiredCount
TaskDefinition: !Ref TaskDefinition
DeploymentConfiguration:
DeploymentCircuitBreaker: { Enable: true, Rollback: true }
MinimumHealthyPercent: 100
MaximumPercent: 200
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: DISABLED
Subnets: [!Ref PrivateA, !Ref PrivateB]
SecurityGroups: [!Ref AppSg]
LoadBalancers:
- { ContainerName: app, ContainerPort: !Ref ContainerPort, TargetGroupArn: !Ref TargetGroup }
Outputs:
AppUrl:
Value: !Sub 'http://${Alb.DNSName}'
AuroraEndpoint:
Value: !GetAtt AuroraCluster.Endpoint.Address
AuroraSecretArn:
Value: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
ReplicationTaskArn:
Condition: EnableDms
Value: !Ref ReplicationTask
PrivateSubnets:
Value: !Join [',', [!Ref PrivateA, !Ref PrivateB]]
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.