Skip to content

Testing Self-Managed AD Domain Join

This guide provides a complete checklist for setting up and testing self-managed AD domain join functionality in a lab or development environment.

Prerequisites Checklist

Use this checklist to ensure all components are properly configured before attempting domain join testing.

1. Deploy Domain Controller

  • [ ] Deploy Windows Server instance (2019 or 2022)
  • [ ] Assign a static private IP address (not DHCP)
  • [ ] Configure security group to allow AD ports (see Security Groups)
  • [ ] Verify SSM agent is running and instance appears in Fleet Manager

Static IP Requirement

Domain controllers must have static IPs. If using DHCP initially, configure a static IP before promotion or create a DHCP reservation.

2. Promote Domain Controller

Install AD DS role and promote to domain controller:

# Install AD DS role
Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools

# Promote to domain controller (creates new forest)
Install-ADDSForest `
  -DomainName "sapphy.int" `
  -DomainNetbiosName "SAPPHY" `
  -SafeModeAdministratorPassword (ConvertTo-SecureString "YourSafeModePassword123!" -AsPlainText -Force) `
  -InstallDns `
  -Force

Automatic Reboot

The server will automatically reboot after promotion completes. Wait 5-10 minutes for all AD services to start.

3. Create Service Account

After DC promotion, create a dedicated service account for domain join operations:

# Create service account
New-ADUser -Name "svc-domainjoin" `
  -UserPrincipalName "[email protected]" `
  -SamAccountName "svc-domainjoin" `
  -AccountPassword (ConvertTo-SecureString "ServicePassword123!" -AsPlainText -Force) `
  -Enabled $true `
  -PasswordNeverExpires $true `
  -CannotChangePassword $true

# Grant domain join permissions
# Option 1: Add to Domain Admins (for testing only)
Add-ADGroupMember -Identity "Domain Admins" -Members "svc-domainjoin"

# Option 2: Delegate specific permissions (production)
# Use Active Directory Delegation Wizard to grant "Join a computer to the domain"

Production vs Testing

For testing, adding to Domain Admins is acceptable. For production, use least-privilege delegation with specific "Join computer to domain" rights.

4. Create Organizational Unit (Optional)

Create a dedicated OU for AWS-joined computers:

# Create OU structure
New-ADOrganizationalUnit -Name "Servers" -Path "DC=sapphy,DC=int"
New-ADOrganizationalUnit -Name "AWS" -Path "OU=Servers,DC=sapphy,DC=int"

# Verify OU creation
Get-ADOrganizationalUnit -Filter 'Name -like "AWS"' | Select-Object DistinguishedName

Expected Output:

OU=AWS,OU=Servers,DC=sapphy,DC=int

Delegate OU Permissions (Production)

# Grant service account permission to create computer objects in the OU
# This must be done via GUI: Active Directory Users and Computers
# 1. Right-click OU → Delegate Control
# 2. Add "svc-domainjoin"
# 3. Select "Create, delete, and manage user accounts" and "Create, delete, and manage computer objects"

5. Create Secret in AWS Secrets Manager

Create a secret containing the service account credentials:

Via AWS Console: 1. Navigate to Secrets ManagerStore a new secret 2. Select Other type of secret 3. Key/value pairs: - Key: username, Value: SAPPHY\svc-domainjoin - Key: password, Value: ServicePassword123! 4. Name: sapphy/domainjoin 5. Note the ARN (needed for Terraform)

Via AWS CLI:

aws secretsmanager create-secret \
  --name "sapphy/domainjoin" \
  --description "Domain join credentials for self-managed AD" \
  --secret-string '{"username":"SAPPHY\\svc-domainjoin","password":"ServicePassword123!"}' \
  --region us-east-2

Username Format

Use DOMAIN\username format with escaped backslash in JSON: SAPPHY\\svc-domainjoin

6. Configure DNS Resolution

Instances must be able to resolve the domain name to join it. Choose one of these options:

Option A: DHCP Option Sets (Simpler)

# In your VPC configuration
resource "aws_vpc_dhcp_options" "domain_dns" {
  domain_name          = "sapphy.int"
  domain_name_servers  = ["10.248.17.4"]  # DC private IP
  tags = {
    Name = "Domain-DNS-Options"
  }
}

resource "aws_vpc_dhcp_options_association" "dns_association" {
  vpc_id          = aws_vpc.main.id
  dhcp_options_id = aws_vpc_dhcp_options.domain_dns.id
}

Option B: Route 53 Resolver Rules

# Create resolver endpoint and forwarding rule
# (More steps - see AWS documentation)

7. Configure IAM Policies and Roles

Create instance profile with required permissions:

# IAM policy for SSM and Secrets Manager
resource "aws_iam_policy" "domain_join" {
  name        = "DomainJoinPolicy"
  description = "Allows EC2 instances to join self-managed AD"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue"
        ]
        Resource = "arn:aws:secretsmanager:us-east-2:271851283454:secret:sapphy/domainjoin-*"
      }
    ]
  })
}

# IAM role for EC2 instances
resource "aws_iam_role" "domain_member" {
  name = "DomainMemberRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
    }]
  })
}

# Attach managed SSM policy
resource "aws_iam_role_policy_attachment" "ssm_managed" {
  role       = aws_iam_role.domain_member.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# Attach custom domain join policy
resource "aws_iam_role_policy_attachment" "domain_join" {
  role       = aws_iam_role.domain_member.name
  policy_arn = aws_iam_policy.domain_join.arn
}

# Create instance profile
resource "aws_iam_instance_profile" "domain_member" {
  name = "DomainMemberInstanceProfile"
  role = aws_iam_role.domain_member.name
}

8. Configure Security Groups {#security-groups}

Domain Controller Security Group

security_groups = {
  DomainControllerSG = {
    ingress = {
      DNS_TCP = {
        from_port   = 53
        to_port     = 53
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"  # Adjust to your VPC CIDR
      }
      DNS_UDP = {
        from_port   = 53
        to_port     = 53
        ip_protocol = "udp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      Kerberos_TCP = {
        from_port   = 88
        to_port     = 88
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      Kerberos_UDP = {
        from_port   = 88
        to_port     = 88
        ip_protocol = "udp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      RPC = {
        from_port   = 135
        to_port     = 135
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      LDAP_TCP = {
        from_port   = 389
        to_port     = 389
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      LDAP_UDP = {
        from_port   = 389
        to_port     = 389
        ip_protocol = "udp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      SMB = {
        from_port   = 445
        to_port     = 445
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      LDAPS = {
        from_port   = 636
        to_port     = 636
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      GlobalCatalog = {
        from_port   = 3268
        to_port     = 3269
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      RPC_Dynamic = {
        from_port   = 49152
        to_port     = 65535
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/8"
      }
      RDP = {
        from_port   = 3389
        to_port     = 3389
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/0"  # Restrict to your IP
      }
    }
    egress = {
      Allow_All = {
        ip_protocol = "-1"
        cidr_ipv4   = "0.0.0.0/0"
      }
    }
  }
}

Target Instance Security Group

security_groups = {
  DomainMemberSG = {
    ingress = {
      RDP = {
        from_port   = 3389
        to_port     = 3389
        ip_protocol = "tcp"
        cidr_ipv4   = "10.0.0.0/0"  # Restrict to your IP
      }
    }
    egress = {
      Allow_All = {
        ip_protocol = "-1"
        cidr_ipv4   = "0.0.0.0/0"
      }
    }
  }
}

9. SSM Prerequisites

Ensure target instances can communicate with SSM:

Option A: VPC Endpoints (for private subnets without NAT)

endpoints = {
  ssm = {
    service_name      = "com.amazonaws.us-east-2.ssm"
    vpc_endpoint_type = "Interface"
    subnets           = ["PrivateSubnet1", "PrivateSubnet2"]
    security_groups   = ["EndpointSG"]
  }
  ssmmessages = {
    service_name      = "com.amazonaws.us-east-2.ssmmessages"
    vpc_endpoint_type = "Interface"
    subnets           = ["PrivateSubnet1", "PrivateSubnet2"]
    security_groups   = ["EndpointSG"]
  }
  ec2messages = {
    service_name      = "com.amazonaws.us-east-2.ec2messages"
    vpc_endpoint_type = "Interface"
    subnets           = ["PrivateSubnet1", "PrivateSubnet2"]
    security_groups   = ["EndpointSG"]
  }
}

Option B: NAT Gateway/Internet Gateway (simpler for testing) - Ensure subnet route table has route to NAT Gateway or IGW - Instance can reach SSM endpoints via internet

10. Reference Secret in Terraform Variables

Update your tfvars file:

# Reference the secret
secretsmanager_secrets = {
  domain_join_creds = {
    arn = "arn:aws:secretsmanager:us-east-2:271851283454:secret:sapphy/domainjoin-Z9EFWO"
  }
}

# Define domain join document
domain_join = {
  SelfManaged_AD_Domain_Join = {
    ad_type = "self-managed"
    content = {
      mainSteps = [{
        inputs = {
          directoryName      = "sapphy.int"
          dnsIpAddresses     = ["10.248.17.4"]  # DC private IP
          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 Testing"
    }
  }
}

11. Deploy Target Instance

Configure an instance to be domain-joined:

instances = {
  test = {
    names                = ["TestServer01"]
    instance_type        = "t3.medium"
    iam_instance_profile = "EC2InstanceProfile"
    key_pair             = "gianniIRE"
    domain_join          = "SelfManaged_AD_Domain_Join"
    nics = {
      primary = {
        subnet          = "SharedInfra.SharedInfraPrivateAZ1"
        security_groups = ["DomainMemberSG"]
      }
    }
  }
}

Deploy with Terraform:

terraform -chdir=src apply -var-file="../environments/nonprod/gianni_ire.tfvars" --auto-approve

12. Monitor Domain Join Process

Check SSM Command History

  1. Navigate to AWS Systems ManagerRun Command
  2. Find the association execution for your instance
  3. Click on the command ID to view output
  4. Check for PowerShell errors in the output

View CloudWatch Logs

SSM agent logs to CloudWatch (if configured): - Log group: /aws/ssm/instance-id - Look for domain join execution output

Common Success Indicators

In SSM output:

Successfully joined domain 'sapphy.int'
Restarting computer...

After reboot (RDP to instance):

# Verify domain membership
(Get-WmiObject -Class Win32_ComputerSystem).Domain
# Expected: sapphy.int

# Check computer account
whoami
# Expected: SAPPHY\TESTSERVER01$

13. Verify in Active Directory

On the domain controller:

# Check if computer account exists
Get-ADComputer -Filter 'Name -like "TestServer*"' | Select-Object Name, DistinguishedName

# Verify OU placement (if specified)
Get-ADComputer -Filter 'Name -like "TestServer*"' | Select-Object DistinguishedName
# Expected: CN=TestServer01,OU=AWS,OU=Servers,DC=sapphy,DC=int

Common Issues and Solutions

Domain Join Fails - "The specified domain either does not exist or could not be contacted"

Cause: DNS resolution failure

Solutions: 1. Verify DC private IP in dnsIpAddresses 2. Check DHCP option set or Route 53 resolver rules 3. Test DNS from instance:

nslookup sapphy.int 10.248.17.4
Resolve-DnsName sapphy.int -Server 10.248.17.4

Domain Join Fails - "Access is denied"

Cause: Invalid credentials or insufficient permissions

Solutions: 1. Verify secret contains correct username/password 2. Check username format: DOMAIN\username (with escaped backslash in JSON) 3. Ensure service account can join computers to domain 4. Check computer account quota (default 10 per user)

SSM Document Not Executing

Cause: SSM connectivity issues

Solutions: 1. Verify instance appears in Fleet Manager 2. Check IAM instance profile has AmazonSSMManagedInstanceCore 3. Ensure VPC endpoints or NAT Gateway configured 4. Check security groups allow HTTPS outbound

Computer Not in Specified OU

Cause: OU doesn't exist or service account lacks permissions

Solutions: 1. Verify OU Distinguished Name is correct 2. Ensure OU exists before domain join 3. Grant service account "Create Computer Objects" in target OU 4. Test without OU first, then add OU parameter

Testing Workflow Summary

graph TD
    A[Deploy DC Instance] --> B[Promote to DC]
    B --> C[Create Service Account]
    C --> D[Create OU Optional]
    D --> E[Create AWS Secret]
    E --> F[Configure DNS]
    F --> G[Configure IAM]
    G --> H[Configure Security Groups]
    H --> I[Deploy Target Instance]
    I --> J[Monitor SSM Execution]
    J --> K[Verify Domain Membership]
    K --> L{Success?}
    L -->|Yes| M[Complete]
    L -->|No| N[Troubleshoot]
    N --> J

Cleanup

To remove test resources:

# Destroy target instance
terraform -chdir=src destroy -target=module.instance.aws_instance.instance[\"TestServer01\"] -var-file="../environments/nonprod/gianni_ire.tfvars"

# Manually delete secret (with recovery window)
aws secretsmanager delete-secret --secret-id sapphy/domainjoin --recovery-window-in-days 7

# Destroy DC (if no longer needed)
terraform -chdir=src destroy -target=module.instance.aws_instance.instance[\"DomainController01\"] -var-file="../environments/nonprod/gianni_ire.tfvars"

Next Steps

After successful testing, consider: - Implementing credential rotation in Secrets Manager - Creating separate service accounts per environment - Documenting customer-specific OU structure requirements - Setting up CloudWatch alarms for failed domain joins - Creating runbooks for troubleshooting common issues