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.
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.
# --- 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.
# --- 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.
# --- 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"
}
🐛 Scenario 4 — Module Not Found
Error: The term 'Connect-AzAccount' is not recognized as a name of a cmdlet...
# --- 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.
# --- 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.
# --- 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:
- Error stream: run
$Error[0] | Format-List *to see the full last error including ScriptStackTrace. - Error action: confirm
$ErrorActionPreferenceis 'Stop', not 'SilentlyContinue'. - Exit codes: check
$LASTEXITCODEafter every external command call. - Verbosity: add
-Verboseflag or$VerbosePreference = 'Continue'to see Write-Verbose output. - Type inspection: use
$variable | Get-Memberto see the actual type and available properties. - Step trace: add
Set-PSDebug -Trace 1to trace every script line, then disable withSet-PSDebug -Off. - Pipeline isolation: extract each pipeline segment manually to isolate which step returns unexpected output.
- Module availability:
Get-Module -ListAvailable Az*to verify modules are installed. - Scope: if a variable is unexpectedly empty inside a function, it may be a local scope copy.
- JSON depth: if nested objects appear as
@{}strings, add-Depth 10to ConvertTo-Json.
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.