10 ยท Legacy App Migration Playbook: On-Prem VM to Containers on AWS

Code Repository / projects / 10-legacy-migration-playbook
Project 10 ยท AWS CloudFormation

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)

README.md ยท 64 lines
# 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
```
template.yaml ยท 468 lines
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.