Guides

PowerShell Post-Setup Automation in Windows Deployment: FirstLogonCommands and SetupComplete

Core idea: Windows installation does not end when the OS image is applied. The real configuration work — software installs, security hardening, domain joins, and environment initialization — happens after Setup finishes. PowerShell combined with the right post-setup hook gives you a scriptable, repeatable way to finish what the answer file started.

Why post-setup scripting matters

An unattended Windows answer file handles the mechanical parts of installation: partitioning, image selection, locale, account creation. What it does not handle is everything that makes a machine ready to work — line-of-business software, agent installs, firewall rules, registry tweaks, and IT policy enforcement. Those tasks need a scripting layer that runs immediately after Windows Setup hands off control.

PowerShell is the natural choice here. It ships with every modern Windows edition, it has access to the full .NET ecosystem, and it handles error codes, logging, and remote management better than batch files. The challenge is knowing which hook point to use and understanding exactly what environment your script will land in.

Post-setup automation is not unique to VIO Deployment Studio. But when you generate an answer file programmatically from a deployment recipe, you can include post-setup script references as a first-class parameter — making the script an auditable, versionable part of the deployment artifact rather than a manual afterthought.

The two post-install hook points: SetupComplete.cmd and FirstLogonCommands

Windows provides two main mechanisms for running commands after installation finishes. They look similar but behave very differently:

Hook When it runs Execution account Interactive desktop? Configured via
SetupComplete.cmd At the very end of Windows Setup, before the first user logs on SYSTEM No — runs behind the scenes Place at %WINDIR%\Setup\Scripts\SetupComplete.cmd (injected into the image or copied by earlier setup commands)
FirstLogonCommands On the first user logon after OOBE completes The logged-on user (often the account configured in AutoLogon) Yes — the desktop is present autounattend.xml → oobeSystem → Microsoft-Windows-Shell-Setup → FirstLogonCommands

Pick the hook based on what your script needs. If the task requires SYSTEM privileges and should happen before any user sees the desktop — registry policies, service installation, driver configuration — use SetupComplete.cmd. If the task needs a logged-on user context — per-user preferences, user-profile initialization, interactive installers — use FirstLogonCommands.

A common mistake: placing domain-join commands in FirstLogonCommands. A domain join requires SYSTEM-level access and should run from SetupComplete.cmd or from the specialize pass in the answer file using the Microsoft-Windows-UnattendedJoin component.

Execution context: SYSTEM vs. user, before vs. after desktop

The execution context shapes every assumption your script can make. Here is what each hook gives you — and what it withholds:

SetupComplete.cmd runs as SYSTEM

  • No user profile is loaded. Paths like %USERPROFILE% point to the SYSTEM profile, not to your deployment account. Write to machine-wide paths instead.
  • No network shares by default. If your scripts live on a network path, you need to establish credentials and map the share explicitly inside the script.
  • No desktop. Any script that pops a GUI window will hang the deployment silently. Use -NonInteractive and -WindowStyle Hidden in PowerShell calls to prevent this.
  • Runs only once. Windows marks SetupComplete.cmd as consumed after it runs. If you need it to re-run on failure, your retry logic must be baked in.

FirstLogonCommands runs as the first logged-on user

  • User profile is present. You get a real %USERPROFILE%, which is useful for per-user configurations.
  • Network may or may not be ready yet. Particularly on wireless-connected machines, network stack initialization can lag behind the first logon event. Add wait logic or retry loops if your script fetches remote resources.
  • UAC prompt risk. If the account is not a standard administrator, or if UAC is active and the script tries to elevate, you will get an interactive prompt that blocks the automation. Ensure the account in AutoLogon is a local admin and consider disabling UAC temporarily during the provisioning window.

Calling PowerShell from autounattend.xml and SetupComplete.cmd

From SetupComplete.cmd

Place your PowerShell invocation in the batch script. The key flags prevent the script from blocking on GUI elements or execution policy prompts:

@echo off
REM ===========================================================
REM  SetupComplete.cmd — runs as SYSTEM at end of Windows Setup
REM ===========================================================

REM Allow PowerShell to run the post-setup script without an
REM execution policy prompt.  Bypass applies to this call only.
PowerShell.exe -NoProfile -NonInteractive -WindowStyle Hidden ^
  -ExecutionPolicy Bypass ^
  -File "C:\Windows\Setup\Scripts\PostSetup.ps1" ^
  >> "C:\Windows\Setup\Scripts\setupcomplete.log" 2>&1

exit /b 0

The 2>&1 redirect captures both stdout and stderr into the same log file. Always redirect output to a persistent log location during setup — the console is not visible, so logs are your only debugging tool.

From autounattend.xml via FirstLogonCommands

Wire up the PowerShell call as a synchronous SynchronousCommand inside the oobeSystem pass. Order values define execution sequence:

<settings pass="oobeSystem">
  <component name="Microsoft-Windows-Shell-Setup"
             processorArchitecture="amd64"
             publicKeyToken="31bf3856ad364e35"
             language="neutral"
             versionScope="nonSxS">
    <FirstLogonCommands>
      <SynchronousCommand wcm:action="add">
        <Order>1</Order>
        <Description>Run post-setup PowerShell script</Description>
        <CommandLine>PowerShell.exe -NoProfile -NonInteractive
          -ExecutionPolicy Bypass
          -File "C:\Windows\Setup\Scripts\UserProvision.ps1"</CommandLine>
        <RequiresUserInput>false</RequiresUserInput>
      </SynchronousCommand>
    </FirstLogonCommands>
  </component>
</settings>

Important: the script file referenced must already exist on the machine when this command runs. Either inject it into the OS image via DISM before rebuilding the ISO, or copy it using an earlier SetupComplete.cmd step. A missing script file causes a silent skip, not an error message you will easily find.

Common post-setup automation tasks with PowerShell

The following examples illustrate patterns for the most frequently automated post-install tasks. Adapt them to your environment before use.

Install software silently

# Install applications from a local or network source
# -Wait ensures the next line runs only after the install finishes
Start-Process -FilePath "C:\Packages\AppInstaller.exe" `
  -ArgumentList "/S /norestart" `
  -Wait -NoNewWindow

# Winget-based install (Windows 11 and updated Windows 10)
winget install --id Microsoft.VisualStudioCode --silent --accept-source-agreements `
  --accept-package-agreements

Configure Windows Defender exclusions

# Add a folder exclusion (useful for deployment share or dev tool paths)
Add-MpPreference -ExclusionPath "C:\DevTools"
Add-MpPreference -ExclusionProcess "C:\DevTools\build.exe"

Join an Active Directory domain

# Domain join — must run as SYSTEM from SetupComplete.cmd
# Use -Credential if your unattended service account is required
$joinCredential = New-Object System.Management.Automation.PSCredential(
  "DOMAIN\JoinServiceAccount",
  (ConvertTo-SecureString "YourPassword" -AsPlainText -Force)
)

Add-Computer `
  -DomainName "corp.example.com" `
  -OUPath "OU=Workstations,OU=IT,DC=corp,DC=example,DC=com" `
  -Credential $joinCredential `
  -Restart

Apply registry policies

# Disable Windows Telemetry (HKLM — requires SYSTEM or admin)
$regPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection"
if (-not (Test-Path $regPath)) {
  New-Item -Path $regPath -Force | Out-Null
}
Set-ItemProperty -Path $regPath -Name "AllowTelemetry" -Value 0 -Type DWord

Enable Windows features

# Enable Hyper-V (server workloads, developer machines)
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -NoRestart

# Enable OpenSSH Server
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic

Reliability patterns: logging, error handling, and idempotency

Post-setup scripts run in an environment you cannot watch in real time. The only way to know what happened is to build observability into the script from the start.

Use a structured log file

# Structured log helper — write timestamped entries to a persistent log
function Write-DeployLog {
  param([string]$Message, [string]$Level = "INFO")
  $entry = "{0} [{1}] {2}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Level, $Message
  Add-Content -Path "C:\Windows\Logs\PostSetup.log" -Value $entry
  Write-Host $entry
}

Write-DeployLog "Post-setup script started"

Wrap risky steps in try/catch

try {
  Write-DeployLog "Attempting domain join..."
  Add-Computer -DomainName "corp.example.com" -Credential $joinCredential -Restart
  Write-DeployLog "Domain join succeeded"
} catch {
  Write-DeployLog "Domain join failed: $_" -Level "ERROR"
  # Continue without failing the entire script — a partial setup
  # is usually better than a hung one
}

Make steps idempotent

Idempotency means that running the same step twice produces the same result as running it once. This protects you when Setup re-runs a hook due to an interrupted shutdown or a manual re-trigger:

# Idempotent registry write — check before setting
$regPath = "HKLM:\SOFTWARE\MyApp\Config"
if (-not (Test-Path $regPath)) {
  New-Item -Path $regPath -Force | Out-Null
  Write-DeployLog "Created registry path: $regPath"
} else {
  Write-DeployLog "Registry path already exists, skipping creation"
}
Set-ItemProperty -Path $regPath -Name "DeployedBy" -Value "VIO-DS" -Type String

Return a meaningful exit code

# End the script with an explicit exit code
# SetupComplete.cmd checks %ERRORLEVEL% after calling PowerShell
$exitCode = 0
if ($criticalStepFailed) { $exitCode = 1 }

Write-DeployLog "Script completed with exit code $exitCode"
exit $exitCode

Security considerations

Credentials in scripts: never hard-code production passwords in post-setup scripts that travel inside the ISO. Use managed service accounts, Group Policy preferences, or a secrets-management solution (such as an on-premise vault or Azure Key Vault) to retrieve credentials at runtime rather than storing them at build time.

  • Execution policy: using -ExecutionPolicy Bypass for a single call is safe and conventional during deployment. It does not change the machine's persistent execution policy. If your security baseline requires a specific policy, set it as an explicit post-setup step and document it in the deployment record.
  • Script signing: for regulated environments, sign your PowerShell scripts with a code-signing certificate issued by your internal PKI. This allows you to enforce AllSigned policy on production machines while still running your own scripts reliably.
  • Least privilege: give the service account used for domain joins only the rights to add workstations to the specific OU it targets. It should not be a domain admin.
  • Clean up after yourself: remove technician autologon settings, temporary credentials, and setup helper scripts from the machine once provisioning is complete. A post-setup step that deletes C:\Windows\Setup\Scripts\ at the very end prevents leftover artifacts from becoming attack surface.

Putting it all together: a post-setup recipe template

A reliable post-setup workflow in an unattended deployment pipeline typically looks like this:

Step Hook What it does
1. Copy scripts into the image DISM offline servicing or ISO injection Place PostSetup.ps1 and SetupComplete.cmd into %WINDIR%\Setup\Scripts\ inside install.wim before building the ISO
2. System initialization SetupComplete.cmd (SYSTEM) Apply registry policies, install services, join domain, enable features — tasks that need SYSTEM and a pre-desktop window
3. User-context provisioning FirstLogonCommands (admin user) User-profile setup, per-user software installs, startup shortcuts
4. Cleanup and signal Last command in FirstLogonCommands Delete temporary scripts, log completion timestamp, optionally write a registry key that signals "provisioning complete" so monitoring systems can check in

If you generate the answer file from a deployment recipe in VIO Deployment Studio, the script paths and runtime parameters can be part of the recipe itself. This means the same automation layer is reproducible across dev, staging, and production builds without manual edits to the XML or batch files.

For the answer file structure that wraps these hooks, see Automated Windows Installation: Understanding autounattend.xml. For offline injection of the script files into install.wim, see DISM Offline Servicing: Injecting Drivers and Updates into Windows Images.