Real Work: File System, REST APIs, JSON/CSV, Scheduled Tasks, Windows Services, IIS and Network Diagnostics on Windows
ConvertTo-Json only goes 2 levels deep by default; a nested body sent to an API silently turns into the string "System.Collections.Hashtable", so pass -Depth to every body. Bearer tokens, -MaximumRetryCount retrying a POST even on 401, $PSScriptRoot and CSV encoding, Windows Services, scheduled tasks, IIS and network commands, and finally a deploy script for Bookstore.Api with a backup and a way back.
Version note: The examples in this post were run with PowerShell 7.6.10 (LTS) and PowerShell 7.7.0 on Ubuntu 24.04 with the .NET 10 SDK (10.0.404) and EF Core 10.0.15; the output was the same on both versions, except that 7.7, running on .NET 11, words the error for a missing file as "Could not find file". Windows Services, scheduled tasks, IIS and the Windows-only network commands come from the Microsoft Learn documentation and were not run.
Guess First
In the back office of Bookstore, the online bookstore used as the shared example across this site's series, price updates go through an admin endpoint. The script turns the request from a hashtable into JSON and sends it with a bearer token:
$body = @{
updates = @(
@{ isbn = '9780441172719'; price = @{ amount = 13.49; currency = 'TRY' } }
)
}
$json = $body | ConvertTo-Json
$json
$token = ConvertTo-SecureString -String $env:BOOKSTORE_ADMIN_TOKEN -AsPlainText -Force
Invoke-RestMethod -Uri http://127.0.0.1:5080/api/admin/prices -Method Post -Body $json `
-ContentType 'application/json' -Authentication Bearer -Token $token -AllowUnencryptedAuthentication
Everything is in place in the hashtable: the ISBN, the amount and the currency. What does the API see in the body, and does the request succeed?
In the previous post we built the Bookstore.Ops module. This post covers the daily work of scripts: REST APIs and JSON, retries, file paths and CSV, Windows Services, scheduled tasks, IIS and network diagnostics on Windows. At the end we combine all of it in a deploy script for Bookstore.Api that takes a backup, takes the site offline with app_offline, runs migrations, does a health check and rolls back on failure.
REST and JSON: -Depth, Bearer Tokens and the Error Body
The answer to the guess:
PS> ./lab06/update-prices.ps1
WARNING: Resulting JSON is truncated as serialization has exceeded the set depth of 2.
{
"updates": [
{
"price": "System.Collections.Hashtable",
"isbn": "9780441172719"
}
]
}
Invoke-RestMethod: /home/user/bookstore/lab06/update-prices.ps1:10
Line |
10 | Invoke-RestMethod -Uri http://127.0.0.1:5080/api/admin/prices -Method …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Response status code does not indicate success: 400 (Bad Request).
The default depth of ConvertTo-Json is 2. The price hashtable on the third level was turned into the result of ToString(), the string "System.Collections.Hashtable", instead of a JSON object. PowerShell 7 says so with a warning, but a warning easily gets lost in a CI log; the API saw a string where it expected an object and returned 400. The fix is to pass -Depth:
PS> $body = @{ updates = @( @{ isbn = '9780441172719'; price = @{ amount = 13.49; currency = 'TRY' } } ) }
PS> $body | ConvertTo-Json -Depth 5 -Compress
{"updates":[{"isbn":"9780441172719","price":{"currency":"TRY","amount":13.49}}]}
PS> $request = @{ Uri = 'http://127.0.0.1:5080/api/admin/prices'; Method = 'Post'; ContentType = 'application/json' }
PS> Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) -Authentication Bearer -Token $token -AllowUnencryptedAuthentication
updated currency
------- --------
1 {TRY}
PS> Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) -Authentication Bearer -Token $token
Invoke-RestMethod: The cmdlet cannot protect plain text secrets sent over unencrypted connections. To suppress this warning and send plain text secrets over unencrypted networks, reissue the command specifying the AllowUnencryptedAuthentication parameter.
PS> Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5)
Invoke-RestMethod: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Missing or invalid bearer token","status":401}
PS> try { Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) } catch { $_.Exception.Response.StatusCode; ($_.ErrorDetails.Message | ConvertFrom-Json).title }
Unauthorized
Missing or invalid bearer token
PS> $response = Invoke-RestMethod @request -Body ($body | ConvertTo-Json -Depth 5) -SkipHttpErrorCheck -StatusCodeVariable status; "$status $($response.title)"
401 Missing or invalid bearer token
Several things show up together. With -Depth 5 the body went out correctly and the API accepted the update. The order of the keys in a hashtable is not guaranteed: running the same command in three separate sessions changed the order of the fields in the JSON every time; use [ordered] when the order matters. The token is passed as a SecureString with -Authentication Bearer -Token; we do not build the Authorization header by hand. If the address does not start with https://, PowerShell refuses to send the token; the -AllowUnencryptedAuthentication in the lab is only for 127.0.0.1. The request without a token got 401, and the error message contains the problem details body the API returned. Inside catch you can read that body from $_.ErrorDetails.Message and the status code from $_.Exception.Response.StatusCode. If you want to evaluate the result yourself instead of handling an exception, -SkipHttpErrorCheck and -StatusCodeVariable, as in the first post, are easier to read.
Retries: What Does -MaximumRetryCount Retry?
Bookstore's daily report returns 503 on the first requests and 200 once the report is ready. Invoke-RestMethod has its own retry parameters:
PS> Invoke-RestMethod http://127.0.0.1:5080/api/reports/daily
Invoke-RestMethod: {"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Report is being generated","status":503}
PS> Invoke-RestMethod http://127.0.0.1:5080/api/reports/daily -MaximumRetryCount 3 -RetryIntervalSec 2 -Verbose
VERBOSE: WebRequest: v1.1 GET http://127.0.0.1:5080/api/reports/daily
VERBOSE: WebResponse: 503 Service Unavailable with application/problem+json payload
VERBOSE: Retrying after interval of 2 seconds. Status code for previous attempt: ServiceUnavailable
VERBOSE: WebRequest: v1.1 GET http://127.0.0.1:5080/api/reports/daily
VERBOSE: WebResponse: 200 OK with application/json payload
date orders revenue
---- ------ -------
2026-11-15 42 1234.50
The first request failed with 503; the second retried once and got the report. According to the documentation, retries happen on every status code between 400 and 599 and on 304; on a 429 response with a Retry-After header, the wait time comes from that header. I tried the "every status code" part in the lab:
PS> $sw = [System.Diagnostics.Stopwatch]::StartNew()
PS> Invoke-RestMethod http://127.0.0.1:5080/api/admin/prices -Method Post -Body '{"updates":[]}' -ContentType application/json -MaximumRetryCount 2 -RetryIntervalSec 1 -Verbose
VERBOSE: WebRequest: v1.1 POST http://127.0.0.1:5080/api/admin/prices with application/json payload with body size 14 B (14 bytes)
VERBOSE: WebResponse: 401 Unauthorized with application/problem+json payload
VERBOSE: Retrying after interval of 1 seconds. Status code for previous attempt: Unauthorized
VERBOSE: WebRequest: v1.1 POST http://127.0.0.1:5080/api/admin/prices with application/json payload with body size 14 B (14 bytes)
VERBOSE: WebResponse: 401 Unauthorized with application/problem+json payload
VERBOSE: Retrying after interval of 1 seconds. Status code for previous attempt: Unauthorized
VERBOSE: WebRequest: v1.1 POST http://127.0.0.1:5080/api/admin/prices with application/json payload with body size 14 B (14 bytes)
VERBOSE: WebResponse: 401 Unauthorized with application/problem+json payload
Invoke-RestMethod: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Missing or invalid bearer token","status":401}
PS> Invoke-RestMethod http://127.0.0.1:5081/health -MaximumRetryCount 2 -RetryIntervalSec 1 -Verbose
VERBOSE: WebRequest: v1.1 GET http://127.0.0.1:5081/health
Invoke-RestMethod: Connection refused
PS> '{0:N1} s' -f $sw.Elapsed.TotalSeconds
2.2 s
A 401 is not a transient error, yet the POST was sent three times. When no connection could be made at all (connection refused), there was no retry and the command failed right away. So these parameters are not a retry policy that tells transient errors apart. Turning on retries for a POST that is not idempotent can make an API that returned 500 but actually did the work apply the same update twice. Use retries for GET and idempotent work; write your own loop for connection errors and specific status codes. The health check loop in this post's deploy script is such an example.
File Paths and CSV: $PSScriptRoot and Encoding
We mentioned $PSScriptRoot briefly in the first post. A relative path is resolved against the working directory, not against the folder the script is in:
param([string] $Path = './data/books.csv')
$books = Import-Csv -Path $Path
"$($books.Count) titles, $(($books | Measure-Object -Property Stock -Sum).Sum) books in stock"
param([string] $Path = (Join-Path -Path $PSScriptRoot -ChildPath '..', 'data', 'books.csv'))
$books = Import-Csv -Path $Path
"$($books.Count) titles, $(($books | Measure-Object -Property Stock -Sum).Sum) books in stock"
PS> ./lab06/stock-count.ps1
12 titles, 100 books in stock
PS> Set-Location ~
PS> ./bookstore/lab06/stock-count.ps1
Import-Csv: /home/user/bookstore/lab06/stock-count.ps1:2
Line |
2 | $books = Import-Csv -Path $Path
| ~~~~~~~~~~~~~~~~~~~~~~
| Could not find a part of the path '/home/user/data/books.csv'.
0 titles, books in stock
PS> ./bookstore/lab06/stock-count-fixed.ps1
12 titles, 100 books in stock
PS> Set-Location ~/bookstore
PS> Import-Csv ./lab06/warehouse-stock.csv -Delimiter ';'
Warehouse Isbn Quantity
--------- ---- --------
�stanbul 9780441172719 14
�zmir 9780141439587 3
PS> Import-Csv ./lab06/warehouse-stock.csv -Delimiter ';' -Encoding 1254
Warehouse Isbn Quantity
--------- ---- --------
İstanbul 9780441172719 14
İzmir 9780141439587 3
PS> Get-Service
Get-Service: The term 'Get-Service' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
Run from its own folder, the script is fine. Run from the home folder, ./data/books.csv resolved to /home/user/data; on top of that the script went on after the error and printed "0 titles", because $ErrorActionPreference is not Stop here. The path built from $PSScriptRoot worked from everywhere. Scheduled tasks and CI do not run your script from the folder you expect; build every path in a script from $PSScriptRoot.
The CSV example in the same output is a warehouse count file from an old Windows tool. The file was written in Windows-1254; PowerShell 7 reads UTF-8 by default, so the İ letters were broken. With -Encoding 1254 it was read correctly. The other direction, producing a semicolon-separated CSV with a BOM that Turkish Excel can open, we did in the second post; the details are in the "Working with CSV" post of the I/O in .NET series.
Windows Services and Scheduled Tasks
The commands in this section exist only on Windows; on the lab's Linux even Get-Service is not defined (the last line of the output above). Microsoft's PowerShell 7 module compatibility list shows ScheduledTasks as "Natively Compatible", so it works directly in PowerShell 7:
# Windows only, not run: Bookstore's notification worker runs as a Windows Service
Get-Service -Name BookstoreNotifications | Select-Object Name, Status, StartType
Restart-Service -Name BookstoreNotifications -Force
(Get-Service -Name BookstoreNotifications).WaitForStatus('Running', [timespan]::FromSeconds(30))
# Nightly backup at 02:30 as SYSTEM; the module must be installed with -Scope AllUsers
$command = 'Import-Module Bookstore.Ops -RequiredVersion 0.2.0; ' +
'Invoke-BookstoreBackup -Path C:\inetpub\bookstore-api -Destination D:\backups\bookstore-api -KeepLast 14 -ErrorAction Stop'
$task = @{
TaskName = 'Bookstore nightly backup'
TaskPath = '\Bookstore\'
Action = New-ScheduledTaskAction -Execute 'pwsh.exe' -Argument "-NoProfile -NonInteractive -Command `"$command`""
Trigger = New-ScheduledTaskTrigger -Daily -At '02:30'
Principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1) -StartWhenAvailable
}
Register-ScheduledTask @task
Start-ScheduledTask -TaskPath '\Bookstore\' -TaskName 'Bookstore nightly backup'
Get-ScheduledTaskInfo -TaskPath '\Bookstore\' -TaskName 'Bookstore nightly backup' | Select-Object LastRunTime, LastTaskResult
Bookstore's worker that sends notifications is a Windows Service; restarting it after a deploy and waiting until it is Running is one line with WaitForStatus. For the nightly backup, the action, trigger, principal and settings are built one by one and passed to Register-ScheduledTask with splatting. Two details matter: since the task runs as the SYSTEM account, the module must be installed for all users, not with -Scope CurrentUser; and pwsh running with -Command turns exit codes other than 0 and 1 into 1, as we saw in the first post. Watch the result through LastTaskResult in Get-ScheduledTaskInfo.
Managing IIS from a Script
There are two ways to manage IIS. The IISAdministration module was written for Windows PowerShell 5.1 and is listed as "Untested with Compatibility Layer" in the compatibility list. In PowerShell 7 it can run in a 5.1 session in the background with Import-Module IISAdministration -UseWindowsPowerShell, but then the objects you get back are deserialized copies without their methods; we look at this behaviour in detail in the remoting post. Microsoft's IIS support blog recommends loading the Microsoft.Web.Administration API directly for PowerShell 7; Restart-BookstorePool from the previous post uses this approach too:
# Windows only, run as administrator, not run
Add-Type -Path "$env:SystemRoot\System32\inetsrv\Microsoft.Web.Administration.dll"
$manager = [Microsoft.Web.Administration.ServerManager]::new()
$manager.Sites['bookstore-api'] | Select-Object Name, State
$manager.ApplicationPools['BookstoreApi'] | Select-Object Name, State, ManagedRuntimeVersion
$manager.Sites['bookstore-api'].Stop()
$manager.Sites['bookstore-api'].Start()
$manager.Dispose()
The Hosting Bundle, the ASP.NET Core Module and the app pool settings themselves are in posts 9 and 10 of the Web Servers and Hosting series. One behaviour matters here: when the ASP.NET Core Module sees app_offline.htm in the site folder, it stops the app and serves that file to incoming requests; once the file is deleted, the first request starts the app again. A deploy does not need to stop the app pool.
Network Diagnostics on Windows
The Windows counterparts of the "is the port open, who is listening" questions from the "Network Commands" post of the Linux and Shell series live in the NetTCPIP and DnsClient modules; both work directly with PowerShell 7 according to the compatibility list:
# Windows only, not run
Test-NetConnection -ComputerName sql1 -Port 1433
Resolve-DnsName -Name api.bookstore.test -Type A
Get-NetTCPConnection -LocalPort 5080 -State Listen | Select-Object LocalAddress, LocalPort, OwningProcess
Get-Process -Id (Get-NetTCPConnection -LocalPort 5080 -State Listen).OwningProcess
TcpTestSucceeded in the Test-NetConnection result says whether the port is open, and Get-NetTCPConnection says which process listens on a port. These three commands do not exist on Linux; if the script has to run on both platforms, there is PowerShell 7's own Test-Connection -TcpPort and the Dns class of .NET:
PS> Test-Connection -TargetName 127.0.0.1 -TcpPort 5080
True
PS> Test-Connection -TargetName 127.0.0.1 -TcpPort 5081
False
PS> Test-Connection -TargetName 127.0.0.1 -TcpPort 5080 -Detailed
Target: 127.0.0.1
Id Source Address Port Latency Connected Status
(ms)
-- ------ ------- ---- ------- --------- ------
1 lab 127.0.0.1 5080 0 True Success
PS> [System.Net.Dns]::GetHostAddresses('localhost').IPAddressToString
::1
127.0.0.1
PS> Get-Command Test-NetConnection, Resolve-DnsName, Get-NetTCPConnection -ErrorAction SilentlyContinue
Bookstore: A Deploy Script with a Backup and a Way Back
The build script from the fourth post produces artifacts/api. The deploy script installs that folder into an IIS site: it backs up the current site, takes the site offline with app_offline.htm, copies the files, runs the EF Core migration bundle, brings the site online and does a health check. If any step fails, it restores the files from the backup. We added a new function to the module for the health check, and the module version became 0.2.0:
function Wait-BookstoreHealthy {
[CmdletBinding(PositionalBinding = $false)]
[OutputType('Bookstore.Health')]
param(
[Parameter(Mandatory)]
[ValidatePattern('^https?://', ErrorMessage = "'{0}' is not an http or https address.")]
[string] $BaseUrl,
[ValidateRange(1, 3600)]
[int] $TimeoutSec = 60,
[ValidateRange(1, 60)]
[int] $IntervalSec = 2
)
$deadline = [datetime]::UtcNow.AddSeconds($TimeoutSec)
$attempt = 0
do {
$attempt++
$result = Get-BookstoreHealth -BaseUrl $BaseUrl -TimeoutSec 5
$detail = if ($result.Error) { $result.Error } else { "HTTP $($result.StatusCode) $($result.Status)" }
Write-Information " health attempt ${attempt}: $detail"
if ($result.Healthy) { return $result }
Start-Sleep -Seconds $IntervalSec
} while ([datetime]::UtcNow -lt $deadline)
$result
}
#!/usr/bin/env pwsh
#Requires -Version 7.6
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -Path $_ -PathType Container }, ErrorMessage = "Package folder '{0}' does not exist.")]
[string] $PackagePath,
[Parameter(Mandatory)]
[string] $SitePath,
[ValidateScript({ Test-Path -Path $_ -PathType Leaf }, ErrorMessage = "Migration bundle '{0}' does not exist.")]
[string] $MigrationBundle,
[string] $BackupPath = (Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'backups'),
[ValidatePattern('^https?://')]
[string] $HealthUrl = 'http://127.0.0.1:5080',
[ValidateRange(5, 600)]
[int] $HealthTimeoutSec = 60
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '..', 'modules', 'Bookstore.Ops') -Force
$offlineFile = Join-Path -Path $SitePath -ChildPath 'app_offline.htm'
$backup = $null
$step = 'backup'
try {
if (Test-Path -Path (Join-Path -Path $SitePath -ChildPath '*')) {
# Module functions do not see this script's -WhatIf: pass it on explicitly
$backup = Invoke-BookstoreBackup -Path $SitePath -Destination $BackupPath -KeepLast 5 -WhatIf:$WhatIfPreference
if ($backup) { Write-Output "==> backup: $($backup.Name)" }
}
$step = 'offline'
if ($PSCmdlet.ShouldProcess($SitePath, 'Take the site offline (app_offline.htm)')) {
Set-Content -Path $offlineFile -Value '<!doctype html><title>Bookstore</title><p>Bookstore is being updated. Please try again in a minute.</p>'
Write-Output '==> app_offline.htm written'
}
$step = 'copy'
if ($PSCmdlet.ShouldProcess($SitePath, "Copy files from $PackagePath")) {
Copy-Item -Path (Join-Path -Path $PackagePath -ChildPath '*') -Destination $SitePath -Recurse -Force
Write-Output "==> copied $((Get-ChildItem -Path $PackagePath -Recurse -File).Count) files"
}
$step = 'migrate'
if ($MigrationBundle -and $PSCmdlet.ShouldProcess($MigrationBundle, 'Apply EF Core migrations')) {
if (-not $env:BOOKSTORE_DB_CONNECTION) { throw 'BOOKSTORE_DB_CONNECTION is not set.' }
Write-Output '==> migrations'
& $MigrationBundle --connection $env:BOOKSTORE_DB_CONNECTION
}
$step = 'online'
if ($PSCmdlet.ShouldProcess($SitePath, 'Bring the site online (remove app_offline.htm)')) {
Remove-Item -Path $offlineFile
Write-Output '==> app_offline.htm removed'
}
$step = 'health'
if (-not $WhatIfPreference) {
$health = Wait-BookstoreHealthy -BaseUrl $HealthUrl -TimeoutSec $HealthTimeoutSec -InformationAction Continue
if (-not $health.Healthy) { throw "Health check failed for $($health.Url)" }
}
Write-Output ($WhatIfPreference ? 'WhatIf: nothing was changed' : 'Deploy OK')
}
catch {
Write-Output "FAILED at step '$step': $($_.Exception.Message)"
# The files are restored; a migration that already ran stays in the database
if ($backup -and $step -ne 'offline') {
Set-Content -Path $offlineFile -Value 'Bookstore is being restored.'
Expand-Archive -Path $backup.FullName -DestinationPath $SitePath -Force
Remove-Item -Path $offlineFile -ErrorAction Ignore
Write-Output "Restored $SitePath from $($backup.Name)"
}
exit 1
}
Every step that makes a change is guarded by ShouldProcess, so the deploy can first be written to the log with -WhatIf. The $step variable tells which step failed and whether a restore is needed. The connection string for the migration bundle comes from an environment variable, not a parameter; a parameter on the command line shows up in the process list and in the CI log. The lab has no IIS: the ASP.NET Core Module that sees app_offline.htm and stops the app does not run here, so I started the API from the site folder myself. Bookstore's database is SQL Server; in the lab I built the migration bundle with SQLite, and the script side is the same. First -WhatIf, then the real deploy:
$ pwsh -NoProfile -File ./scripts/Deploy-BookstoreApi.ps1 -PackagePath ./artifacts/api -SitePath ~/site -MigrationBundle ./artifacts/efbundle -HealthUrl http://127.0.0.1:5082 -WhatIf
What if: Performing the operation "Back up /home/user/site" on target "/home/user/bookstore/backups/bookstore-20261115-061137.zip".
What if: Performing the operation "Take the site offline (app_offline.htm)" on target "/home/user/site".
What if: Performing the operation "Copy files from ./artifacts/api" on target "/home/user/site".
What if: Performing the operation "Apply EF Core migrations" on target "./artifacts/efbundle".
What if: Performing the operation "Bring the site online (remove app_offline.htm)" on target "/home/user/site".
WhatIf: nothing was changed
$ ls backups ~/site/app_offline.htm
ls: cannot access 'backups': No such file or directory
ls: cannot access '/home/user/site/app_offline.htm': No such file or directory
$ export BOOKSTORE_DB_CONNECTION="Data Source=$HOME/data/bookstore.db"
$ pwsh -NoProfile -File ./scripts/Deploy-BookstoreApi.ps1 -PackagePath ./artifacts/api -SitePath ~/site -MigrationBundle ./artifacts/efbundle -HealthUrl http://127.0.0.1:5082; echo "exit=$?"
==> backup: bookstore-20261115-061138.zip
==> app_offline.htm written
==> copied 9 files
==> migrations
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20261115060804_Initial'.
Done.
==> app_offline.htm removed
health attempt 1: HTTP 200 Healthy
Deploy OK
exit=0
In the -WhatIf run no backup was taken and no app_offline.htm was created. In the real deploy the migration was applied and the health check passed on the first attempt. Now I make the API unhealthy and repeat the deploy:
$ touch /tmp/bookstore-unhealthy
$ export BOOKSTORE_DB_CONNECTION="Data Source=$HOME/data/bookstore.db"
$ pwsh -NoProfile -File ./scripts/Deploy-BookstoreApi.ps1 -PackagePath ./artifacts/api -SitePath ~/site -MigrationBundle ./artifacts/efbundle -HealthUrl http://127.0.0.1:5082 -HealthTimeoutSec 6; echo "exit=$?"
==> backup: bookstore-20261115-061140.zip
==> app_offline.htm written
==> copied 9 files
==> migrations
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
No migrations were applied. The database is already up to date.
Done.
==> app_offline.htm removed
health attempt 1: HTTP 503 Unhealthy
health attempt 2: HTTP 503 Unhealthy
health attempt 3: HTTP 503 Unhealthy
FAILED at step 'health': Health check failed for http://127.0.0.1:5082/health
Restored /home/user/site from bookstore-20261115-061140.zip
exit=1
$ rm /tmp/bookstore-unhealthy
$ ls backups
bookstore-20261115-061138.zip
bookstore-20261115-061140.zip
The health check got 503 for six seconds, the script restored the files from the backup it had taken before the deploy and exited with 1. This time the migration bundle said "already up to date"; had a migration been applied, it would not have been rolled back. That is why migrations need to be written so that the old code still works with them, expanding first and contracting later. We covered updating servers one by one in the "Rolling Deployment" post of the Deployment Strategies series; in the seventh post we run the same script on two servers through remoting.
In Production
The shorter the deploy script runs, the shorter the site stays offline. Bring the files to copy and the migration bundle to the server beforehand, so that the window opened by app_offline.htm holds only the copy and the migration. For locked files in IIS and Application Initialization, see post 10 of the Web Servers and Hosting series.
The health check is part of the deploy. The script's exit code should not only mean "the files were copied" but "the new version answers". The check means more if the health endpoint really touches the database and the services it depends on.
Keep secrets off the command line. Bearer tokens and connection strings should come from environment variables or a secret store; -Token wants a SecureString and refuses to send a token over plain HTTP. We look at taking credentials to a remote server without embedding them in a script in the seventh post.
Common Pitfalls
The default depth of ConvertTo-Json. The default depth is 2: every object nested deeper turns into a string, with a warning. Pass -Depth to every ConvertTo-Json that sends a body to an API; you can also turn the warning into an error with -WarningAction Stop.
Expecting a module function to see the script's -WhatIf. The preference trap from the previous post applies here too. In the first draft of the deploy script I had not written -WhatIf:$WhatIfPreference on the backup call; run from the console with -WhatIf:
PS> ./lab06/Deploy-Draft.ps1 -PackagePath ./artifacts/api -SitePath ~/site -WhatIf
==> backup: bookstore-20261115-061202.zip
What if: Performing the operation "Take the site offline (app_offline.htm)" on target "~/site".
What if: Performing the operation "Copy files from ./artifacts/api" on target "~/site".
What if: Performing the operation "Bring the site online (remove app_offline.htm)" on target "~/site".
WhatIf: nothing was changed
PS> (Get-ChildItem ./backups).Name
bookstore-20261115-061202.zip
The script said "nothing was changed" but took a real backup. Binary cmdlets such as Copy-Item and Remove-Item see -WhatIf; functions in a script module do not. Pass -WhatIf:$WhatIfPreference, and -Confirm:$ConfirmPreference when needed, to module functions explicitly.
Relative paths. ./data in a script is resolved against the working directory, and the working directory of a scheduled task is often C:\Windows\System32. Build every path from $PSScriptRoot.
A Small Experiment
★☆☆ Add a history array to every update in the price update body (@(@{ from = 12.99; to = 13.49 })). Is -Depth 5 still enough? Try turning ConvertTo-Json's warning into an error with -WarningAction Stop.
★★☆ Write a Get-BookstoreDailyReport function that fetches the daily report: it retries only on 503 and 429, at most 5 times, doubling the wait each time, and fails right away on 401 and 404. Without -MaximumRetryCount, using -SkipHttpErrorCheck.
★★★ Add a -KeepOffline switch to the deploy script: when the health check fails, it leaves app_offline.htm in place and exits instead of restoring the files. In which case is restoring right, and in which is keeping the site offline better? Compare the two with a release whose migration makes a schema change that cannot be undone.
The deploy script works on a single server. In the next post we take the same job to remote servers: SSH-based remoting, Invoke-Command, sessions, Copy-Item -ToSession, and using credentials with SecretManagement without embedding them in scripts.