Skip to content

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

  1. Network Connectivity - VPN or Direct Connect from VPC to AD domain controllers
  2. DNS Resolution - Route 53 Resolver rules or custom DNS pointing to domain controllers
  3. Security Groups - Allow AD ports (389, 636, 88, 445, 135, 3268-3269, 49152-65535)
  4. IAM Permissions - Instance profile with SSM and Secrets Manager access
  5. 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):

{
  "username": "DOMAIN\\svc-domainjoin",
  "password": "YourSecurePassword123!"
}

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

1
2
3
4
5
secretsmanager_secrets = {
  domain_join_creds = {
    arn = "arn:aws:secretsmanager:us-east-2:271851283454:secret:sapphy/domainjoin-Z9EFWO"
  }
}

Step 3: Define the Domain Join Document

domain_join = {
  SelfManaged_AD_Domain_Join = {
    ad_type = "self-managed"
    content = {
      mainSteps = [{
        inputs = {
          directoryName      = "sapphy.int"
          dnsIpAddresses     = ["10.248.17.4", "10.248.17.5"]
          secretArn          = "arn:aws:secretsmanager:us-east-2:271851283454:secret:sapphy/domainjoin-Z9EFWO"
          organizationalUnit = "OU=AWS,OU=Servers,DC=sapphy,DC=int"  # Optional
        }
      }]
    }
    tags = {
      Environment = "nonprod"
      Purpose     = "Self-Managed AD Domain Join"
    }
  }
}

Step 4: Associate with EC2 Instances

instances = {
  app = {
    names               = ["AppServer01"]
    instance_type       = "t3.medium"
    iam_instance_profile = "EC2InstanceProfile"
    key_pair            = "gianniIRE"               # example key
    domain_join         = "SelfManaged_AD_Domain_Join"
    nics = {
      primary = {
        subnet          = "SharedInfra.SharedInfraPrivateAZ1"
        security_groups = ["DomainMemberSG"]
      }
    }
  }
}

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:

  1. Store credentials once in Secrets Manager (managed by customer security team)
  2. Grant Terraform execution role access to read the secret via IAM policy
  3. Allow vendor/partner to run Terraform without ever seeing the actual credentials
  4. 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):

OU=<OrgUnit>,OU=<ParentOU>,DC=<domain>,DC=<tld>

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 → PropertiesAttribute Editor → Find distinguishedName attribute

How It Works

The module generates a PowerShell-based SSM document that performs the following:

  1. Import AWS PowerShell Module - Loads AWS cmdlets for Secrets Manager access
  2. Retrieve Credentials - Fetches username/password from Secrets Manager
  3. Configure DNS - Sets network adapter DNS to point to domain controllers
  4. Join Domain - Executes Add-Computer with optional OU placement
  5. 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 MachineAccountQuota may limit non-delegated accounts to 10 computer joins; delegated/privileged accounts bypass this limit.

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 AmazonSSMManagedInstanceCore policy
  • 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:

  1. AWS ConsoleSystems ManagerRun Command
  2. Locate the command execution for your instance
  3. Click View Output to see PowerShell execution logs
  4. Look for specific error messages from Add-Computer cmdlet