Self-Managed AD Domain Join
Join EC2 instances to customer-owned Active Directory domains running on-premises or in AWS using PowerShell-based domain join with credentials from AWS Secrets Manager.
Use Cases
- Joining instances to existing on-premises Active Directory
- Connecting to AD in a shared services VPC/account
- Simulating customer environments for testing
- Multi-account architectures where AD is centralized
Prerequisites
- Network Connectivity - VPN or Direct Connect from VPC to AD domain controllers
- DNS Resolution - Route 53 Resolver rules or custom DNS pointing to domain controllers
- Security Groups - Allow AD ports (389, 636, 88, 445, 135, 3268-3269, 49152-65535)
- IAM Permissions - Instance profile with SSM and Secrets Manager access
- Secrets Manager Secret - Domain join credentials stored in AWS Secrets Manager
Configuration
Step 1: Create Secrets Manager Secret
Before deploying, manually create a secret in AWS Secrets Manager with domain credentials:
Secret Name: sapphy/domainjoin (or any name you prefer)
Secret Value (JSON):
JSON Escaping Note
In JSON, backslashes must be escaped with a second backslash. The JSON value DOMAIN\\svc-domainjoin represents the actual username DOMAIN\svc-domainjoin that the PowerShell script will use at runtime.
Manual Step Required
You must create this secret before running Terraform. The secret ARN will be referenced in your Terraform configuration.
Step 2: Reference the Secret in Terraform
Step 3: Define the Domain Join Document
Step 4: Associate with EC2 Instances
Security Benefits: Credential Delegation Without Exposure
One of the primary advantages of using AWS Secrets Manager for domain join credentials is the ability to delegate automation capabilities to vendors, partners, or teams without exposing sensitive credentials.
The Challenge
Organizations often need external vendors or partner teams to deploy and manage infrastructure, but have strict security policies that prevent sharing domain credentials directly. Common scenarios include:
- Vendor-managed application deployments requiring domain-joined servers
- Partner teams with limited AWS console access but Terraform automation responsibilities
- Managed service providers needing to automate infrastructure without credential access
The Solution
By storing domain join credentials in AWS Secrets Manager and using IAM policies, customers can:
- Store credentials once in Secrets Manager (managed by customer security team)
- Grant Terraform execution role access to read the secret via IAM policy
- Allow vendor/partner to run Terraform without ever seeing the actual credentials
- Maintain audit trail via CloudTrail of when credentials were accessed (but not the values)
Real-World Example
Scenario: Customer with strict vendor security policies needs partner team to automate domain-joined EC2 deployments
Customer's Security Posture:
- Vendor/partner AWS console access is highly restricted (read-only on most services)
- Vendor cannot view or manage Secrets Manager secrets in the console
- Domain credentials must never be exposed to vendor personnel
Implementation:
# Customer creates secret (vendor never sees this)
# Secret ARN: arn:aws:secretsmanager:us-east-2:123456789:secret:domain/join-ABC123
# Customer grants Terraform execution role access
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-2:123456789:secret:domain/join-*"
}
# Vendor writes Terraform referencing the secret ARN
domain_join = {
CustomerDomain = {
ad_type = "self-managed"
content = {
mainSteps = [{
inputs = {
directoryName = "customer.local"
dnsIpAddresses = ["10.0.1.10"]
secretArn = "arn:aws:secretsmanager:us-east-2:123456789:secret:domain/join-ABC123"
}
}]
}
}
}
Outcome:
- Vendor successfully automates domain join via Terraform
- Actual credentials never exposed to vendor team
- Customer maintains full control over credential lifecycle
- CloudTrail provides audit trail of secret access
- Customer can rotate credentials without vendor involvement
Parameters
Required Parameters
| Parameter | Type | Description | Example |
|---|---|---|---|
directoryName |
String | FQDN of the Active Directory domain | "contoso.com" |
dnsIpAddresses |
List | IP addresses of domain controllers | ["10.0.1.10", "10.0.1.11"] |
secretArn |
String | ARN of Secrets Manager secret with credentials | "arn:aws:secretsmanager:..." |
Optional Parameters
| Parameter | Type | Description | Example |
|---|---|---|---|
organizationalUnit |
String | Distinguished Name of target OU | "OU=Servers,DC=contoso,DC=com" |
Organizational Unit (OU) Placement
By default, computer accounts are created in the default CN=Computers container. For production environments, it's recommended to specify a custom OU for:
- Group Policy Application - OUs allow GPO targeting (Computers container doesn't)
- Delegation - Fine-grained permissions for specific teams
- Organization - Logical separation by environment, application, or function
OU Distinguished Name Format
The organizationalUnit parameter requires a full LDAP Distinguished Name (DN):
Examples:
# Root-level OU
organizationalUnit = "OU=Servers,DC=sapphy,DC=int"
# Nested OU (Web servers in Servers OU)
organizationalUnit = "OU=Web,OU=Servers,DC=sapphy,DC=int"
# Environment-based nesting
organizationalUnit = "OU=Production,OU=AWS,OU=Servers,DC=sapphy,DC=int"
Finding the OU DN
In Active Directory Users and Computers, right-click the OU → Properties → Attribute Editor → Find distinguishedName attribute
How It Works
The module generates a PowerShell-based SSM document that performs the following:
- Import AWS PowerShell Module - Loads AWS cmdlets for Secrets Manager access
- Retrieve Credentials - Fetches username/password from Secrets Manager
- Configure DNS - Sets network adapter DNS to point to domain controllers
- Join Domain - Executes
Add-Computerwith optional OU placement - Reboot - Automatically reboots to complete domain join
Generated PowerShell Script
$ErrorActionPreference = 'Stop'
# Import AWS PowerShell module
Import-Module AWSPowerShell
# Get credentials from Secrets Manager
$secretArn = 'arn:aws:secretsmanager:...'
$secretValue = Get-SECSecretValue -SecretId $secretArn
$secret = $secretValue.SecretString | ConvertFrom-Json
$username = $secret.username
$password = $secret.password | ConvertTo-SecureString -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
# Configure DNS
$dnsServers = @('10.248.17.4','10.248.17.5')
$adapter = Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1
Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -ServerAddresses $dnsServers
# Join domain
$domainName = 'sapphy.int'
$ou = 'OU=AWS,OU=Servers,DC=sapphy,DC=int'
if ($ou) {
Add-Computer -DomainName $domainName -Credential $credential -OUPath $ou -Force -Restart
} else {
Add-Computer -DomainName $domainName -Credential $credential -Force -Restart
}
Required IAM Permissions
The instance IAM role must have these permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:UpdateInstanceInformation",
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-2:271851283454:secret:sapphy/domainjoin-*"
}
]
}
Network Requirements
Required AD Ports
| Port | Protocol | Purpose |
|---|---|---|
| 53 | TCP/UDP | DNS |
| 88 | TCP/UDP | Kerberos authentication |
| 135 | TCP | RPC |
| 389 | TCP/UDP | LDAP |
| 445 | TCP | SMB/CIFS |
| 636 | TCP | LDAPS (if using) |
| 3268-3269 | TCP | Global Catalog |
| 49152-65535 | TCP | RPC dynamic ports |
Security Group Example
security_groups = {
DomainMemberSG = {
egress = {
AD_DNS = {
from_port = 53
to_port = 53
ip_protocol = "udp"
cidr_ipv4 = "10.0.0.0/8"
}
AD_Kerberos = {
from_port = 88
to_port = 88
ip_protocol = "tcp"
cidr_ipv4 = "10.0.0.0/8"
}
AD_LDAP = {
from_port = 389
to_port = 389
ip_protocol = "tcp"
cidr_ipv4 = "10.0.0.0/8"
}
AD_SMB = {
from_port = 445
to_port = 445
ip_protocol = "tcp"
cidr_ipv4 = "10.0.0.0/8"
}
AD_RPC = {
from_port = 135
to_port = 135
ip_protocol = "tcp"
cidr_ipv4 = "10.0.0.0/8"
}
AD_RPC_Dynamic = {
from_port = 49152
to_port = 65535
ip_protocol = "tcp"
cidr_ipv4 = "10.0.0.0/8"
}
}
}
}
Troubleshooting
DNS Resolution Failures
Symptom: Instance cannot resolve domain name
Solutions:
Option 1: VPC DHCP Option Sets (simpler for spoke VPCs)
- Configure DHCP option sets in spoke VPCs to point directly to domain controllers
- Example: Set domain-name-servers to 10.248.17.4, 10.248.17.5
- This works when DCs are in a shared services account/VPC with network connectivity (VPN, TGW, peering)
- Instances automatically receive DC IPs as DNS servers via DHCP
- No additional Route 53 infrastructure required
Option 2: Route 53 Resolver Rules (centralized DNS forwarding) - Create Route 53 Resolver outbound endpoints in the VPC - Configure forwarding rules to send domain queries to DCs - Share resolver rules across accounts via RAM (Resource Access Manager) - More complex but provides centralized DNS management and conditional forwarding - Note: Many customers already have this infrastructure in place via AWS Landing Zone Accelerator (LZA) or custom multi-account DNS solutions. Check with the customer if resolver rules are already configured in their shared services or network account before creating new ones.
General Checks:
- Verify that DNS IP addresses are correct and reachable
- Ensure VPN/Direct Connect is established
- Test DNS from instance:
nslookup sapphy.int 10.248.17.4
Domain Join Fails - Access Denied
Symptom: Error "Access is denied" during domain join
Solutions:
- Verify secret contains correct domain credentials
- Ensure username format in the JSON secret is
DOMAIN\\username(two backslashes in JSON represent one backslash at runtime). Alternatively, UPN format[email protected]may work but NetBIOS format is recommended and tested. - Confirm service account has rights to join computers to domain
- Check if computer account quota is reached:
- AD
MachineAccountQuotamay limit non-delegated accounts to 10 computer joins; delegated/privileged accounts bypass this limit.
- AD
Computer Account Not in Specified OU
Symptom: Computer appears in default Computers container instead of target OU
Solutions:
- Verify OU Distinguished Name is correct (check for typos)
- Ensure service account has permission to create objects in target OU
- Check AD delegations - account needs "Create Computer Objects" in OU
- OU must exist before domain join executes
SSM Document Not Executing
Symptom: Instance never attempts domain join
Solutions:
- Verify SSM agent is running and instance appears in Fleet Manager
- Check IAM instance profile has
AmazonSSMManagedInstanceCorepolicy - Ensure VPC has SSM VPC endpoints OR instances have internet access
- Review CloudWatch Logs for SSM agent errors
Viewing Execution Results
To see detailed domain join results:
- AWS Console → Systems Manager → Run Command
- Locate the command execution for your instance
- Click View Output to see PowerShell execution logs
- Look for specific error messages from
Add-Computercmdlet