Hands-on LabLesson 15 of 16

Debugging PowerShell Scripts

Diagnose and fix the six most common production PowerShell failures: execution policy blocks, permission denied errors, silent failures in pipelines, missing modules, ConvertTo-Json truncation, and scope-related variable bugs.

Each scenario below presents a broken script with real error output, explains the root cause, and shows the fixed version with explanation. This is the triage playbook for PowerShell failures in production and CI/CD environments.

🐛 Scenario 1 — Execution Policy Block

Error: &'script.ps1' cannot be loaded because running scripts is disabled on this system.

powershell
# --- BROKEN ---
# Running on a fresh Windows Server 2022 with default policy
.\deploy.ps1

# Output:
# .\deploy.ps1 : File C:\deploy.ps1 cannot be loaded because running scripts is disabled on this system.
# For more information, see about_Execution_Policies at https://go.microsoft.com/fwlink/?LinkID=135170.

# --- DIAGNOSE ---
Get-ExecutionPolicy -List
# MachinePolicy : Restricted    ← this wins over everything below

# --- FIX OPTION 1: For this session only (safe for CI/CD agents) ---
# Run from pipeline YAML:
#   pwsh -ExecutionPolicy Bypass -File .\deploy.ps1

# --- FIX OPTION 2: For the local machine permanently (needs admin) ---
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine

# --- FIX OPTION 3: Work with IT to update Group Policy ---
# If MachinePolicy is Restricted, only a GPO change or Bypass at -Scope Process works
# Bypass at process scope in a CI/CD step:
powershell -ExecutionPolicy Bypass -NonInteractive -File .\deploy.ps1

🐛 Scenario 2 — Permission Denied on File Copy

Error: Copy-Item: Access to the path 'C:\inetpub\wwwroot\app' is denied.

powershell
# --- BROKEN ---
Copy-Item -Path .\build\* -Destination C:\inetpub\wwwroot\app -Recurse -Force
# ACCESS DENIED

# --- DIAGNOSE ---
# 1. Check who we are
[System.Security.Principal.WindowsIdentity]::GetCurrent().Name

# 2. Check ACL on the destination
Get-Acl -Path C:\inetpub\wwwroot\app | Format-List

# 3. Check if the target exists at all
Test-Path C:\inetpub\wwwroot\app

# --- ROOT CAUSES ---
# A) Not running as admin and the folder requires elevated write access
# B) The target folder doesn't exist — Copy-Item to non-existent deep path errors
# C) Another process (IIS worker w3wp.exe) has a lock on a file in the destination

# --- FIX A: Run elevated or as the correct service account ---
#Requires -RunAsAdministrator  # add to top of script

# --- FIX B: Ensure parent path exists first ---
$dest = "C:\inetpub\wwwroot\app"
if (-not (Test-Path $dest)) { New-Item -ItemType Directory -Path $dest -Force | Out-Null }
Copy-Item -Path .\build\* -Destination $dest -Recurse -Force

# --- FIX C: Stop IIS before copying locked files ---
Stop-Service W3SVC -Force
Copy-Item -Path .\build\* -Destination C:\inetpub\wwwroot\app -Recurse -Force
Start-Service W3SVC

🐛 Scenario 3 — Silent Pipeline Failure (Exit Code 0 on Error)

Error: Pipeline passes, deployment did not actually happen. No error in the build log.

powershell
# --- BROKEN ---
# This script silently swallows errors and ALWAYS exits 0

$files = Get-ChildItem -Path "C:\does-not-exist" -ErrorAction SilentlyContinue
foreach ($f in $files) {
    Copy-Item -Path $f.FullName -Destination "D:\backup\" -ErrorAction SilentlyContinue
}
Write-Host "Backup complete."
# Exit code: 0 — pipeline sees SUCCESS even though nothing was backed up

# --- DIAGNOSE ---
# The problem: -ErrorAction SilentlyContinue combined with no explicit exit code
# The fix: set the default error action and check results

# --- FIXED ---
$ErrorActionPreference = 'Stop'

try {
    $files = Get-ChildItem -Path "C:\does-not-exist"
    if ($files.Count -eq 0) {
        Write-Warning "No files found to back up."
        exit 0  # legitimate empty result — OK
    }
    foreach ($f in $files) {
        Copy-Item -Path $f.FullName -Destination "D:\backup\" -Force
    }
    Write-Host "Backup complete: $($files.Count) files."
}
catch {
    Write-Error "Backup failed: $_"
    exit 1  # pipeline sees FAILURE
}

# For external tool calls, always check $LASTEXITCODE:
& az deployment group create --resource-group rg-prod --template-file main.bicep
if ($LASTEXITCODE -ne 0) {
    throw "az deployment failed with exit code $LASTEXITCODE"
}
🚨
The Silent Failure Pattern is a Production Incident

Scripts that exit 0 while silently failing cause the scariest production incidents: the pipeline shows green, confidence is high, and damage accumulates undetected. The fix is always the same: $ErrorActionPreference = 'Stop' at the top and explicit exit 1 in all failure paths. Run $Error[0] to see the most recent error that was swallowed.

🐛 Scenario 4 — Module Not Found

Error: The term 'Connect-AzAccount' is not recognized as a name of a cmdlet...

powershell
# --- BROKEN ---
Connect-AzAccount -Identity
# ERROR: The term 'Connect-AzAccount' is not recognized...

# --- DIAGNOSE ---
# 1. Check what is installed
Get-Module -Name Az* -ListAvailable | Select-Object Name, Version

# 2. Check PSModulePath
$env:PSModulePath -split ";"

# 3. Check if installed but not imported
Get-InstalledModule -Name Az.Accounts -ErrorAction SilentlyContinue

# --- ROOT CAUSES ---
# A) Module not installed at all
# B) Module installed for a different user (CurrentUser vs AllUsers scope)
# C) Running PowerShell 5.x but module was installed in PowerShell 7's path
# D) PSModulePath does not include the user's module directory

# --- FIX A: Install the module ---
Install-Module -Name Az.Accounts, Az.Resources -Force -Scope CurrentUser
# For CI agents (all users):
Install-Module -Name Az.Accounts, Az.Resources -Force -Scope AllUsers

# --- FIX B/C: Explicit import with path ---
$moduleBase = "$env:USERPROFILE\Documents\PowerShell\Modules"
Import-Module "$moduleBase\Az.Accounts\*\Az.Accounts.psd1" -Force

# --- FIX D: Add to PSModulePath ---
$env:PSModulePath += ";C:\Program Files\PowerShell\Modules"

# --- BEST PRACTICE for CI/CD agents ---
# Add this at the top of pipeline scripts:
if (-not (Get-Module Az.Accounts -ListAvailable)) {
    Install-Module Az.Accounts, Az.Resources -Force -Scope AllUsers -AllowClobber
}
Import-Module Az.Accounts

🐛 Scenario 5 — ConvertTo-Json Truncation (@ Strings in Output)

Error: JSON output contains @{Name=value; ...} strings instead of nested objects.

powershell
# --- BROKEN ---
$data = @{
    server = "web01"
    config = @{
        port = 443
        tls  = @{
            cert = "wildcard.crt"
            key  = "wildcard.key"
        }
    }
}
$data | ConvertTo-Json
# Output:
# {
#   "server": "web01",
#   "config": "@{port=443; tls=}"    <-- WRONG: nested object converted to string
# }

# --- ROOT CAUSE ---
# ConvertTo-Json defaults to -Depth 2
# Level 1: $data keys
# Level 2: $data.config keys
# Level 3: $data.config.tls — exceeds depth, gets .ToString() called → "@{cert=...}"

# --- FIX ---
$data | ConvertTo-Json -Depth 10
# Output:
# {
#   "server": "web01",
#   "config": {
#     "port": 443,
#     "tls": {
#       "cert": "wildcard.crt",
#       "key": "wildcard.key"
#     }
#   }
# }

# RULE: Always use -Depth 10 (or higher) when serializing complex objects.
# The default -Depth 2 is almost never what you want for real data structures.

🐛 Scenario 6 — Variable Scope Bug (Script vs Function)

Error: a function modifies a variable but the caller's copy is unchanged. Logic does not work as expected.

powershell
# --- BROKEN --- function Add-ServerToList { param([string]$Server) $serverList += $Server # WRONG: this creates a new LOCAL variable, does not modify the caller's } $serverList = @("web01", "web02") Add-ServerToList -Server "web03" $serverList.Count # output: 2 ← web03 was NOT added # --- ROOT CAUSE --- # In PowerShell, function variables are local by default. # $serverList inside the function is a new local copy. The caller's $serverList is unchanged. # --- FIX OPTION 1: Return the new list (PREFERRED — pure function pattern) --- function Add-ServerToList { param([string[]]$List, [string]$Server) return $List + $Server } $serverList = Add-ServerToList -List $serverList -Server "web03" $serverList.Count # output: 3 ✅ # --- FIX OPTION 2: Use [ref] (rare, use only when necessary) --- function Add-ServerToList { param([ref]$ListRef, [string]$Server) $ListRef.Value += $Server } Add-ServerToList -ListRef ([ref]$serverList) -Server "web03" $serverList.Count # output: 3 ✅ # --- FIX OPTION 3: Use $script: scope prefix (for scripts, NOT modules) --- function Add-ServerToList { param([string]$Server) $script:serverList += $Server } # Only safe when you control the script scope and there are no modules involved.

📋 Debugging Checklist

When a PowerShell script fails or produces unexpected behavior, work through this checklist:

  1. Error stream: run $Error[0] | Format-List * to see the full last error including ScriptStackTrace.
  2. Error action: confirm $ErrorActionPreference is 'Stop', not 'SilentlyContinue'.
  3. Exit codes: check $LASTEXITCODE after every external command call.
  4. Verbosity: add -Verbose flag or $VerbosePreference = 'Continue' to see Write-Verbose output.
  5. Type inspection: use $variable | Get-Member to see the actual type and available properties.
  6. Step trace: add Set-PSDebug -Trace 1 to trace every script line, then disable with Set-PSDebug -Off.
  7. Pipeline isolation: extract each pipeline segment manually to isolate which step returns unexpected output.
  8. Module availability: Get-Module -ListAvailable Az* to verify modules are installed.
  9. Scope: if a variable is unexpectedly empty inside a function, it may be a local scope copy.
  10. JSON depth: if nested objects appear as @{} strings, add -Depth 10 to ConvertTo-Json.
🔑
Your Debugging Starting Points

90% of PowerShell debugging comes down to four questions: (1) Is $ErrorActionPreference set to Stop? (2) Is $LASTEXITCODE checked after external calls? (3) Is the correct type coming through the pipeline (use Get-Member)? (4) Is a variable actually in scope where I think it is? Answer these four questions and you will find most bugs quickly.