AWS Compute Blog
Optimizing Joining Windows Server Instances to a Domain with PowerShell in AWS CloudFormation

Scott Zimmerman
AWS Solutions Architect
Deploying applications with Active Directory, including Microsoft SharePoint Server and custom .NET applications, can take several minutes and possibly even hours. My colleague Julien Lépine wrote an excellent post, Optimize AWS CloudFormation Templates, about parallelizing Amazon EC2 instance creation in AWS CloudFormation. His post shows a general technique for using cfn-signal after an instance completes its initialization to trigger other dependent instances (which were launched in parallel) to begin initialization. Launching multiple instances simultaneously, and then blocking initialization only when necessary, can significantly reduce total stack creation time.
This post describes a specialization of his technique as it applies to connecting Microsoft Windows Server instances to a domain. Often, a domain controller needs to be set up before an application server can connect to the domain. To reduce stack creation time, you can launch those two servers in parallel, as long as there is a mechanism to block the application server from attempting to join the domain until the domain controller is fully initialized. Using the DependsOn element in the application server instance is one solution. A faster technique is to use cfn-signal. And it's even faster to create the domain controller as a custom AMI.
The following diagram shows an example infrastructure with these four components:
- NAT gateway in the public subnet for allowing outbound Internet traffic from the private subnet
- BastionHost1 in the public subnet for allowing inbound administrative remote access into the private subnet
- Server1 in the private subnet to represent a typical application server that needs to join the Active Directory domain
- DC1 , where the Active Directory role is installed

Example infrastructure for an application server and domain controller
I'm deliberately keeping this simple with a single Availability Zone so we can focus on the concept of domain joining. Of course, in a production environment, you would have two Availability Zones and redundant instances for high availability.
There are many variables that affect total stack creation time, including instance types, regions, and other intangibles. I timed three approaches (described later) to building the infrastructure shown in the diagram and got the following results using m4.large for the domain controllers (your mileage will vary).
| Template | Method | Timing |
|---|---|---|
| Domain-Join1.json | Standard AMI with CreationPolicy and DependsOn | About 20 minutes |
| Domain-Join2.json | Standard AMI with cfn-signal and a PowerShell loop | About 16 minutes |
| Domain-Join3.json | Custom AMI with the AD role pre-installed | About 7 minutes |
The CloudFormation templates are included below so you can try it yourself. You just need to replace the AMIID parameter with the latest AMI ID for Windows Server 2012 R2 Base in your preferred region, and provide a key pair for your region.
After you deploy the stack in CloudFormation, you can remotely connect to the BastionHost1 instance using the administrator password you specified when you launched the stack. Then from the BastionHost1 instance, you can run Remote Desktop Connection to the private IP address of either Server1 or DC1. Note that you log in as the local administrator to BastionHost1 and Server1, but use mydomain\StackAdmin with your selected password when logging into DC1.
Now let's dive into each of these techniques.
Using the Windows AMI with CreationPolicy and DependsOn
Filename: Domain-Join1.json
{
"Description": "CloudFormation template for domain join with CreationPolicy and DependsOn",
"Parameters": {
"KeyName": {
"MinLength" : 1,
"Type": "AWS::EC2::KeyPair::KeyName"
},
"BaseAmiId": {
"Default": "ami-bd3ba0aa",
"Type": "String"
},
"DomainAdminUser": {
"Description": "User name for the account that will be added as Domain Administrator. This is separate from the default \"Administrator\" account",
"Type": "String",
"Default": "StackAdmin"
},
"AdminPassword": {
"NoEcho": "true",
"Description" : "The Windows administrator account password",
"Type": "String",
"MinLength": "8",
"MaxLength": "41"
},
"DomainDNSName": {
"Description": "Fully qualified domain name (FQDN) of the forest root domain",
"Type": "String",
"Default": "mydomain.local"
},
"DomainNetBiosName": {
"Description": "Netbios name for the domain",
"Type": "String",
"Default": "mydomain"
}
},
"Resources": {
"BastionHost1": {
"Properties": {
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "t2.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.1.100",
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionHost1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"DC1": {
"Type": "AWS::EC2::Instance",
"DependsOn": "NATRoute",
"CreationPolicy" : {
"ResourceSignal" : {
"Timeout": "PT20M",
"Count" : "1"
}
},
"Metadata": {
"AWS::CloudFormation::Init": {
"configSets": {
"config": [
"setup",
"rename",
"installADDS"
]
},
"setup": {
"files": {
"c:\\cfn\\cfn-hup.conf": {
"content": {
"Fn::Join": [
"",
[
"[main]\n",
"stack=",
{
"Ref": "AWS::StackName"
},
"\n",
"region=",
{
"Ref": "AWS::Region"
},
"\n"
]
]
}
},
"c:\\cfn\\hooks.d\\cfn-auto-reloader.conf": {
"content": {
"Fn::Join": [
"",
[
"[cfn-auto-reloader-hook]\n",
"triggers=post.update\n",
"path=Resources.DC1.Metadata.AWS::CloudFormation::Init\n",
"action=cfn-init.exe -v -c config -s ",
{
"Ref": "AWS::StackId"
},
" -r DC1",
" --region ",
{
"Ref": "AWS::Region"
},
"\n"
]
]
}
},
"c:\\cfn\\scripts\\Set-StaticIP.ps1": {
"content": {
"Fn::Join": [
"",
[
"$netip = Get-NetIPConfiguration;",
"$ipconfig = Get-NetIPAddress | ?{$_.IpAddress -eq $netip.IPv4Address.IpAddress};",
"Get-NetAdapter | Set-NetIPInterface -DHCP Disabled;",
"Get-NetAdapter | New-NetIPAddress -AddressFamily IPv4 -IPAddress $netip.IPv4Address.IpAddress -PrefixLength $ipconfig.PrefixLength -DefaultGateway $netip.IPv4DefaultGateway.NextHop;",
"Get-NetAdapter | Set-DnsClientServerAddress -ServerAddresses $netip.DNSServer.ServerAddresses;",
"\n"
]
]
}
},
"c:\\cfn\\scripts\\ConvertTo-EnterpriseAdmin.ps1": {
"source": "https://s3.amazonaws.com/quickstart-reference/microsoft/activedirectory/latest/scripts/ConvertTo-EnterpriseAdmin.ps1"
}
},
"services": {
"windows": {
"cfn-hup": {
"enabled": "true",
"ensureRunning": "true",
"files": [
"c:\\cfn\\cfn-hup.conf",
"c:\\cfn\\hooks.d\\cfn-auto-reloader.conf"
]
}
}
},
"commands": {
"a-disable-win-fw": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command \"Get-NetFirewallProfile | Set-NetFirewallProfile -Enabled False"
]
]
},
"waitAfterCompletion": "0"
}
}
},
"rename": {
"commands": {
"a-set-static-ip": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -ExecutionPolicy RemoteSigned -Command c:\\cfn\\scripts\\Set-StaticIP.ps1"
]
]
},
"waitAfterCompletion": "15"
},
"b-run-powershell-RenameComputer-no-reboot": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe Rename-Computer -NewName DC1 -force -restart"
]
]
},
"waitAfterCompletion": "forever"
}
}
},
"installADDS": {
"commands": {
"1-install-prereqs": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command \"Install-WindowsFeature AD-Domain-Services, rsat-adds -IncludeAllSubFeature"
]
]
},
"waitAfterCompletion": "0"
},
"2-install-adds": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command Install-ADDSForest -DomainName ",
{
"Ref": "DomainDNSName"
},
" -SafeModeAdministratorPassword (ConvertTo-SecureString '",
{
"Ref": "AdminPassword"
},
"' -AsPlainText -Force) -DomainMode Win2012R2 -DomainNetbiosName ",
{
"Ref": "DomainNetBiosName"
},
" -ForestMode Win2012R2 -Confirm:$false -Force"
]
]
},
"waitAfterCompletion": "forever"
},
"3-restart-service": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command Restart-Service NetLogon -EA 0"
]
]
},
"waitAfterCompletion": "60"
},
"4-create-adminuser": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command $s = Get-Service -Name ADWS; while ($s.Status -ne 'Running'){ Start-Service ADWS; Start-Sleep 3 }; Start-Sleep 60\n",
"powershell.exe -Command $u = New-ADUser -Name ",
{
"Ref": "DomainAdminUser"
},
" -UserPrincipalName ",
{
"Ref": "DomainAdminUser"
},
"@",
{
"Ref": "DomainDNSName"
},
" -AccountPassword (ConvertTo-SecureString '",
{
"Ref": "AdminPassword"
},
"' -AsPlainText -Force) -Enabled $true -PasswordNeverExpires $true -PassThru"
]
]
},
"waitAfterCompletion": "0"
},
"5-update-adminuser": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -ExecutionPolicy RemoteSigned -Command c:\\cfn\\scripts\\ConvertTo-EnterpriseAdmin.ps1 -Members ",
{
"Ref": "DomainAdminUser"
}
]
]
},
"waitAfterCompletion": "0"
}
}
}
}
},
"Properties": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": "40"
}
}
],
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "m4.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "false",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "PrivateSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.100",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "DC1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<script>\n",
"cfn-init.exe -v -c config -s ",
{
"Ref": "AWS::StackId"
},
" -r DC1",
" --region ",
{
"Ref": "AWS::Region"
},
"\n",
"</script>\n"
]
]
}
}
}
},
"Server1": {
"DependsOn" : "DC1",
"Properties": {
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "m4.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.101",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "Server1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password for consistency...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"### Join the domain...\n",
"$computer = Get-WmiObject -Class Win32_ComputerSystem \n",
"if ($computer.domain -eq 'WORKGROUP') { \n",
" $adapter = Get-NetAdapter -Name 'Ethernet*'\n",
" Set-DNSClientServerAddress -InterfaceAlias $adapter.Name -ServerAddresses ('",
{
"Fn::GetAtt": [
"DC1",
"PrivateIp"
]
},
"')\n",
" $strNETLOGON='\\\\",
{
"Fn::GetAtt": [
"DC1",
"PrivateIp"
]
},
"\\NETLOGON' \n",
" $done=$false \n",
" while (!$done) {\n",
" Start-Sleep 5 \n",
" net use * $strNETLOGON /user:",
{
"Ref": "DomainAdminUser"
},
" '",
{
"Ref": "AdminPassword"
},
"'\n",
" if (Test-Path $strNETLOGON) {$done=$true} \n",
" } \n",
" $domain = '",
{
"Ref": "DomainDNSName"
},
"'\n",
" $password = '",
{
"Ref": "AdminPassword"
},
"' | ConvertTo-SecureString -asPlainText -Force \n",
" $Administrator = '",
{
"Ref": "DomainNetBiosName"
},
"\\",
{
"Ref": "DomainAdminUser"
},
"'\n",
" $credential = New-Object System.Management.Automation.PSCredential($Administrator,$password) \n",
" Add-Computer -DomainName $domain -Credential $credential -restart \n",
"}\n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"PrivateSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for Active Directory",
"SecurityGroupEgress": [
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
}
],
"SecurityGroupIngress": [
{
"FromPort": "3389",
"IpProtocol": "tcp",
"SourceSecurityGroupId": {
"Ref": "BastionSecurityGroup"
},
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "PrivateSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"BastionSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for the bastion host",
"SecurityGroupEgress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"SecurityGroupIngress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"PublicSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.1.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PublicSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PublicSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "DMZRouteTable"
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"PrivateSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.3.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PrivateSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PrivateSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "InternalRouteTable"
},
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"DMZRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Application",
"Value": {
"Ref": "AWS::StackName"
}
},
{
"Key": "Name",
"Value": "DMZRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"InternalRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternalRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"NATRoute": {
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"NatGatewayId": {
"Ref": "AWSNat"
},
"RouteTableId": {
"Ref": "InternalRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"PublicRoute": {
"DependsOn" : "InternetGatewayAttachment",
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"GatewayId": {
"Ref": "InternetGateway"
},
"RouteTableId": {
"Ref": "DMZRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"VPC": {
"Properties": {
"CidrBlock": "10.1.0.0/16",
"EnableDnsHostnames": "true",
"EnableDnsSupport": "true",
"Tags": [
{
"Key": "Name",
"Value": "VPC"
}
]
},
"Type": "AWS::EC2::VPC"
},
"InternetGateway": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternetGateway"
}
]
},
"Type": "AWS::EC2::InternetGateway"
},
"InternetGatewayAttachment": {
"Properties": {
"InternetGatewayId": {
"Ref": "InternetGateway"
},
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::VPCGatewayAttachment"
},
"AWSNat": {
"DependsOn": "InternetGatewayAttachment",
"Properties": {
"AllocationId": {
"Fn::GetAtt": [
"NatEip",
"AllocationId"
]
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::NatGateway"
},
"NatEip": {
"Properties": {
"Domain": "vpc"
},
"Type": "AWS::EC2::EIP"
}
}
}
Notice in Domain-Join1.json that the DC1 resource has the following CreationPolicy attribute:
"CreationPolicy" : {
"ResourceSignal" : {
"Timeout": "PT20M",
"Count" : "1"
}
},
This CreationPolicy attribute sets a 20-minute timeout. If the instance hasn't finished launching and running all of its initialization code within 20 minutes, then CloudFormation considers it to have failed. We'll get to the Metadata section of DC1 in a minute, but the second half of the primary logic is that Server1 has a DependsOn property, like this:
"Server1": { "DependsOn" : "DC1", ...
This means that CloudFormation will not even launch Server1 until DC1 has the status of CREATE_COMPLETE. Putting this all together, Server1 isn't ready until all of the following events occur in order:

Now look at the Metadata section of DC1 in Domain-Join1.json. It has code to load a couple of CloudFormation configuration files and a couple of PowerShell scripts on the instance. It also sets a static IP address for the domain controller (recommended practice), and renames the instance to DC1. Then it has commands that install the Active Directory forest and create the domain administrator.
None of the commands in the Metadata section will run unless the UserData section of DC1 includes the command to run cfn-init.exe.
Before you leave this section, here's a CloudFormation best practice. Before your stack can run cfn-init.exe, the stack must have established outbound Internet access. This means that your NAT gateway, security groups, and routing table resources must all have the status CREATE_COMPLETE. The DC1 resource achieves this with a DependsOn attribute for the NATRoute.
Using cfn-signal and a PowerShell loop
Now consider a better way to do this. The DependsOn attribute on Server1 is the main roadblock in the preceding approach, so let's remove that. Using Julien's technique, you can replace it with a PowerShell loop in the UserData section of Server1 that polls a WaitHandle. The WaitHandle is triggered by DC1 when it finishes running all of its initiation logic to install the Active Directory forest. This allows Server1 to launch simultaneously with DC1, but not connect to the domain until DC1 has created the forest.
This diagram shows the compressed sequence of events:

Filename: Domain-Join2.json
{
"Description": "CloudFormation template for domain join with cfn-signal and PowerShell loop",
"Parameters": {
"KeyName": {
"MinLength" : 1,
"Type": "AWS::EC2::KeyPair::KeyName"
},
"BaseAmiId": {
"Default": "ami-bd3ba0aa",
"Type": "String"
},
"DomainAdminUser": {
"Description": "User name for the account that will be added as Domain Administrator. This is separate from the default \"Administrator\" account",
"Type": "String",
"Default": "StackAdmin"
},
"AdminPassword": {
"NoEcho": "true",
"Description" : "The Windows administrator account password",
"Type": "String",
"MinLength": "8",
"MaxLength": "41"
},
"DomainDNSName": {
"Description": "Fully qualified domain name (FQDN) of the forest root domain",
"Type": "String",
"Default": "mydomain.local"
},
"DomainNetBiosName": {
"Description": "Netbios name for the domain",
"Type": "String",
"Default": "mydomain"
}
},
"Resources": {
"RootRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version" : "2012-10-17",
"Statement": [ {
"Effect": "Allow",
"Principal": {
"Service": [ "ec2.amazonaws.com" ]
},
"Action": [ "sts:AssumeRole" ]
} ]
},
"Path": "/",
"Policies": [ {
"PolicyName": "root",
"PolicyDocument": {
"Version" : "2012-10-17",
"Statement": [ {
"Effect": "Allow",
"Action": "*",
"Resource": "*"
} ]
}
} ]
}
},
"RootInstanceProfile": {
"Type": "AWS::IAM::InstanceProfile",
"Properties": {
"Path": "/",
"Roles": [ {
"Ref": "RootRole"
} ]
}
},
"DomainControllerWaitCondition": {
"Type": "AWS::CloudFormation::WaitCondition",
"DependsOn": "DC1",
"Properties": {
"Handle": {
"Ref": "DomainControllerWaitHandle"
},
"Timeout": "1100"
}
},
"DomainControllerWaitHandle": {
"Type": "AWS::CloudFormation::WaitConditionHandle"
},
"BastionHost1": {
"Properties": {
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "t2.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.1.100",
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionHost1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"DC1": {
"Type": "AWS::EC2::Instance",
"DependsOn": "NATRoute",
"Metadata": {
"AWS::CloudFormation::Init": {
"configSets": {
"config": [
"setup",
"rename",
"installADDS",
"finalize"
]
},
"setup": {
"files": {
"c:\\cfn\\cfn-hup.conf": {
"content": {
"Fn::Join": [
"",
[
"[main]\n",
"stack=",
{
"Ref": "AWS::StackName"
},
"\n",
"region=",
{
"Ref": "AWS::Region"
},
"\n"
]
]
}
},
"c:\\cfn\\hooks.d\\cfn-auto-reloader.conf": {
"content": {
"Fn::Join": [
"",
[
"[cfn-auto-reloader-hook]\n",
"triggers=post.update\n",
"path=Resources.DC1.Metadata.AWS::CloudFormation::Init\n",
"action=cfn-init.exe -v -c config -s ",
{
"Ref": "AWS::StackId"
},
" -r DC1",
" --region ",
{
"Ref": "AWS::Region"
},
"\n"
]
]
}
},
"c:\\cfn\\scripts\\Set-StaticIP.ps1": {
"content": {
"Fn::Join": [
"",
[
"$netip = Get-NetIPConfiguration;",
"$ipconfig = Get-NetIPAddress | ?{$_.IpAddress -eq $netip.IPv4Address.IpAddress};",
"Get-NetAdapter | Set-NetIPInterface -DHCP Disabled;",
"Get-NetAdapter | New-NetIPAddress -AddressFamily IPv4 -IPAddress $netip.IPv4Address.IpAddress -PrefixLength $ipconfig.PrefixLength -DefaultGateway $netip.IPv4DefaultGateway.NextHop;",
"Get-NetAdapter | Set-DnsClientServerAddress -ServerAddresses $netip.DNSServer.ServerAddresses;",
"\n"
]
]
}
}
},
"services": {
"windows": {
"cfn-hup": {
"enabled": "true",
"ensureRunning": "true",
"files": [
"c:\\cfn\\cfn-hup.conf",
"c:\\cfn\\hooks.d\\cfn-auto-reloader.conf"
]
}
}
},
"commands": {
"a-disable-win-fw": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command \"Get-NetFirewallProfile | Set-NetFirewallProfile -Enabled False"
]
]
},
"waitAfterCompletion": "0"
}
}
},
"rename": {
"commands": {
"a-set-static-ip": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -ExecutionPolicy RemoteSigned -Command c:\\cfn\\scripts\\Set-StaticIP.ps1"
]
]
},
"waitAfterCompletion": "15"
},
"b-run-powershell-RenameComputer-no-reboot": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe Rename-Computer -NewName DC1 -force -restart"
]
]
},
"waitAfterCompletion": "forever"
}
}
},
"installADDS": {
"commands": {
"1-install-prereqs": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command \"Install-WindowsFeature AD-Domain-Services, rsat-adds -IncludeAllSubFeature"
]
]
},
"waitAfterCompletion": "0"
},
"2-install-adds": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command Install-ADDSForest -DomainName ",
{
"Ref": "DomainDNSName"
},
" -SafeModeAdministratorPassword (ConvertTo-SecureString '",
{
"Ref": "AdminPassword"
},
"' -AsPlainText -Force) -DomainMode Win2012R2 -DomainNetbiosName ",
{
"Ref": "DomainNetBiosName"
},
" -ForestMode Win2012R2 -Confirm:$false -Force"
]
]
},
"waitAfterCompletion": "forever"
},
"3-restart-service": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command Restart-Service NetLogon -EA 0"
]
]
},
"waitAfterCompletion": "20"
},
"4-start-ADWS": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command $s = Get-Service -Name ADWS; while ($s.Status -ne 'Running'){ Start-Service ADWS; Start-Sleep 3 }"
]
]
},
"waitAfterCompletion": "30"
},
"5-create-adminuser": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command $u = New-ADUser ",
{
"Ref": "DomainAdminUser"
},
" -SamAccountName ",
{
"Ref": "DomainAdminUser"
},
" -UserPrincipalName ",
{
"Ref": "DomainAdminUser"
},
"@",
{
"Ref": "DomainDNSName"
},
" -AccountPassword (ConvertTo-SecureString '",
{
"Ref": "AdminPassword"
},
"' -AsPlainText -Force) -Enabled $true -PasswordNeverExpires $true -PassThru; Add-ADGroupMember -Identity 'domain admins' -Members $u"
]
]
},
"waitAfterCompletion": "0"
}
}
},
"finalize": {
"commands": {
"a-signal-success": {
"command": {
"Fn::Join": [
"",
[
"cfn-signal.exe -e 0 ",
{
"Fn::Base64": {
"Ref": "DomainControllerWaitHandle"
}
},
""
]
]
}
}
}
}
}
},
"Properties": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": "40"
}
}
],
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "m4.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "false",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "PrivateSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.100",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "DC1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<script>\n",
"cfn-init.exe -v -c config -s ",
{
"Ref": "AWS::StackId"
},
" -r DC1",
" --region ",
{
"Ref": "AWS::Region"
},
"\n",
"</script>\n"
]
]
}
}
}
},
"Server1": {
"Properties": {
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "m4.large",
"IamInstanceProfile" : {"Ref" : "RootInstanceProfile"},
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.101",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "Server1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"### Wait for DC to be fully available...\n",
"$resource = 'DomainControllerWaitCondition'\n",
"$region = '",
{ "Ref": "AWS::Region" },
"'\n$stack = '",
{ "Ref": "AWS::StackId" },
"'\n$output = (Get-CFNStackResources -StackName $stack -LogicalResourceId $resource -Region $region)\n",
"while (($output -eq $null) -or ($output.ResourceStatus -ne 'CREATE_COMPLETE') -and ($output.ResourceStatus -ne 'UPDATE_COMPLETE')) {\n",
" Start-Sleep 5\n",
" $output = (Get-CFNStackResources -StackName $stack -LogicalResourceId $resource -Region $region)\n",
"}\n",
"### Join the domain...\n",
"$computer = Get-WmiObject -Class Win32_ComputerSystem \n",
"if ($computer.domain -eq 'WORKGROUP') { \n",
" $adapter = Get-NetAdapter -Name 'Ethernet*'\n",
" Set-DNSClientServerAddress -InterfaceAlias $adapter.Name -ServerAddresses ('",
{
"Fn::GetAtt": [
"DC1",
"PrivateIp"
]
},
"')\n",
" $domain = '",
{
"Ref": "DomainDNSName"
},
"'\n",
" $password = '",
{
"Ref": "AdminPassword"
},
"' | ConvertTo-SecureString -asPlainText -Force \n",
" $Administrator = '",
{
"Ref": "DomainNetBiosName"
},
"\\",
{
"Ref": "DomainAdminUser"
},
"'\n",
" $credential = New-Object System.Management.Automation.PSCredential($Administrator,$password) \n",
" Add-Computer -DomainName $domain -Credential $credential -restart \n",
"}\n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"PrivateSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for Active Directory",
"SecurityGroupEgress": [
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
}
],
"SecurityGroupIngress": [
{
"FromPort": "3389",
"IpProtocol": "tcp",
"SourceSecurityGroupId": {
"Ref": "BastionSecurityGroup"
},
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "PrivateSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"BastionSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for the bastion host",
"SecurityGroupEgress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"SecurityGroupIngress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"PublicSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.1.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PublicSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PublicSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "DMZRouteTable"
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"PrivateSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.3.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PrivateSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PrivateSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "InternalRouteTable"
},
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"DMZRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Application",
"Value": {
"Ref": "AWS::StackName"
}
},
{
"Key": "Name",
"Value": "DMZRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"InternalRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternalRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"NATRoute": {
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"NatGatewayId": {
"Ref": "AWSNat"
},
"RouteTableId": {
"Ref": "InternalRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"PublicRoute": {
"DependsOn" : "InternetGatewayAttachment",
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"GatewayId": {
"Ref": "InternetGateway"
},
"RouteTableId": {
"Ref": "DMZRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"VPC": {
"Properties": {
"CidrBlock": "10.1.0.0/16",
"EnableDnsHostnames": "true",
"EnableDnsSupport": "true",
"Tags": [
{
"Key": "Name",
"Value": "VPC"
}
]
},
"Type": "AWS::EC2::VPC"
},
"InternetGateway": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternetGateway"
}
]
},
"Type": "AWS::EC2::InternetGateway"
},
"InternetGatewayAttachment": {
"Properties": {
"InternetGatewayId": {
"Ref": "InternetGateway"
},
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::VPCGatewayAttachment"
},
"AWSNat": {
"DependsOn": "InternetGatewayAttachment",
"Properties": {
"AllocationId": {
"Fn::GetAtt": [
"NatEip",
"AllocationId"
]
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::NatGateway"
},
"NatEip": {
"Properties": {
"Domain": "vpc"
},
"Type": "AWS::EC2::EIP"
}
}
}
Here's the WaitCondition and the WaitHandle code in Domain-Join2.json (they go together in CloudFormation):
"DomainControllerWaitCondition": {
"Type": "AWS::CloudFormation::WaitCondition",
"DependsOn": "DC1",
"Properties": {
"Handle": {
"Ref": "DomainControllerWaitHandle"
},
"Timeout": "1100"
}
},
"DomainControllerWaitHandle": {
"Type": "AWS::CloudFormation::WaitConditionHandle"
},
Now look at the finalize section in the DC1 metadata. It runs cfn-signal.exe to trigger the WaitHandle:
"finalize": {
"commands": {
"a-signal-success": {
"command": {
"Fn::Join": [
"",
[
"cfn-signal.exe -e 0 ",
{
"Fn::Base64": {
"Ref": "DomainControllerWaitHandle"
}
},
""
]
]
}
}
}
}
The final piece is the PowerShell loop in the Server1 UserData section. It polls the WaitHandle until it is marked as CREATE_COMPLETE by cfn-signal:
"'\n$output = (Get-CFNStackResources -StackName $stack -LogicalResourceId $resource -Region $region)\n",
"while (($output -eq $null) -or ($output.ResourceStatus -ne 'CREATE_COMPLETE') -and ($output.ResourceStatus -ne 'UPDATE_COMPLETE')) {\n",
" Start-Sleep 5\n",
" $output = (Get-CFNStackResources -StackName $stack -LogicalResourceId $resource -Region $region)\n",
"}\n",
Unfortunately, the syntax of the PowerShell code inside JSON gets a bit tricky with respect to double-quoted strings and newlines. When you develop PowerShell inside CloudFormation, you need to login to the instance and examine the PowerShell results in cfn-init.log. (For more details, see the tip at the end of this section.)
The while loop uses the AWS Tools for PowerShell cmdlet Get-CFNStackResources. Because this is an AWS operation, not a Windows cmdlet, you must provide AWS credentials to PowerShell. The best way to do this is to launch the Server1 instance with an IamInstanceProfile property. You'll notice that Domain-Join2.json and Domain-Join3.json create an IAM::Role resource, then inject that role into an IAM::InstanceProfile resource, then specify that InstanceProfile in Server1's IamInstanceProfile property.
One more tip: Remember when I said that your stack needs outbound Internet access before it can run cfn-init.exe? The same is true if your stack uses cfn-signal.exe, or Get-CFNStackResources, or many other AWS cmdlets.
Using a custom AMI that has the Active Directory role preinstalled
Using cfn-signal definitely speeds things up because it enables Server1 to launch simultaneously with DC1. But the whole process is still delayed waiting for the Active Directory role to be installed, the forest to be created, and DC1 to be rebooted. If you are running this repeatedly in a dev/test environment, you want it to be as fast as possible. To do that, you can create the domain controller and make a custom AMI of it. Then specify that AMI ID when launching DC1.
Here's the optimized sequence of events:

To use this technique, you need to make the following changes to the previous approach:
- Run Domain-Join3-Create.json (see below) in CloudFormation. It builds only a domain controller called DC1.
- Connect to DC1 using RDP. (As described in the preceding section, first remotely connect to BastionHost1 as an administrator, then remotely connect to DC1 as mydomain\StackAdmin.)
- On DC1, run Ec2configSettings.exe to enable execution of the UserData section. See screenshot below. (This updates C:\Program Files\Amazon\Ec2ConfigService\Settings\config.xml.)

Enable UserData execution in Ec2ConfigSettings.exe
- Stop the DC1 instance in the EC2 Dashboard (a best practice before making an image). Wait for the instance state to change to stopped , and then create an image of it.
- In CloudFormation, delete the stack.
- Open Domain-Join3.json and edit the DCAmiId parameter so that it matches the custom AMI that you just created. You'll notice that I removed a large chunk of code that installs the Windows feature and creates the forest from this template because that logic already ran in Domain-Join3-Create.json.
- Finally, run Domain-Join3.json in CloudFormation whenever you want to deploy the complete sample infrastructure. This is the fastest way I've found to do this, but I'd love to hear your ideas if you have improvements.
Filename: Domain-Join3-Create.json
{
"Description": "CloudFormation template to create domain controller for AMI",
"Parameters": {
"KeyName": {
"MinLength" : 1,
"Type": "AWS::EC2::KeyPair::KeyName"
},
"BaseAmiId": {
"Default": "ami-bd3ba0aa",
"Type": "String"
},
"DomainAdminUser": {
"Description": "User name for the account that will be added as Domain Administrator. This is separate from the default \"Administrator\" account",
"Type": "String",
"Default": "StackAdmin"
},
"AdminPassword": {
"NoEcho": "true",
"Description" : "The Windows administrator account password",
"Type": "String",
"MinLength": "8",
"MaxLength": "41"
},
"DomainDNSName": {
"Description": "Fully qualified domain name (FQDN) of the forest root domain",
"Type": "String",
"Default": "mydomain.local"
},
"DomainNetBiosName": {
"Description": "Netbios name for the domain",
"Type": "String",
"Default": "mydomain"
}
},
"Resources": {
"BastionHost1": {
"Properties": {
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "t2.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.1.100",
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionHost1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"DC1": {
"Type": "AWS::EC2::Instance",
"DependsOn": "NATRoute",
"Metadata": {
"AWS::CloudFormation::Init": {
"configSets": {
"config": [
"setup",
"rename",
"installADDS"
]
},
"setup": {
"files": {
"c:\\cfn\\scripts\\Set-StaticIP.ps1": {
"content": {
"Fn::Join": [
"",
[
"$netip = Get-NetIPConfiguration;",
"$ipconfig = Get-NetIPAddress | ?{$_.IpAddress -eq $netip.IPv4Address.IpAddress};",
"Get-NetAdapter | Set-NetIPInterface -DHCP Disabled;",
"Get-NetAdapter | New-NetIPAddress -AddressFamily IPv4 -IPAddress $netip.IPv4Address.IpAddress -PrefixLength $ipconfig.PrefixLength -DefaultGateway $netip.IPv4DefaultGateway.NextHop;",
"Get-NetAdapter | Set-DnsClientServerAddress -ServerAddresses $netip.DNSServer.ServerAddresses;",
"\n"
]
]
}
}
},
"commands": {
"a-disable-win-fw": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command \"Get-NetFirewallProfile | Set-NetFirewallProfile -Enabled False"
]
]
},
"waitAfterCompletion": "0"
}
}
},
"rename": {
"commands": {
"a-set-static-ip": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -ExecutionPolicy RemoteSigned -Command c:\\cfn\\scripts\\Set-StaticIP.ps1"
]
]
},
"waitAfterCompletion": "15"
},
"b-run-powershell-RenameComputer-no-reboot": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe Rename-Computer -NewName DC1 -force -restart"
]
]
},
"waitAfterCompletion": "forever"
}
}
},
"installADDS": {
"commands": {
"1-install-prereqs": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command \"Install-WindowsFeature AD-Domain-Services, rsat-adds -IncludeAllSubFeature"
]
]
},
"waitAfterCompletion": "0"
},
"2-install-adds": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command Install-ADDSForest -DomainName ",
{
"Ref": "DomainDNSName"
},
" -SafeModeAdministratorPassword (ConvertTo-SecureString '",
{
"Ref": "AdminPassword"
},
"' -AsPlainText -Force) -DomainMode Win2012R2 -DomainNetbiosName ",
{
"Ref": "DomainNetBiosName"
},
" -ForestMode Win2012R2 -Confirm:$false -Force"
]
]
},
"waitAfterCompletion": "forever"
},
"3-start-ADWS": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command $s = Get-Service -Name ADWS; while ($s.Status -ne 'Running'){ Start-Service ADWS; Start-Sleep 3 }"
]
]
},
"waitAfterCompletion": "30"
},
"4-create-adminuser": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -Command $u = New-ADUser ",
{
"Ref": "DomainAdminUser"
},
" -SamAccountName ",
{
"Ref": "DomainAdminUser"
},
" -UserPrincipalName ",
{
"Ref": "DomainAdminUser"
},
"@",
{
"Ref": "DomainDNSName"
},
" -AccountPassword (ConvertTo-SecureString '",
{
"Ref": "AdminPassword"
},
"' -AsPlainText -Force) -Enabled $true -PasswordNeverExpires $true -PassThru; Add-ADGroupMember -Identity 'domain admins' -Members $u"
]
]
},
"waitAfterCompletion": "0"
}
}
}
}
},
"Properties": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": "40"
}
}
],
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "m4.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "false",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "PrivateSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.100",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "DC1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<script>\n",
"cfn-init.exe -v -c config -s ",
{
"Ref": "AWS::StackId"
},
" -r DC1",
" --region ",
{
"Ref": "AWS::Region"
},
"\n",
"</script>\n"
]
]
}
}
}
},
"PrivateSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for Active Directory",
"SecurityGroupEgress": [
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
}
],
"SecurityGroupIngress": [
{
"FromPort": "3389",
"IpProtocol": "tcp",
"SourceSecurityGroupId": {
"Ref": "BastionSecurityGroup"
},
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "PrivateSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"BastionSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for the bastion host",
"SecurityGroupEgress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"SecurityGroupIngress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"PublicSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.1.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PublicSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PublicSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "DMZRouteTable"
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"PrivateSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.3.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PrivateSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PrivateSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "InternalRouteTable"
},
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"DMZRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Application",
"Value": {
"Ref": "AWS::StackName"
}
},
{
"Key": "Name",
"Value": "DMZRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"InternalRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternalRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"NATRoute": {
"DependsOn" : "InternetGatewayAttachment",
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"NatGatewayId": {
"Ref": "AWSNat"
},
"RouteTableId": {
"Ref": "InternalRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"PublicRoute": {
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"GatewayId": {
"Ref": "InternetGateway"
},
"RouteTableId": {
"Ref": "DMZRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"VPC": {
"Properties": {
"CidrBlock": "10.1.0.0/16",
"EnableDnsHostnames": "true",
"EnableDnsSupport": "true",
"Tags": [
{
"Key": "Name",
"Value": "VPC"
}
]
},
"Type": "AWS::EC2::VPC"
},
"InternetGateway": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternetGateway"
}
]
},
"Type": "AWS::EC2::InternetGateway"
},
"InternetGatewayAttachment": {
"Properties": {
"InternetGatewayId": {
"Ref": "InternetGateway"
},
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::VPCGatewayAttachment"
},
"AWSNat": {
"DependsOn": "InternetGatewayAttachment",
"Properties": {
"AllocationId": {
"Fn::GetAtt": [
"NatEip",
"AllocationId"
]
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::NatGateway"
},
"NatEip": {
"Properties": {
"Domain": "vpc"
},
"Type": "AWS::EC2::EIP"
}
}
}
Filename: Domain-Join3.json
{
"Description": "CloudFormation template for domain join with pre-created AMI",
"Parameters": {
"KeyName": {
"MinLength" : 1,
"Type": "AWS::EC2::KeyPair::KeyName"
},
"BaseAmiId": {
"Default": "ami-79dc1b14",
"Type": "String"
},
"DCAmiId": {
"Default": "ami-67284570",
"Type": "String"
},
"DomainAdminUser": {
"Description": "User name for the account that will be added as Domain Administrator. This is separate from the default \"Administrator\" account",
"Type": "String",
"Default": "StackAdmin"
},
"AdminPassword": {
"NoEcho": "true",
"Description" : "The Windows administrator account password",
"Type": "String",
"MinLength": "8",
"MaxLength": "41"
},
"DomainDNSName": {
"Description": "Fully qualified domain name (FQDN) of the forest root domain",
"Type": "String",
"Default": "mydomain.local"
},
"DomainNetBiosName": {
"Description": "Netbios name for the domain",
"Type": "String",
"Default": "mydomain"
}
},
"Resources": {
"RootRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version" : "2012-10-17",
"Statement": [ {
"Effect": "Allow",
"Principal": {
"Service": [ "ec2.amazonaws.com" ]
},
"Action": [ "sts:AssumeRole" ]
} ]
},
"Path": "/",
"Policies": [ {
"PolicyName": "root",
"PolicyDocument": {
"Version" : "2012-10-17",
"Statement": [ {
"Effect": "Allow",
"Action": "*",
"Resource": "*"
} ]
}
} ]
}
},
"RootInstanceProfile": {
"Type": "AWS::IAM::InstanceProfile",
"Properties": {
"Path": "/",
"Roles": [{
"Ref": "RootRole"
} ]
}
},
"DomainControllerWaitCondition": {
"Type": "AWS::CloudFormation::WaitCondition",
"DependsOn": "DC1",
"Properties": {
"Handle": {
"Ref": "DomainControllerWaitHandle"
},
"Timeout": "1100"
}
},
"DomainControllerWaitHandle": {
"Type": "AWS::CloudFormation::WaitConditionHandle"
},
"BastionHost1": {
"Properties": {
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "t2.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.1.100",
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionHost1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"DC1": {
"Type": "AWS::EC2::Instance",
"DependsOn": "NATRoute",
"Metadata": {
"AWS::CloudFormation::Init": {
"configSets": {
"config": [
"setup",
"rename",
"finalize"
]
},
"setup": {
"files": {
"c:\\cfn\\scripts\\Set-StaticIP.ps1": {
"content": {
"Fn::Join": [
"",
[
"$netip = Get-NetIPConfiguration;",
"$ipconfig = Get-NetIPAddress | ?{$_.IpAddress -eq $netip.IPv4Address.IpAddress};",
"Get-NetAdapter | Set-NetIPInterface -DHCP Disabled;",
"Get-NetAdapter | New-NetIPAddress -AddressFamily IPv4 -IPAddress $netip.IPv4Address.IpAddress -PrefixLength $ipconfig.PrefixLength -DefaultGateway $netip.IPv4DefaultGateway.NextHop;",
"Get-NetAdapter | Set-DnsClientServerAddress -ServerAddresses $netip.DNSServer.ServerAddresses;",
"\n"
]
]
}
}
}
},
"rename": {
"commands": {
"a-set-static-ip": {
"command": {
"Fn::Join": [
"",
[
"powershell.exe -ExecutionPolicy RemoteSigned -Command c:\\cfn\\scripts\\Set-StaticIP.ps1"
]
]
},
"waitAfterCompletion": "15"
}
}
},
"finalize": {
"commands": {
"a-signal-success": {
"command": {
"Fn::Join": [
"",
[
"cfn-signal.exe -e 0 ",
{
"Fn::Base64": {
"Ref": "DomainControllerWaitHandle"
}
},
""
]
]
}
}
}
}
}
},
"Properties": {
"BlockDeviceMappings": [
{
"DeviceName": "/dev/sda1",
"Ebs": {
"VolumeSize": "40"
}
}
],
"ImageId": {
"Ref": "DCAmiId"
},
"InstanceType": "m4.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "false",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "PrivateSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.100",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "DC1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<script>\n",
"cfn-init.exe -v -c config -s ",
{
"Ref": "AWS::StackId"
},
" -r DC1",
" --region ",
{
"Ref": "AWS::Region"
},
"\n",
"</script>\n"
]
]
}
}
}
},
"Server1": {
"Properties": {
"IamInstanceProfile" : {"Ref" : "RootInstanceProfile"},
"ImageId": {
"Ref": "BaseAmiId"
},
"InstanceType": "m4.large",
"KeyName": {
"Ref": "KeyName"
},
"NetworkInterfaces": [
{
"AssociatePublicIpAddress": "true",
"DeleteOnTermination": "true",
"DeviceIndex": "0",
"GroupSet": [
{
"Ref": "BastionSecurityGroup"
}
],
"PrivateIpAddress": "10.1.3.101",
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
}
],
"Tags": [
{
"Key": "Name",
"Value": "Server1"
}
],
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"<powershell>\n",
"### Change local admin password...\n",
"([adsi]\"WinNT://$env:computername/Administrator\").SetPassword('",
{
"Ref": "AdminPassword"
},
"') \n",
"### Wait for DC to be fully available...\n",
"$resource = 'DomainControllerWaitCondition'\n",
"$region = '",
{ "Ref": "AWS::Region" },
"'\n$stack = '",
{ "Ref": "AWS::StackId" },
"'\n$output = (Get-CFNStackResources -StackName $stack -LogicalResourceId $resource -Region $region)\n",
"while (($output -eq $null) -or ($output.ResourceStatus -ne 'CREATE_COMPLETE') -and ($output.ResourceStatus -ne 'UPDATE_COMPLETE')) {\n",
" Start-Sleep 5\n",
" $output = (Get-CFNStackResources -StackName $stack -LogicalResourceId $resource -Region $region)\n",
"}\n",
"### Join the domain...\n",
"$computer = Get-WmiObject -Class Win32_ComputerSystem \n",
"if ($computer.domain -eq 'WORKGROUP') { \n",
" $adapter = Get-NetAdapter -Name 'Ethernet*'\n",
" Set-DNSClientServerAddress -InterfaceAlias $adapter.Name -ServerAddresses ('",
{
"Fn::GetAtt": [
"DC1",
"PrivateIp"
]
},
"')\n",
" $domain = '",
{
"Ref": "DomainDNSName"
},
"'\n",
" $password = '",
{
"Ref": "AdminPassword"
},
"' | ConvertTo-SecureString -asPlainText -Force \n",
" $Administrator = '",
{
"Ref": "DomainNetBiosName"
},
"\\",
{
"Ref": "DomainAdminUser"
},
"'\n",
" $credential = New-Object System.Management.Automation.PSCredential($Administrator,$password) \n",
" Add-Computer -DomainName $domain -Credential $credential -restart \n",
"}\n",
"</powershell>\n"
]
]
}
}
},
"Type": "AWS::EC2::Instance"
},
"PrivateSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for Active Directory",
"SecurityGroupEgress": [
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
}
],
"SecurityGroupIngress": [
{
"FromPort": "3389",
"IpProtocol": "tcp",
"SourceSecurityGroupId": {
"Ref": "BastionSecurityGroup"
},
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "PrivateSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"BastionSecurityGroup": {
"Properties": {
"GroupDescription": "This is the security group for the bastion host",
"SecurityGroupEgress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "80",
"IpProtocol": "tcp",
"ToPort": "80"
},
{
"CidrIp": "0.0.0.0/0",
"FromPort": "443",
"IpProtocol": "tcp",
"ToPort": "443"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "tcp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "0",
"IpProtocol": "udp",
"ToPort": "65535"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"SecurityGroupIngress": [
{
"CidrIp": "0.0.0.0/0",
"FromPort": "3389",
"IpProtocol": "tcp",
"ToPort": "3389"
},
{
"CidrIp": "10.1.0.0/16",
"FromPort": "-1",
"IpProtocol": "icmp",
"ToPort": "-1"
}
],
"Tags": [
{
"Key": "Name",
"Value": "BastionSecurityGroup"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::SecurityGroup"
},
"PublicSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.1.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PublicSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PublicSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "DMZRouteTable"
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"PrivateSubnetAZ1": {
"Properties": {
"AvailabilityZone": {
"Fn::Select": [
0,
{
"Fn::GetAZs": ""
}
]
},
"CidrBlock": "10.1.3.0/24",
"Tags": [
{
"Key": "Name",
"Value": "PrivateSubnetAZ1"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::Subnet"
},
"PrivateSubnetAZ1SubnetAssociation": {
"Properties": {
"RouteTableId": {
"Ref": "InternalRouteTable"
},
"SubnetId": {
"Ref": "PrivateSubnetAZ1"
}
},
"Type": "AWS::EC2::SubnetRouteTableAssociation"
},
"DMZRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Application",
"Value": {
"Ref": "AWS::StackName"
}
},
{
"Key": "Name",
"Value": "DMZRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"InternalRouteTable": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternalRouteTable"
}
],
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::RouteTable"
},
"NATRoute": {
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"NatGatewayId": {
"Ref": "AWSNat"
},
"RouteTableId": {
"Ref": "InternalRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"PublicRoute": {
"DependsOn" : "InternetGatewayAttachment",
"Properties": {
"DestinationCidrBlock": "0.0.0.0/0",
"GatewayId": {
"Ref": "InternetGateway"
},
"RouteTableId": {
"Ref": "DMZRouteTable"
}
},
"Type": "AWS::EC2::Route"
},
"VPC": {
"Properties": {
"CidrBlock": "10.1.0.0/16",
"EnableDnsHostnames": "true",
"EnableDnsSupport": "true",
"Tags": [
{
"Key": "Name",
"Value": "VPC"
}
]
},
"Type": "AWS::EC2::VPC"
},
"InternetGateway": {
"Properties": {
"Tags": [
{
"Key": "Name",
"Value": "InternetGateway"
}
]
},
"Type": "AWS::EC2::InternetGateway"
},
"InternetGatewayAttachment": {
"Properties": {
"InternetGatewayId": {
"Ref": "InternetGateway"
},
"VpcId": {
"Ref": "VPC"
}
},
"Type": "AWS::EC2::VPCGatewayAttachment"
},
"AWSNat": {
"DependsOn": "InternetGatewayAttachment",
"Properties": {
"AllocationId": {
"Fn::GetAtt": [
"NatEip",
"AllocationId"
]
},
"SubnetId": {
"Ref": "PublicSubnetAZ1"
}
},
"Type": "AWS::EC2::NatGateway"
},
"NatEip": {
"Properties": {
"Domain": "vpc"
},
"Type": "AWS::EC2::EIP"
}
}
}
Alas, there is a drawback to this approach. Sysprep is not supported for the Active Directory role, so you won't be able to have more than one domain controller built from your custom AMI in the same VPC. But there's a workaround. If you are building a production SharePoint farm spanning two Availability Zones, you could still use this technique to optimize the launch time of the first domain controller. You would then build the other domain controllers as Domain-Join2.json does, and join all of the application servers and other domain controllers to the forest after it's ready (as done in Domain-Join3.json). Although it sounds like more work, this approach allows you to launch all of the instances simultaneously.
Other best practices
Here are a couple tips to help you diagnose CloudFormation stack failures.
Start by examining the Events tab in the CloudFormation console, of course. But sometimes the stack appears to succeed even if there is an error in your Windows initialization logic. For that, you need to remotely connect to the instance and study these two log files:
- C:\Program Files\Amazon\Ec2ConfigService\Logs\Ec2ConfigLog.txt
- C:\cfn\log\cfn-init.log
These logs aren't formatted very well in Notepad on Windows Server, so I copy and paste the whole file into WordPad on my local workstation. That also allows me to study the error messages even if the instance terminates.
Sometimes the stack fails in CloudFormation, but only because it timed out waiting for the PowerShell initiation logic to complete. In that case, you need to set the CloudFormation Rollback on failure setting to No , in the Advanced options section for your stack.
Conclusion
This post described some best practices for working with CloudFormation and a few ways to join Windows Server instances to a domain. Using PowerShell, I reduced the time to create a stack from about 20 minutes to about 7 minutes.
In addition to these methods of running your own domain controller in Amazon EC2, another option is to use the AWS managed service, AWS Directory Service. It makes connecting instances to a domain easy. For more information, see How to Configure Your EC2 Instances to Automatically Join a Microsoft Active Directory Domain.
If you have questions or suggestions, please comment below.
Going Serverless: Migrating an Express Application to Amazon API Gateway and AWS Lambda

Brett Andrews
Software Development Engineer
Amazon API Gateway recently released three new features that simplify the process of forwarding HTTP requests to your integration endpoint: greedy path variables, the ANY method, and proxy integration types. With this new functionality, it becomes incredibly easy to run HTTP applications in a serverless environment by leveraging the aws-serverless-express library.
In this post, I go through the process of porting an "existing" Node.js Express application onto API Gateway and AWS Lambda, and discuss some of the advantages, disadvantages, and current limitations. While I use Express in this post, the steps are similar for other Node.js frameworks, such as Koa, Hapi, vanilla, etc.
Modifying an existing Express application
Express is commonly used for both web applications as well as REST APIs. While the primary API Gateway function is to deliver APIs, it can certainly be used for delivering web apps/sites (HTML) as well. To cover both use cases, the Express app below exposes a web app on the root / resource, and a REST API on the /pets resource.
The goal of this walkthrough is for it to be complex enough to cover many of the limitations of this approach today (as comments in the code below), yet simple enough to follow along. To this end, you implement just the entry point of the Express application (commonly named app.js) and assume standard implementations of views and controllers (which are more insulated and thus less affected). You also use MongoDB, due to it being a popular choice in the Node.js community as well as providing a time-out edge case. For a greater AWS serverless experience, consider adopting Amazon DynamoDB.
//app.js
'use strict'
const path = require('path')
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const mongoose = require('mongoose')
// const session = require('express-session')
// const compress = require('compression')
// const sass = require('node-sass-middleware')
// Lambda does not allow you to configure environment variables, but dotenv is an
// excellent and simple solution, with the added benefit of allowing you to easily
// manage different environment variables per stage, developer, environment, etc.
require('dotenv').config()
const app = express()
const homeController = require('./controllers/home')
const petsController = require('./controllers/pets')
// MongoDB has a default timeout of 30s, which is the same timeout as API Gateway.
// Because API Gateway was initiated first, it also times out first. Reduce the
// timeout and kill the process so that the next request attempts to connect.
mongoose.connect(process.env.MONGODB_URI, { server: { socketOptions: { connectTimeoutMS: 10000 } } })
mongoose.connection.on('error', () => {
console.error('Error connecting to MongoDB.')
process.exit(1)
})
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'pug')
app.use(cors())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
/*
* GZIP support is currently not available to API Gateway.
app.use(compress())
* node-sass is a native binary/library (aka Addon in Node.js) and thus must be
* compiled in the same environment (operating system) in which it will be run.
* If you absolutely need to use a native library, you can set up an Amazon EC2 instance
* running Amazon Linux for packaging your Lambda function.
* In the case of SASS, I recommend to build your CSS locally instead and
* deploy all static assets to Amazon S3 for improved performance.
const publicPath = path.join(__dirname, 'public')
app.use(sass({ src: publicPath, dest: publicPath, sourceMap: true}))
app.use(express.static(publicPath, { maxAge: 31557600000 }))
* Storing local state is unreliable due to automatic scaling. Consider going stateless (using REST),
* or use an external state store (for MongoDB, you can use the connect-mongo package)
app.use(session({ secret: process.env.SESSION_SECRET }))
*/
app.get('/', homeController.index)
app.get('/pets', petsController.listPets)
app.post('/pets', petsController.createPet)
app.get('/pets/:petId', petsController.getPet)
app.put('/pets/:petId', petsController.updatePet)
app.delete('/pets/:petId', petsController.deletePet)
/*
* aws-serverless-express communicates over a Unix domain socket. While it's not required
* to remove this line, I recommend doing so as it just sits idle.
app.listen(3000)
*/
// Export your Express configuration so that it can be consumed by the Lambda handler
module.exports = app
Assuming that you had the relevant code implemented in your views and controllers directories and a MongoDB server available, you could uncomment the listen line, run node app.js and have an Express application running at http://localhost:3000. The following "changes" made above were specific to API Gateway and Lambda:
- Used dotenv to set environment variables.
- Reduced the timeout for connecting to DynamoDB so that API Gateway does not time out first.
- Removed the compression middleware as API Gateway does not (currently) support GZIP.
- Removed node-sass-middleware (I opted for serving static assets through S3, but if there is a particular native library your application absolutely needs, you can build/package your Lambda function on an EC2 instance).
- Served static assets through S3/CloudFront. Not only is S3 a better option for static assets for performance reasons, API Gateway does not currently support binary data (e.g., images).
- Removed session state for scalability (alternatively, you could have stored session state in MongoDB using connect-mongo.
- Removed
app.listen()as HTTP requests are not being sent over ports (not strictly required). - Exported the Express configuration so that you can consume it in your Lambda handler (more on this soon).
Going serverless with aws-serverless-express
In order for users to be able to hit the app (or for developers to consume the API), you first need to get it online. Because this app is going to be immensely popular, you obviously need to consider scalability, resiliency, and many other factors. Previously, you could provision some servers, launch them in multiple Availability Zones, configure Auto Scaling policies, ensure that the servers were healthy (and replace them if they weren't), keep up-to-date with the latest security updates, and so on…. As a developer, you just care that your users are able to use the product; everything else is a distraction.
Enter serverless. By leveraging AWS services such as API Gateway and Lambda, you have zero servers to manage, automatic scaling out-of-the-box, and true pay-as-you-go: the promise of the cloud, finally delivered.
The example included in the aws-serverless-express library library includes a good starting point for deploying and managing your serverless resources.
- Clone the library into a local directory
git clone https://github.com/awslabs/aws-serverless-express.git - From within the
exampledirectory, runnpm run config <accountId> <bucketName> [region](this modifies some of the files with your own settings). - Edit the
package-functioncommand inpackage.jsonby removing "index.html" and adding "views" and "controllers" (the additional directories required for running your app). -
Copy the following files in the
exampledirectory into your existing project's directory:simple-proxy-api.yaml– A Swagger file that describes your API.cloudformation.json– A CloudFormation template for creating the Lambda function and API.package.json– You may already have a version of this file, in which case just copy thescriptsandconfigsections. This includes some helpful npm scripts for managing your AWS resources, and testing and updating your Lambda function.api-gateway-event.json– Used for testing your Lambda function locally.lambda.js– The Lambda function, a thin wrapper around your Express application.
Take a quick look at
lambda.jsso that you understand exactly what's going on there. Theaws-serverless-express librarytransforms the request from the client (via API Gateway) into a standard Node.js HTTP request object; sends this request to a special listener (a Unix domain socket); and then transforms it back for the response to API Gateway. It also starts your Express server listening on the Unix domain socket on the initial invocation of the Lambda function. Here it is in its entirety:
// lambda.js
'use strict'
const awsServerlessExpress = require('aws-serverless-express')
const app = require('./app')
const server = awsServerlessExpress.createServer(app)
exports.handler = (event, context) => awsServerlessExpress.proxy(server, event, context)
TIP: Everything outside of the handler function is executed only one time per container: that is, the first time your app receives a request (or the first request after several minutes of inactivity), and when it scales up additional containers.
Deploying
Now that you have more of an understanding of how API Gateway and Lambda communicate with your Express server, it's time to release your app to the world.
From the project's directory, run:
npm run setup
This command creates the Amazon S3 bucket specified earlier (if it does not yet exist); zips the necessary files and directories for your Lambda function and uploads it to S3; uploads simple-proxy-api.yaml to S3; creates the CloudFormation stack; and finally opens your browser to the AWS CloudFormation console where you can monitor the creation of your resources. To clean up the AWS resources created by this command, simply run npm run delete-stack. Additionally, if you specified a new S3 bucket, run npm run delete-bucket.
After the status changes to CREATE_COMPLETE (usually after a couple of minutes), you see three links in the Outputs section: one to the API Gateway console, another to the Lambda console, and most importantly one for your web app/REST API. Clicking the link to your API displays the web app; appending /pets in the browser address bar displays your list of pets. Your Express application is available online with automatic scalability and pay-per-request without having to manage a single server!
Additional features
Now that you have your REST API available to your users, take a quick look at some of the additional features made available by API Gateway and Lambda:
- Usage plans for monetizing the API
- Caching to improve performance
- Authorizers for authentication and authorization microservices that determine access to your Express application
- Stages and versioning and aliases when you need additional stages or environments (dev, beta, prod, etc.)
- SDK generation to provide SDKs to consumers of your API (available in JavaScript, iOS, Android Java, and Android Swift)
- API monitoring for logs and insights into usage
After running your Express application in a serverless environment for a while and learning more about the best practices, you may start to want more: more performance, more control, more microservices!
So how do you take your existing serverless Express application (a single Lambda function) and refactor it into microservices? You strangle it. Take a route, move the logic to a new Lambda function, and add a new resource or method to your API Gateway API. However, you'll find that the tools provided to you by the aws-serverless-express example just don't cut it for managing these additional functions. For that, you should check out Claudia; it even has support for aws-serverless-express.
Conclusion
To sum it all up, you took an existing application of moderate complexity, applied some minimal changes, and deployed it in just a couple of commands. You now have no servers to manage, automatic scaling out-of-the-box, true pay-as-you-go, loads of features provided by API Gateway, and as a bonus, a great path forward for refactoring into microservices.
If that's not enough, or this server-side stuff doesn't interest you and the front end is where you live, consider using aws-serverless-express for server-side rendering of your single page application.
If you have questions or suggestions, please comment below.
Easier integration with AWS Lambda and Amazon API Gateway
This week, Amazon API Gateway announced three new features that make it easier for you to leverage API Gateway and AWS Lambda to build your serverless applications.
First, we now support catch-all path variables. You can define routes such as /store/{proxy+}, where the + symbol tells API Gateway to intercept all requests to the /store/* path. Second, we now support a new method type called ANY. You can use the catch-all ANY method to define the same integration behavior for all requests (GET, POST, etc). Third, you can now use a new proxy integration type for Lambda functions and HTTP endpoints. Lambda function proxy integrations apply a default mapping template to send the entire request to your functions, and it automatically maps Lambda output to HTTP responses. HTTP proxy integrations simply pass the entire request and response directly through to your HTTP endpoint.
One way to use these new features is to migrate Express applications to Lambda and API Gateway. Previously, in order to preserve your API routes in Express, you had to redefine each API method and its corresponding Express integration endpoint on API Gateway. Now, you can simply define one catch-all resource in API Gateway and configure it as a proxy integration with a Lambda function that wraps your Express application.
Head on over to Jeff Barr’s blog to read more about these new features!
Techniques and Tools for Better Serverless API Logging with Amazon API Gateway and AWS Lambda

Ryan Green @ryangtweets
Software Development Engineer, API Gateway
Developing, testing, and operating Serverless APIs using Amazon API Gateway and AWS Lambda can be made much easier with built-in support for Amazon CloudWatch Logs.
In Lambda functions, you can use log statements to send log events to CloudWatch log streams, and API Gateway automatically submits log events for requests to APIs with logging enabled.
However, it can be difficult to reconcile log events for a serverless API sent across multiple CloudWatch log groups and log streams. Tracking down logs for a specific request or tailing request logs for that operation can sometimes be a cumbersome experience.
Here are some simple logging techniques and tools that can help greatly while developing, testing, and operating your serverless API.
Technique: Capture the request ID for a particular API request
The request ID for an individual API request is always returned by API Gateway in the " x-amzn-RequestId" response header. When API Gateway logs an individual API request, the request ID is always included in the first event in the request log:
"Starting execution for request: [REQUEST_ID]"
Logs for an API Gateway API are always sent to a log group in the following format:
"API-Gateway-Execution-Logs_[API_ID]/[STAGE_NAME]"
To troubleshoot an individual API request, search for the request ID in the CloudWatch Logs console, or using the Cloudwatch API or an AWS SDK (more on tooling later).
Technique: Correlate your API Gateway request IDs with Lambda request IDs
Individual API requests are tracked independently across AWS services. Thus, an individual request to your API actually generates at least two request identifiers ("request IDs") – one for the API Gateway request and one for the Lambda invocation request.
To reduce time-to-diagnosis when analyzing logs, it is helpful to correlate both request IDs together in the logs. Log the API Gateway request ID from your Lambda function and send the API Gateway request ID ($context.requestId) to your Lambda function via a mapping template:
{
"context" : {
"request-id" : "$context.requestId"
}
…
}
Then, in your Lambda function, log the API Gateway request ID along with the Lambda request ID. The Lambda request ID is automatically included in the log message.
exports.handler = function(event, context) {
var apiRequestId = event.context['request-id'];
var lambdaRequestId = context.awsRequestId;
console.log("API Gateway Request ID: " + apiRequestId + " Lambda Request ID: " + context.awsRequestId);
var logprefix = "APIG: " + apiRequestId + " - ";
console.log(logprefix + "hello world!");
...
}
Invoking this Lambda function produces these log messages in the "/aws/lambda/[FUNCTION_NAME]" log group:
/aws/lambda/echo 2016/09/07/[$LATEST]6ccf17d298b64b5fac8c41b1a65e0831 2016-09-07T21:39:39.145Z 943ad105-7543-11e6-a9ac-65e093327849 API Gateway Request ID: 9439989f-7543-11e6-8dda-150c09a55dc2 Lambda Request ID: 943ad105-7543-11e6-a9ac-65e093327849
/aws/lambda/echo 2016/09/07/[$LATEST]6ccf17d298b64b5fac8c41b1a65e0831 2016-09-07T21:39:39.145Z 943ad105-7543-11e6-a9ac-65e093327849 APIG: 9439989f-7543-11e6-8dda-150c09a55dc2 - hello world!
Using this technique allows you to quickly locate the Lambda function logs for an individual API request. Search for the request ID returned in the "x-amzn-RequestId" header in the log group for the Lambda function (by default, named "/aws/lambda/[FUNCTION_NAME]").
Tooling
While the AWS SDK and CLI provide excellent building blocks for working with API Gateway, Lambda, and CloudWatch Logs, there is still room for specialized tooling for logs when developing, testing, and operating your serverless API.
To that end, I've created apilogs—a fork of the excellent awslogs project—to include native support for API Gateway/Lambda serverless APIs.
Given an API Gateway REST API ID and Stage name, this command-line tool produces an aggregated stream of time-ordered, ANSI-colored log events emitted by API Gateway and all Lambda functions attached to your API. It automatically aggregates events from all log streams for the API and Lambda functions.

For example:
Stream all log events emitted from API Gateway as well as from all Lambda functions attached to the API:
$ apilogs get --api-id xyz123 --stage prod –-watch
Search APIG/Lambda logs for events from a specific request ID in the past hour:
$ apilogs get --api-id xyz123 --stage prod --start='1h ago' | grep "6605b081-6f04-11e6-97ac-c34deb0b3dd9"
The log events can then be further filtered and processed by standard command-line tools. Credentials are passed to apilogs via the same mechanism as the AWS CLI.
Conclusion
I hope apilogs can become part of your standard dev, test, or ops workflows. Check out apilogs on Github. Feedback and contributions are always welcome!
Using Federation with Amazon ECR
This is a guest post from my colleague Asif Khan.
————————-
Federation is a mechanism to connect identity management systems together. A user’s credentials are always stored with the “home” organization (the identity provider). A service provider trusts the identity provider to validate credentials when the user logs into a service. The user never provides credentials directly to anybody but the identity provider.
Many of our customers use federation to manage secure access to systems and user stores and have expressed a requirement to use federation with Amazon ECR, and this post explains how to do that.
Overview of Amazon ECR
With Amazon EC2 Container Registry (Amazon ECR), customers can store their Docker images in highly available repositories managed by AWS. ECR encrypts images at rest using server-side encryption managed by Amazon S3, and also provides integration with AWS Identity and Access Management (IAM) to control who can access a given repository.
Amazon ECR makes it easy to set up repository policies that grant push/pull access to IAM users, roles, or other AWS accounts. In order to push and pull images to an ECR repository using the standard Docker commands, customers must first authenticate with ECR by obtaining an encrypted token to pass in the docker login command. The token is generated for the AWS identity (IAM user, group, role, etc.) that originally requested the token.
However, many customers might already have an identity store outside AWS that they would like to use to authenticate with ECR. With IAM identity federation support, customers can benefit from the standardization on a protocol such as SAML, security control, and an improved user experience. Developers can use their corporate SAML identity provider to log in and use ECR seamlessly. IAM provides an integration with a variety of SAML providers such as Auth0, Bitium and Okta Ping Identity, among others. For a full list, see Integrating Third-Party SAML Solution Providers with AWS.
Customers can also choose to implement federation and control access issuing tokens themselves which can be used with the docker login command.
With IAM federation, we can set up federation to other identity providers listed. After federation is set up, developers or hosts can use AssumeRole, be granted tokens from the customer identity provider (IDP), and switch to a role which has permission to access the ECR repository.

Walkthrough
This post walks you through the process of using federation along with ECR. This walkthrough uses the SAML identity provider Auth0, but you can follow along with any other AWS-supported provider. The steps are:
- Set up federation between an identity provider and IAM.
- Set up a role and configure it for federation.
- Set up user permissions to assume the role.
- Set up ECR permissions to allow access to the repository.
- Use the temporary AWS credentials returned by assumeRole when calling
aws ecr get-loginto obtain the docker auth token. - Push and pull from an ECR repository using the Docker commands.
Set up federation
Follow the steps in the following topic to set up federation against an identity provider of your choice: Integrating Third-Party SAML Solution Providers with AWS. This walkthrough uses Auth0 as an example. Next, register an app on Auth0 or your IDP.
In the IAM console, set up the identity provider on AWS.

Set up a role
Next, create a role with read-only access to assume. Note the role name for later.

Set up your user permissions to assume the new role
To switch to a role using the AWS CLI, follow the steps in Switching to an IAM Role (AWS Command Line Interface) and grant permissions to switch roles to the role that you created in the previous step.
Set up permissions for the ECR repository
With ECR, you can use the permissions tool to restrict access to selected principals. Choose the previously-created role “TestSAML” as the principal in the permissions.


Use temporary AWS credentials
Use the temporary AWS credentials when calling aws ecr get-login to obtain the docker auth token. After you have assumed the role, you can enter the following command:
$ aws ecr get-login --profile testSAML
The results should be as follows:
docker login -u AWS -p -e none https://.dkr.ecr.us-west-2.amazonaws.com
Log in to Docker and push/pull
Using the encrypted credentials returned from the previous command, you can now log in to Docker:
$ docker login -u AWS -p -e none https://.dkr.ecr.us-west-2.amazonaws.com
You can now push or pull to and from Amazon ECR using the following commands:
$ docker push aws_account_id.dkr.ecr.us-east-1.amazonaws.com/my-web-app
$ docker pull aws_account_id.dkr.ecr.us-east-1.amazonaws.com/my-web-app:latest
Conclusion
When you run microservices on Amazon ECS, you can pull images from Amazon ECR securely using federation and your identity provider. Federation includes benefits such as standardization on SAML, enhanced security, and an improved user experience. Use this walkthrough with any other identity provider, and achieve the same results.
If you have questions or suggestions, please comment below.
Migrating a Native JAVA REST API to a Serverless Architecture with the Lambada Framework for AWS
This is a guest post by Çağatay Gürtürk, the creator of the Lambada framework
Serverless computing has become a hot topics since AWS Lambda and Amazon API Gateway started to offer an elegant way to build and deploy REST APIs without needing to maintain 24/7 running servers and infrastructure, with attractive pricing models.
Being the first language offered by Lambda, Node.JS seems to have the most online resources and tools but it is also possible to write Lambda functions natively with Java and Python. Java is especially interesting as a language because of its maturity, large community, and available codebase. With Lambda and Java, it is even possible to apply enterprise patterns and frameworks such as Spring, as well as all the best practices we used to apply in the Java world.
In order to make development for Lambda in Java easier, I started Lambada Framework as an open source project. It is a little, but powerful, open source project in beta stage that lets developers create a new serverless API in AWS infrastructure or migrate an existing one.
Lambada Framework accomplishes this target by implementing the most common JAX-RS annotations and providing a Maven plugin to deploy easily to the AWS cloud. Briefly, JAX-RS is a standard annotation set which can be used to map regular Java methods to HTTP paths and methods. For instance, you can look at the following method:
@GET
@Path("/helloworld/{id}")
public Response indexEndpoint(@PathParam int id) {
return Response.status(200).entity("Hello world: " + id).build();
}
This is a very lean method marked with @GET and @Path annotations, which mean that this method is called when a GET request comes to URLs in "/helloworld/{id}" format, with theid parameter as an argument. Finally, it returns a Response object within this method with a 200 response code and text content. As you can see, these annotations offer a seamless way to define a REST API and map different resources to Java methods.
JAX-RS annotations on their own do not mean so much and they do not have any effect out-of-the-box. To make these annotations work, a JAX-RS implementation framework should be added to the project. This framework would scan all the JAX-RS annotations in the project and create a server and routing table to respond to HTTP requests correctly. While Jersey is one such reference implementation, and the most popular one, there are also other implementations of JAX-RS, such as RESTEasy and Apache CXF. You are free to choose any of them and your controller methods always stay same, thanks to standard annotations.
Lambada Framework is a JAX-RS implementation but different from the others: instead of running a web server, it scans the JAX-RS annotations at build time and populates Lambda functions and the API Gateway definitions using them.
This means that if you already marked your controller methods with JAX-RS annotations and used a framework like Jersey or RestEasy, you can easily switch to serverless architecture with very little modifications in your code. You would have to change only your build mechanism and replace your preferred JAX-RS implementation with Lambada Framework.
In the following example, you see how to deploy a very basic REST API to Lambda.
- First, clone the example project to your local directory:
git clone https://github.com/lambadaframework/lambadaframework-boilerplate
-
This project has a
pom.xmlfile with some configuration options. You must change thedeployment.bucketoption; other changes are up to you. The Lambada Framework creates this bucket in your account if it does not exists and it uses that bucket during your project's lifetime. S3 bucket names are global and must be unique, so you must pick a name which is not taken by any one else. -
Make sure that the default AWS profile installed in your system has administrator privileges, or at least the following IAM policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudformation:*",
"s3:*",
"lambda:*",
"execute-api:*",
"apigateway:*",
"iam:*",
"ec2:DescribeSecurityGroups",
"ec2:DescribeVpcs",
"ec2:DescribeSubnets"
],
"Resource": [
"*"
]
}
]
}
-
Now you are all set. In the root directory of your project, fire the following command:
mvn deploy
Your project compiles to a fat JAR with all its dependencies and is deployed to Lambda. After the JAR file is on the S3 bucket, Lambada scans that for supported JAX-RS annotations in your code and creates the necessary API Gateway endpoints. At the end of the process, the URL of your API is printed on the screen. You can navigate to this URL and explore your API using the AWS Management Console to see which resources and methods are created.
Lambada Framework is under development and support for missing JAX-RS annotations are being added. Follow the Lambada Framework GitHub page for the newest features and feel free to submit any issue or contributions.
Happy serverless computing!
Maintaining a Healthy Email Database with AWS Lambda, Amazon SNS, and Amazon DynamoDB
Carlos Sanchiz |
Mike Deck |
Reputation in the email world is critical to achieve reasonable deliverability rates (the percentage of emails that arrive to inboxes); if you fall under certain levels, your emails end up in the spam folder or rejected by the email servers. To keep these numbers high, you have to constantly improve your email quality, but most importantly, you have to take action when a delivery fails or a recipient doesn't want to receive your email.
Back in 2012, we showed you how to automate the process of handling bounces and complaints with an scheduled task, using Amazon SNS, Amazon SQS, Amazon EC2, and some C# code. We have released many AWS services since then, so this post shows a different approach towards the same goal of a clean and healthy email database.
To set a little bit of context about bounces and complaints processing, I'm reusing some of the previous post:
Amazon SES assigns a unique message ID to each email that you successfully submit to send. When Amazon SES receives a bounce or complaint message from an ISP, we forward the feedback message to you. The format of bounce and complaint messages varies between ISPs, but Amazon SES interprets these messages and, if you choose to set up Amazon SNS topics for them, categorizes them into JSON objects.
Amazon SES will categorize your hard bounces into two types: permanent and transient. A permanent bounce indicates that you should never send to that recipient again. A transient bounce indicates that the recipient's ISP is not accepting messages for that particular recipient at that time and you can retry delivery in the future. The amount of time you should wait before resending to the address that generated the transient bounce depends on the transient bounce type. Certain transient bounces require manual intervention before the message can be delivered (e.g., message too large or content error). If the bounce type is undetermined, you should manually review the bounce and act accordingly.
A complaint indicates the recipient does not want the email that you sent them. When we receive a complaint, we want to remove the recipient addresses from our list.
In this post, we show you how to use AWS Lambda functions to receive SES notifications from the feedback loop from ISPs email servers via Amazon SNS and update an Amazon DynamoDB table with your email database.
Here is a high-level overview of the architecture:

Using the combination of Lambda, SNS and DynamoDB frees you from the operational overhead of having to run servers and maintain them. You focus on your application logic and AWS handles the undifferentiating heavy lifting behind the operations, scalability, and high availability.
Workflow
- Create the SNS topic to receive the SES bounces, deliveries and complaints.
- Create the DynamoDB table to use for our email database.
- Create the Lambda function to process the bounces, deliveries and complaints and subscribe it to the SNS topic
- Test & start emailing!
Create an SNS topic
First, create an SNS topic named "ses-notifications". You subscribe your Lambda function to the topic later.

Create a DynamoDB table
Create a simple DynamoDB table called "mailing" to store the email database. Use the UserId (email address) as the partition key.

Create the Lambda function
Set up your Lambda function that will process all the notifications coming from SES through your SNS topic.
Note: This post uses Node.js 4.3 as the Lambda runtime but at the time of publication, you can also use Python 2.7, Java 8 or Node.js 0.10.

For the Lambda function code, I used the recently published blueprint (ses-notification-nodejs) and adapted it to work with the DynamoDB table. The following code has the modifications highlighted:
'use strict';
console.log('Loading function');
let doc = require('dynamodb-doc');
let dynamo = new doc.DynamoDB();
let tableName = 'mailing';
exports.handler = (event, context, callback) => {
//console.log('Received event:', JSON.stringify(event, null, 2));
const message = JSON.parse(event.Records[0].Sns.Message);
switch(message.notificationType) {
case "Bounce":
handleBounce(message);
break;
case "Complaint":
handleComplaint(message);
break;
case "Delivery":
handleDelivery(message);
break;
default:
callback("Unknown notification type: " + message.notificationType);
}
};
function handleBounce(message) {
const messageId = message.mail.messageId;
const addresses = message.bounce.bouncedRecipients.map(function(recipient){
return recipient.emailAddress;
});
const bounceType = message.bounce.bounceType;
console.log("Message " + messageId + " bounced when sending to " + addresses.join(", ") + ". Bounce type: " + bounceType);
for (var i=0; i<addresses.length; i++){
writeDDB(addresses[i], message, tableName, "disable");
}
}
function handleComplaint(message) {
const messageId = message.mail.messageId;
const addresses = message.complaint.complainedRecipients.map(function(recipient){
return recipient.emailAddress;
});
console.log("A complaint was reported by " + addresses.join(", ") + " for message " + messageId + ".");
for (var i=0; i<addresses.length; i++){
writeDDB(addresses[i], message, tableName, "disable");
}
}
function handleDelivery(message) {
const messageId = message.mail.messageId;
const deliveryTimestamp = message.delivery.timestamp;
const addresses = message.delivery.recipients;
console.log("Message " + messageId + " was delivered successfully at " + deliveryTimestamp + ".");
for (var i=0; i<addresses.length; i++){
writeDDB(addresses[i], message, tableName, "enable");
}
}
function writeDDB(id, payload, tableName, status) {
const item = {
UserId: id,
notificationType: payload.notificationType,
from: payload.mail.source,
timestamp: payload.mail.timestamp,
state: status
};
const params = {
TableName:tableName,
Item: item
};
dynamo.putItem(params,function(err,data){
if (err) console.log(err);
else console.log(data);
});
}
Assign the function a role with execute and DynamoDB permissions so it can run and update the DynamoDB table accordingly and use index.handler as the function Handler.

This is the lambda_dynamo IAM role policy to use for the three functions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Stmt1428341300017",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Effect": "Allow",
"Resource": "arn:aws:dynamodb:us-east-1:ACCOUNT-ID:table/mailing"
},
{
"Sid": "",
"Resource": "*",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Effect": "Allow"
}
]
}
You also set the corresponding SNS topic as an event source so that the Lambda function is triggered when notifications arrive.

Test the Lambda function
After everything is in place, it's time to test. Publish notifications to your SNS topics using Amazon SNS notification examples for Amazon SES, and see how your DynamoDB table is updated by your Lambda functions.
Here's an example of publishing a complaint notification to the ses-complaints-topic SNS topic using the CLI:
$ aws sns publish --topic-arn "arn:aws:sns:us-east-1:xxxxxxxxxxx:ses-notifications" --message file://message_complaints.txt --subject Test --region us-east-1
{
"MessageId": "f7f5ad2d-a268-548d-a45c-e28e7624a64d"
}
$ cat message_complaints.txt
{
"notificationType":"Complaint",
"complaint":{
"userAgent":"Comcast Feedback Loop (V0.01)",
"complainedRecipients":[
{
"emailAddress":"recipient1@example.com"
}
],
"complaintFeedbackType":"abuse",
"arrivalDate":"2009-12-03T04:24:21.000-05:00",
"timestamp":"2012-05-25T14:59:38.623-07:00",
"feedbackId":"000001378603177f-18c07c78-fa81-4a58-9dd1-fedc3cb8f49a-000000"
},
"mail":{
"timestamp":"2012-05-25T14:59:38.623-07:00",
"messageId":"000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000",
"source":"email_1337983178623@amazon.com",
"sourceArn": "arn:aws:sns:us-east-1:XXXXXXXXXXXX:ses-notifications",
"sendingAccountId":"XXXXXXXXXXXX",
"destination":[
"recipient1@example.com",
"recipient2@example.com",
"recipient3@example.com",
"recipient4@example.com"
]
}
}
And here is what you'd start seeing coming in your DynamoDB items list:

After you are done with your tests, you can point your SES notifications to the SNS topic you created and start sending emails.
Conclusion
In this post, we showed how you can use AWS Lambda, Amazon SNS, and Amazon DynamoDB to keep a healthy email database, have a good email sending score, and of course, do all of it without servers to maintain or scale.
While you're at it, why not expose your DynamoDB table with your email database using Amazon API Gateway? For more information, see Using Amazon API Gateway as a proxy for DynamoDB.
If you have questions or suggestions, please comment below.
Authenticating Amazon ECR Repositories for Docker CLI with Credential Helper
This is a guest post from my colleagues Ryosuke Iwanaga and Prahlad Rao.
————————
Developers building and managing microservices and containerized applications using Docker containers require a secure, scalable repository to store and manage Docker images. In order to securely access the repository, proper authentication from the Docker client to the repository is important, but re-authenticating or refreshing authentication token every few hours often can be cumbersome.
This post walks you through a quick overview of Amazon ECR and how deploying Amazon ECR Docker Credential Helper can automate authentication token refresh on Docker push/pull requests.
Overview of Amazon ECS and Amazon ECR
Amazon ECS is a highly scalable, fast container management service that makes it easy to run and manage Docker containers on a cluster of Amazon EC2 instances and eliminates the need to operate your own cluster management or worry about scaling management infrastructure.
In order to reliably store Docker images on AWS, ECR provides a managed Docker registry service that is secure, scalable, and reliable. ECR is a private Docker repository with resource-based permissions using IAM so that users or EC2 instances can access repositories and images through the Docker CLI to push, pull, and manage images.
Manual ECR authentication with the Docker CLI
Most commonly, developers use Docker CLI to push and pull images or automate as part of a CI/CD workflow. Because Docker CLI does not support standard AWS authentication methods, client authentication must be handled so that ECR knows who is requesting to push or pull an image.
This can be done with a docker login command to authenticate to an ECR registry that provides an authorization token valid for 12 hours. One of the reasons for the 12-hour validity and subsequent necessary token refresh is that the Docker credentials are stored in a plain-text file and can be accessed if the system is compromised, which essentially gives access to the images. Authenticating every 12 hours ensures appropriate token rotation to protect against misuse.
If you’re using the AWS CLI, you can use a simpler get-login command which retrieves the token, decodes it, and converts into a docker login command for you. An example for the default registry associated with the account is shown below:
$ aws ecr get-login
docker login –u AWS –p password –e none https://aws_account_id.dkr.ecr.us-east-1.amazonaws.com
To access other account registries, use the -registry-ids <aws_account_id> option.
As you can see, the resulting output is a docker login command that you can use to authenticate your Docker client to your ECR registry. The generated token is valid for 12 hours, which means developers running and managing container images have to re-authenticate every 12 hours manually, or script it to generate a new token, which can be somewhat cumbersome in a CI/CD environment. For example if you’re using Jenkins to build and push docker images to ECR, you have to set up Jenkins instances to re-authenticate using get-login to ECR every 12 hours.
If you want a programmatic approach, you can use GetAuthorizationToken from the AWS SDK to fetch credentials for Docker. GetAuthorizationToken returns an authorization token of a base64-encoded string that can be decoded into username and password with “AWS” as username and temporary token as password.
It’s important to note that when executing docker login commands, the command string can be visible by other users on the system in a process list, e.g., ps –e, meaning other users can view authentication credentials to gain push and pull access to repositories. To avoid this, you can interactively log in by omitting the –p password option and enter password only when prompted. Overall, this may add additional overhead in a continuous development environment where developers need to worry about re-authentication every few hours.
Amazon ECR Docker Credential Helper
This is where Amazon ECR Docker Credential Helper makes it easy for developers to use ECR without the need to use docker login or write logic to refresh tokens and provide transparent access to ECR repositories.
Credential Helper helps developers in a continuous development environment to automate the authentication process to ECR repositories without having to regenerate tokens every 12 hours. In addition, Credential Helper also provides token caching under the hood so you don’t have to worry about getting throttled or writing additional logic.
You can access Credential Helper in the amazon-ecr-credential-helper GitHub repository.
Using Credential Helper on Linux/Mac and Windows
The prerequisites include:
- Docker 1.11 or above installed on your system
- AWS credentials available in one of the standard locations:
- ~/.aws/credentials file
- AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables
- IAM role for Amazon EC2
First, build a binary for your client machine. Although you can do it with your own Go environment, we also provide a way to build it inside a Docker container without installing Go by yourself. To build by container, just type make docker on the root directory of the repository. It will run a container FROM go image and build the binary on the mounted volume. After that, you can see it at /bin/local/docker-credential-ecr-login.
Note: You need to run this with the local Docker engine as the remote Docker Engine can’t mount your local volume.
You can also build the binary cross compiled:
- To build a Mac binary, use
make docker TARGET_GOOS=darwin - To build a Windows binary, use
make docker TARGET_GOOS=windows
With these commands, Go builds the binary for the target OS inside the Linux container.
The last thing you need to do is create a Docker configuration file for the helper. Put the file under ~/.docker/config.json or C:\Users\bob\.docker\config.json with the following content:
{
"credsStore": "ecr-login"
}
Now, you can use the docker command to interact with ECR without docker login. When you type docker push/pull YOUR_ECR_IMAGE_ID, Credential Helper is called and communicates with the ECR endpoint to get the Docker credentials. Because it automatically detects the proper region from the image ID, you don’t have to worry about it.
Using Credential Helper with Jenkins
One of the common customer deployment patterns with ECS and ECR is integrating with existing CI/CD tools like Jenkins. Using Credential Helper, your Docker CI/CD setup with Jenkins is much simpler and more reliable.
To set up ECR as a Docker image repository for Jenkins and configure Credential Helper:
- Ensure that your Jenkins instance has the proper AWS credentials to pull/push with your ECR repository. These can be in the form of environment variables, a shared credential file, or an instance profile.
- Place
docker-credential-ecr-loginbinary at one of directories in $PATH. - Write the Docker configuration file under the home directory of the Jenkins user, for example,
/var/lib/jenkins/.docker/config.json. - Install the Docker Build and Publish plugin and make sure that the
jenkinsuser can contact the Docker daemon.
Then, create a project with a build step, as in the following screenshot:

Now Jenkins can push/pull images to the ECR registry without needing to refresh tokens, just like your previous Docker CLI experience.
Conclusion
The Amazon ECR Docker Credential Helper provides a very efficient way to access ECR repositories. It is transparent so that you no longer need to recall this helper after setup. This tool is hosted on GitHub and we welcome your feedback and pull requests.
If you have any questions or suggestions, please comment below.
Centralized Container Logs with Amazon ECS and Amazon CloudWatch Logs
Containers make it easy to package and share applications but they often run on a shared cluster. So how do you access your application logs for debugging? Fortunately, Docker provides a log driver that lets you send container logs to a central log service, such as Splunk or Amazon CloudWatch Logs.
Centralized logging has multiple benefits: your Amazon EC2 instance’s disk space isn’t being consumed by logs and log services often include additional capabilities that are useful for operations. For example, CloudWatch Logs includes the ability to create metrics filters that can alarm when there are too many errors and integrates with Amazon Elasticsearch Service and Kibana to enable you to perform powerful queries and analysis. This post shows how to configure Amazon ECS and CloudWatch Logs.
Step 1: Create a CloudWatch Log group
Navigate to the CloudWatch console and choose Logs. On the Actions menu, choose Create log group.
Step 2: Create an ECS task definition
The following steps assume you already have an ECS cluster created. If you do not, go through the ECS first run wizard.
A task definition defines the containers you are running and the log driver options. Navigate to the ECS console, choose Task Definitions and Create new Task Definition. Set the task definition Name and choose Add container. Set the container name, image, memory, and cpu values. In the Storage and Logging section, choose the awslogs log driver. Set the awslogs-group with the name you set in step 1. Set the awslogs-region to the region in which your task will run. Set the awslogs-stream-prefix to a custom prefix that will identify the set of logs you are streaming, such as your application’s name.

The awslogs-stream-prefix was recently added to give you the ability to associate a log stream with the ECS task ID and container name. Previously, the log stream was named with the Docker container ID, which made it hard to associate with the task. If there was an error in a log, there was no direct way to find what container was having the problem. Now, the CloudWatch log stream name includes your custom prefix, the container name, and the task ID to make it simple to associate logs with a task’s containers.
Here is a sample task definition JSON of an NGINX server that displays a welcome message:
{
"networkMode": "bridge",
"taskRoleArn": null,
"containerDefinitions": [
{
"memory": 300,
"portMappings": [
{
"hostPort": 80,
"containerPort": 80,
"protocol": "tcp"
}
],
"essential": true,
"entryPoint": [
"sh",
"-c"
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "awslogs-test",
"awslogs-region": "us-west-2",
"awslogs-stream-prefix": "nginx"
}
},
"name": "simple-app",
"image": "httpd:2.4",
"command": [
"/bin/sh -c \"echo 'Congratulations! Your application is now running on a container in Amazon ECS.' > /usr/local/apache2/htdocs/index.html && httpd-foreground\""
],
"cpu": 10
}
],
"family": "cw-logs-example"
}
Step 3: Run the task
In the ECS console, choose Clusters. Select your cluster, then choose the Tasks tab. Choose Run new task and in the Task definition list, select the task definition that you created in step 2. Choose Run Task.
You will see your task in the PENDING state. Select the task to open the detail view. Refresh your task’s detail view until the task gets to the RUNNING state.
Step 4: Generate logs
If you’re using the sample task definition, NGINX will have already sent an initialization message to the log stream. You can also connect with the web server to generate additional log messages.
Step 5: View the log
The task view now includes a link to the log stream. Select the link and navigate to the CloudWatch console. The log stream name includes the prefix that you specified in the task definition, the container name, and the ECS task ID (nginx/simple-app/600e016a-9301-4f81-90b2-6bfd0ad2d975). This makes it easy to find the log stream from the ECS task and find the task from the log stream.


Cleanup
When you are done, you can stop the task in the ECS console and remove the log stream in the CloudWatch console.
Conclusion
We hope you find these improvements useful. You can also use CloudWatch Logs for ECS agent and Docker logs. For more information, see the ECS documentation. If you have suggestions or questions, please comment below.
A Data Sharing Platform Based on AWS Lambda

Julien Lepine
Solutions Architect
As developers, one of our top priorities is to build reliable systems; this is a core pillar of the AWS Well Architected Framework. A common pattern to fulfill this goal is to have an architecture built around loosely coupled components.
Amazon Kinesis Streams offers an excellent answer for this, as the events generated can be consumed independently by multiple consumers and remain available for 1 to 7 days. Building an Amazon Kinesis consumer application is done by leveraging the Amazon Kinesis Client Library (KCL) or native integration with AWS Lambda.
As I was speaking with other developers and customers about their use of Amazon Kinesis, there are a few patterns that came up. This post addresses those common patterns.
Protecting streams
Amazon Kinesis has made the implementation of event buses easy and inexpensive, so that applications can send meaningful information to their surrounding ecosystem. As your applications grow and get more usage within your company, more teams will want to consume the data generated, even probably external parties such as business partners or customers.
When the applications get more usage, some concerns may arise:
- When a new consumer starts (or re-starts after some maintenance), it needs to read a lot of data from the stream (its backlog) in a short amount of time in order to get up to speed
- A customer may start many consumers at the same time, reading a lot of events in parallel or having a high call rate to Amazon Kinesis
- A consumer may have an issue (such as infinite loop, retry error) that causes it to call Amazon Kinesis at an extremely high rate
These cases may lead to a depletion of the resources available in your stream, and that could potentially impact all your consumers.
Managing the increased load can be done by leveraging the scale-out model of Amazon Kinesis through the addition of shards to an existing stream. Each shard adds both input (ingestion) and output (consumption) capacity to your stream:
- 1000 write records and up to 1 megabyte per second for ingesting events
- 5 read transactions and up to 2 megabytes per second for consuming events
Avoiding these scenarios could be done by scaling-out your streams, and provisioning for peak, but that would create inefficiencies and may not even fully protect your consumers from the behavior of others.
What becomes apparent in these cases is the impact that a single failing consumer may have on all other consumers, a symptom described as the “noisy neighbor”, or managing the blast radius of your system. The key point is to limit the impact that a single consumer can have on others.
A solution is to compartmentalize your platform: this method consists of creating multiple streams and then creating groups of consumers that share the same stream. This gives you the possibility to limit the impact a single consumer can have on its neighbors, and potentially to propose a model where some customers have a dedicated stream.

You can build an Amazon Kinesis consumer application (via the KCL or Lambda) that reads a source stream and sends the messages to the “contained” streams that the actual consumers will use.
Transforming streams
Another use case I see from customers is the need to transfer the data in their stream to other services:
- Some applications may have limitations in their ability to receive or process the events
- They may not have connectors to Amazon Kinesis, and only support Amazon SQS
- They may only support a push model, where their APIs need to be called directly when a message arrives
- Some analytics/caching/search may be needed on the events generated
- Data may need to be archived or sent to a data warehouse engine
There are many other cases, but the core need is having the ability to get the data from Amazon Kinesis into other platforms.

The solution for these use cases is to build an Amazon Kinesis consumer application that reads a stream and prepares these messages for other services.
Sharing data with external parties
The final request I have seen is the possibility to process a stream from a different AWS account or region. While you can give access to your resources to an external AWS account through cross-account IAM roles, that feature requires development and is not supported natively by some services. For example, you cannot subscribe a Lambda function to a stream in a different AWS account or region.
The solution is to replicate the Amazon Kinesis stream or the events to another environment (AWS account, region, or service).

This can be done one time through an Amazon Kinesis consumer application that reads a stream and forwards the events to the remote environment.
Solution: A Lambda-based fan-out function
These three major needs have a common solution: the deployment of an Amazon Kinesis consumer application that listens to a stream and is able to send messages to other instances of Amazon Kinesis, services, or environments (AWS accounts or regions).
In the aws-lambda-fanout GitHub repository, you’ll find a Lambda function that specifically supports this scenario. This function is made to forward incoming messages from Amazon Kinesis or DynamoDB Streams.
The architecture of the function is made to be simple and extensible, with one core file fanout.js that loads modules for the different providers. The currently supported providers are as follows:
- Amazon SNS
- Amazon SQS
- Amazon Elasticsearch Service
- Amazon Kinesis Streams
- Amazon Kinesis Firehose
- AWS IoT
- AWS Lambda
- Amazon ElastiCache for Memcached
- Amazon ElastiCache for Redis
The function is built to support multiple inputs:
- Amazon Kinesis streams
- Amazon Kinesis streams containing Amazon Kinesis Producer Library (KPL) records
- DynamoDB Streams records
It relies on Lambda for a fully-managed environment where scaling, logging, and monitoring are automated by the platform. It also supports Lambda functions in a VPC for Amazon ElastiCache.
The configuration is stored in a DynamoDB table, and associates the output configuration with each function. This table has a simple schema:
- sourceArn (Partition Key): The Amazon Resource Name (ARN) of the input Amazon Kinesis stream
- id [String]: The name of the mapping
- type [String]: The destination type
- destination [String]: The ARN or name of the destination
- active [Boolean]: Whether that mapping is active
Depending on the target, some other properties are also stored.

The function can also group records together for services that don’t initially support it, such as Amazon SQS, Amazon SNS, or AWS IoT. Amazon DynamoDB Streams records can also be transformed to plain JSON objects to simplify management in later stages. The function comes with a Bash-based command line Interface to make the deployment and management easier.
As an example, the following lines deploy the function, which registers a mapping from one stream (inputStream) to another (outputStream).
./fanout deploy --function fanout ./fanout register kinesis --function fanout --source-type kinesis --source inputStream --id target1 --destination outputStream --active true ./fanout hook --function fanout --source-type kinesis --source inputStream
Summary
There are many options available for you to forward your events from one service or environment to another. For more information about this topic, see Using AWS Lambda with Amazon Kinesis. Happy eventing!
If you have questions or suggestions, please comment below.

