Blog Post

Microsoft Security Blog
10 MIN READ

Automating Active Directory Domain Join in Azure

MeghnaSaxena's avatar
MeghnaSaxena
Icon for Microsoft rankMicrosoft
Jan 31, 2025

The journey to Azure is an exciting milestone for any organization, and our customer is no exception. With Microsoft assisting in migrating all application servers to the Azure IaaS platform, the goal was clear: make the migration seamless, error-free, efficient, and fast. While the customer had already laid some groundwork with Bicep scripts, we took it a step further—refactoring and enhancing these scripts to not only streamline the current process but also create a robust, scalable framework for their future in Azure.

In this blog, we’ll dive into one of the critical pieces of this automation puzzle: Active Directory Domain Join. We'll explore how we crafted a PowerShell script to automate this essential step, ensuring that every migrated server is seamlessly integrated into the Azure environment. Let’s get started!

Step 1:

List all the tasks or functionalities we want to achieve AD domain Join process in this script:

  1. Verify Local Administrative Rights: Ensure the current user has local admin rights required for installation and configuration.
  2. Check for Active Directory PowerShell Module:
    • Confirm if the module is already installed.
    • If not, install the module.
  3. Check Domain Join Status: Determine the current domain join status of the server.
  4. Validate Active Directory Ports Availability: Ensure necessary AD ports are open and accessible.
  5. Verify Domain Controller (DC) Availability: Confirm the availability of a domain controller.
  6. Test Network Connectivity: Check connectivity between the server and the domain controller.
  7. Retrieve Domain Admin Credentials: Securely prompt and retrieve credentials for a domain administrator account.
  8. Perform AD Join: Execute the Active Directory domain join operation.
  9. Create Log Files: Capture progress and errors in detailed log files for troubleshooting.
  10. Update Event Logs: Record key milestones in the Windows Event Log for auditing and monitoring.
Step 2:

In PowerShell scripting, functions play a crucial role in creating efficient, modular, and reusable code. By making scripts flexible and customizable, functions help streamline processes within a global scope.

To simplify the AD domain-join process, I grouped related tasks into functions that achieve specific functionalities. For instance, tasks like checking the server's domain join status (point 3) and validating AD ports (point 4) can be combined into a single function, VM-Checks, as they both focus on verifying the local server's readiness. Similarly, we can define other functions such as AD-RSAT-Module, DC-Discovery, Check-DC-Connectivity, and Application-Log.

For better organization, we’ll divide all functions into two categories:

  1. Operation Functions: Functions responsible for executing the domain join process.
  2. Logging Functions: Functions dedicated to robust logging for progress tracking and error handling.

Let’s start by building the operation functions.

Step 1:

Define the variables that we will be using in this script, like:

$DomainName $SrvUsernameSecretName, $SrvPasswordSecretName $Creds . . .
Step 2:

We need a function to validate if the current user has local administrative rights, ensuring the script can perform privileged operations seamlessly.

function Check-AdminRights { # Check if the current user is a member of the local Administrators group $isAdmin = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() $isAdminRole = [Security.Principal.WindowsBuiltInRole]::Administrator if ($isAdmin.IsInRole($isAdminRole)) { Add-Content $progressLogFile "The current user has administrative privileges." return $true } else { $errorMessage = "Exiting script due to lack of administrative privileges." Add-Content $progressLogFile $errorMessage Write-ErrorLog $errorMessage Log-Failure -functionName "Check-AdminRights" -message $errorMessage exit } }
Step 3:

Validate the status of Active Directory Module. If it's not installed already, install using the below logic:

. . if (Get-Module -ListAvailable -Name ActiveDirectory) { Add-Content $progressLogFile "Active Directory Module is already installed." Log-Success -functionName "InstallADModule" -message "Active Directory Module is already installed." } else { Add-Content $progressLogFile "Active Directory Module not found. Initializing installation." Add-WindowsFeature RSAT-AD-PowerShell -ErrorAction Stop Install-WindowsFeature RSAT-AD-PowerShell -ErrorAction Stop Import-Module ActiveDirectory -ErrorAction Stop Add-Content $progressLogFile "Active Directory Module imported successfully." Log-Success -functionName "InstallADModule" -message "Active Directory Module imported successfully." } . .
Step 4:

Next, we need to perform multiple checks on the local server, and if desired can be clubbed into a function.

  • Check the current domain-join status: If the server is already joined to a domain, there's no need to join. So, use the below logic to exit the script
    . . $computerSystem = Get-WmiObject Win32_ComputerSystem if ($computerSystem.PartOfDomain) { Add-Content $progressLogFile "This machine is already joined to : $($computerSystem.Domain)." Log-Success -functionName "VM-Checks" -message "Machine is already joined to : $($computerSystem.Domain)." exit 0 } else { Add-Content $progressLogFile "This machine is part of the workgroup: $($computerSystem.Workgroup)." } . .
  • Check the Active directory ports availability: 
    Define parameters with the list of all ports that needs to be available for domain-join :
    param ( $ports = @( @{Port = 88; Protocol = "TCP"}, @{Port = 389; Protocol = "TCP"}, @{Port = 445; Protocol = "TCP"} ) )

Once you have the parameters defined, check the status of each port using the below sample code.

. . foreach ($port in $ports) { try { $checkPort = Test-NetConnection -ComputerName $DomainController -Port $port.Port if ($checkPort.TcpTestSucceeded) { Add-Content $progressLogFile "Port $($port.Port) ($($port.Protocol)) is open." } else { throw "Port $($port.Port) ($($port.Protocol)) is closed." } } catch { $errorMessage = "$($_.Exception.Message) Please check firewall settings." Write-ErrorLog $errorMessage Log-Failure -functionName "VM-Checks" -message $errorMessage exit } } . .
Step 5:

Now, we need to find an available domain controller in the domain, to process the domain join request.

. . try { $domainController = (Get-ADDomainController -DomainName $DomainName -Discover -ErrorAction Stop).HostName Add-Content $progressLogFile "Discovered domain controller: $domainController" Log-Success -functionName "Dc-Discovery" -message "Discovered domain controller $domainController." } catch { $errorMessage = "Failed to discover domain controller for $DomainName." Write-ErrorLog $errorMessage Log-Failure -functionName "Dc-Discovery" -message $errorMessage exit } . .
Step 6:

We need to perform connectivity and name resolution checks between the local server and the previously identified domain controller.

  • For Network connectivity check, you can use this logic:
    if (Test-Connection -ComputerName $DomainController -Count 2 -Quiet) { Write-Host "Domain Controller $DomainController is reachable." -ForegroundColor Green Add-Content $progressLogFile "Domain Controller $DomainController is reachable." } else { $errorMessage = "Domain Controller $DomainController is not reachable." Write-ErrorLog $errorMessage exit }
  • For DNS check, you can use the below logic:
    try { Resolve-DnsName -Name $DomainController -ErrorAction Stop Write-Host "DNS resolution for $DomainController successful." -ForegroundColor Green Add-Content $progressLogFile "DNS resolution for $DomainController successful." } catch { $errorMessage = "DNS resolution for $DomainController failed." Write-Host $errorMessage -ForegroundColor Red Write-ErrorLog $errorMessage Log-Failure -functionName "Dc-ConnectivityCheck" -message $errorMessage exit }


To fully automate the domain-join process, it’s essential to retrieve and pass service account credentials within the script without any manual intervention. However, this comes with a critical responsibility—ensuring the security of the service account, as it holds privileged rights. Any compromise here could have serious repercussions for the entire environment.  

To address this, we leverage Azure Key Vault for secure storage and retrieval of credentials. By using Key Vault, we ensure that sensitive information remains protected while enabling seamless automation.  

P.S : In this blog, we’ll focus on utilizing Azure Key Vault for this purpose. In the next post, we’ll explore how to retrieve domain credentials from the CyberArk Password Vault using the same level of security and automation. Stay tuned!

Step 7:

We need to declare the variable to provide the "key vault name" where the service account credentials are stored. This should be done in Step 1:

$KeyVaultName = "MTest-KV"

The below code ensures that the Azure Key Vault PowerShell module is installed on the local server and if not present, then installs it:

# Check if the Az.KeyVault module is installed if (-not (Get-Module -ListAvailable -Name Az.KeyVault)) { Add-Content $progressLogFile "Az.KeyVault module not found. Installing..." # Install the Az.KeyVault module if not found Install-Module -Name Az.KeyVault -Force -AllowClobber -Scope CurrentUser } else { Add-Content $progressLogFile "Az.KeyVault module is already installed." }

Now, we'll create a function to retrieve the service account credentials from Azure Key Vault, assuming the logged-in user already has the necessary permissions to access the secrets stored in the Key Vault.

function Get-ServiceAccount-Creds { param ( [string]$KeyVaultName, [string]$SrvUsernameSecretName, [string]$SrvPasswordSecretName ) Add-Content $progressLogFile "Initiating retrieval of credentials from vault." try { Add-Content $progressLogFile "Retrieving service account credentials from Azure Key Vault." # Authenticate to access Azure KeyVault using the current account Connect-AzAccount -Identity # Retrieve service account's username and password from Azure Key Vault $SrvUsername = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SrvUsernameSecretName).SecretValueText $SrvPassword = (Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $SrvPasswordSecretName).SecretValueText # Create a PSCredential object $SecurePassword = ConvertTo-SecureString $Password -AsPlainText -Force $Creds = New-Object System.Management.Automation.PSCredential($SrvUsername, $SecurePassword) Add-Content $progressLogFile "Successfully retrieved service account credentials." Log-Success -functionName "AD-DomainJoin" -message "Successfully retrieved credentials." return $Credentials } catch { $errorMessage = "Error retrieving credentials from Azure Key Vault: $($_.Exception.Message)" Add-Content $errorLogFile $errorMessage Log-Failure -functionName "AD-DomainJoin" -message $errorMessage exit } }
Step 8:

We will now use the retrieved service account credentials to send a domain join request to the identified domain controller.

function Join-Domain { param ( [string]$DomainName, [PSCredential]$Creds, [string]$DomainController ) try { Add-Content $progressLogFile "Joining machine to domain: $DomainName via domain controller: $DomainController." # Perform the domain join specifying the domain controller Add-Computer -DomainName $DomainName -Credential $Creds -Server $DomainController -ErrorAction Stop Restart-Computer -Force -ErrorAction Stop Add-Content $progressLogFile "Successfully joined the machine to the domain via domain controller: $DomainController." Log-Success -functionName "AD-DomainJoin" -message "$ComputerName successfully joined $DomainName." } catch { $errorMessage = "Error joining machine to domain via domain controller $DomainController: $($_.Exception.Message)" Write-ErrorLog $errorMessage Log-Failure -functionName "AD-DomainJoin" -message $errorMessage Add-Content $progressLogFile "Domain join to $DomainName for $ComputerName failed. Check error log." exit } }


Now that we've done all the heavy lifting with operational functions, let's talk about logging functions.

During technical activities, especially complex ones, real-time progress monitoring and quick issue identification are essential. Robust logging improves visibility, simplifies troubleshooting, and ensures efficiency when something goes wrong.

To achieve this, we’ll implement two types of logs: a detailed progress log to track each step and an error log to capture issues separately. This approach provides a clear audit trail and makes post-execution analysis much easier. Let's see how we can implement this.

Step 1:

Create log files including current timestamp in the variable declaration holding the log file path:

# Global Log Files $progressLogFile = "C:\Logs\ProgressLog" + (Get-Date -Format yyyy-MM-dd_HH-m) + ".log" $errorLogFile = "C:\Logs\ErrorLog" + (Get-Date -Format yyyy-MM-dd_HH-m) + ".log"

Note: I have included the timestamp in file name, this allows us to capture the logs in separate files in case of multiple attempts. If you do not want multiple files and want to overwrite the existing file, you can remove "+ (Get-Date -Format yyyy-MM-dd_HH-m) +" and it will create a single file named ProcessLog.log.

Step 2: 

How to write events in the log files:

To capture the occurrence of any event in the log file while building the PowerShell script, you can use the following code:

  • For capturing progress in ProgressLog.log file, use:
    Add-Content $progressLogFile "This machine is part of the workgroup: $($computerSystem.Workgroup)."
  • For capturing error occurrence in ErrorLog.log file we need to create a function:
    # Function to Write Error Log function Write-ErrorLog { param ( [string]$message ) Add-Content $errorLogFile "$message at $(Get-Date -Format 'HH:mm, dd-MMM-yyyy')." }


    We will call this function to capture the failure occurrence in the log file:

    $errorMessage = "Error while checking the domain: $($_.Exception.Message)" Write-ErrorLog $errorMessage
Step 3:

As we want to capture the milestones in Application event logs locally on the server as well, we create another function:

# Function to Write to the Application Event Log function Write-ApplicationLog { param ( [string]$functionName, [string]$message, [int]$eventID, [string]$entryType ) # Ensure the event source exists if (-not (Get-EventLog -LogName Application -Source "BuildDomainJoin" -ErrorAction SilentlyContinue)) { New-EventLog -LogName Application -Source "BuildDomainJoin" -ErrorAction Stop } $formattedMessage = "$functionName : $message at $(Get-Date -Format 'HH:mm, dd-MMM-yyyy')." Write-EventLog -LogName Application -Source "BuildDomainJoin" -EventID $eventID -EntryType $entryType -Message $formattedMessage }

 

To capture the success and failure events in Application event logs, we can create separate functions for each case. These functions can be called from other function(s) to capture the results.

Step 4:

Function for the success events: We will use Event Id 3011 to capture the success, by creating separate function. You can use any event Id of your choice but do due diligence to ensure that it does not conflict with any of the existing event ID functions.

# Function to Log Success function Log-Success { param ( [string]$functionName, [string]$message ) Write-ApplicationLog -functionName $functionName -message "Success: $message" -eventID 3011 -entryType "Information" }
Step 5:

To capture failure events, we’ll create a separate function that uses Event ID 3010. Ensure the chosen Event ID does not conflict with any existing Event ID functions.

# Function to Log Failure function Log-Failure { param ( [string]$functionName, [string]$message ) Write-ApplicationLog -functionName $functionName -message "Failed: $message" -eventID 3010 -entryType "Error" }
Step 6: How to call and use Log-Success function in script:

 In case of successful completion of any task, call the function to write success event in the application log. For example, I used the below code in "AD-RSAT-Module" function to report the successful completion of the module installation:

Log-Success -functionName "AD-RSAT-Module" -message "RSAT-AD-PowerShell feature and Active Directory Module imported successfully."
Step 7: How to call and use Log-Failure function in script:

In case of a failure in any task, call the function to write failure event in the application log. For example, I used the below code in "AD-RSAT-Module" function to report the failure along with the error it failed with. It also stops further processing of the PowerShell script:

$errorMessage = "Error during RSAT-AD-PowerShell feature installation or AD module import: $($_.Exception.Message)" Log-Failure -functionName "RSAT-ADModule-Installation" -message $errorMessage Exit

 

With the implementation of an automated domain join solution, the process of integrating servers into Azure becomes more efficient and error-free. By leveraging PowerShell and Azure services, we’ve laid the foundation for future-proof scalability, reducing manual intervention and increasing reliability. This approach sets the stage for further automation in the migration process, providing a seamless experience as the organization continues to grow in the cloud.

Updated Jan 31, 2025
Version 1.0
No CommentsBe the first to comment