Schedule an Azure VM to Start Daily and Deallocate Itself When Idle
Configure an Azure server VM to start itself daily, update Windows, and deallocate when idle, so it runs about an hour or two a day instead of 24/7.

On this page
TL;DR
A Logic App (opens in a new tab) starts the Azure VM. Its Recurrence trigger POSTs to the VM’s start endpoint as the Logic App’s system-assigned identity.
The VM turns itself off. A scheduled task gets a token from the instance metadata service. Then it POSTs to the deallocate endpoint as the VM’s own identity.
Both identities get one custom role with three actions: virtualMachines/read, start/action and deallocate/action. Assign it on that one VM.
Do not shut Windows down to turn the VM off. Azure leaves it Stopped, and a stopped VM still bills for compute. Only a deallocate stops the billing. The task deallocates only when the schedule started this boot. The game must also be stopped, or the server’s lp command must report zero players. Any other answer leaves the VM on.
Give the trigger a startTime one minute in the past. Without one, the Logic App fires the moment you save it. Set it to the scheduled minute and it skips the first day. With timeZone set to UTC, the startTime must end in Z, or the service refuses it. On a subscription that has never had a Logic App, run az provider register --namespace Microsoft.Logic --wait first.
What do zombies and servers have in common? I play a game called 7 Days to Die (opens in a new tab), and I’ve run a private test server for it for years. It has lived in a few places. When I had recurring Azure credits, I moved it there. The problem was that I kept burning through all of them. I needed the server on when I wanted to play and when it needed updates, and off the rest of the time, so some credits were left for trying other Azure features.
On Halloween morning, my server’s nightly update failed. SteamCMD printed Error! App '294420' state is 0x606 after update job. in my update script’s log. The script did what I’d built it to do: it left the game stopped rather than start it on a half-installed build. I didn’t notice for two days. The game was dead the whole time, and the VM didn’t know it. It kept running, and billing, until I found it and turned it off by hand.
That VM had no reason to be on most of the day. It ran around the clock so that a backup at 11:00 and an update at 11:30 could each take a few minutes. So I gave it a daily window instead: it starts itself, runs those tasks and the Windows updates, and turns itself off again when nobody is playing. A failed update can still happen, but now the VM goes off afterward instead of billing, and the next morning’s boot starts the game again. Every piece is built here, so you don’t need my module.
1. Confirm the prerequisites
You need:
- An Azure VM running Windows with a 7 Days to Die server on it. I tested on Windows Server 2019.
- The Azure CLI on your own machine, signed in as someone who can create role definitions and role assignments. Creating the role and its assignments takes Owner or User Access Administrator on the subscription. The other steps also change the VM and create a Logic App, which Owner covers.
- Telnet enabled in the server’s
serverconfig.xml(TelnetEnabledset totrue, with aTelnetPort). The player check in step 7 asks the server over telnet, and telnet is also how the script stops the server cleanly. Start a 7 Days to Die Dedicated Server at Boot on Windows and Stop It Cleanly covers both. - A task that starts the server at boot, a daily backup task and a daily update task. The articles on backing up the server to S3 and on updating it only on a new build build the last two. The script in step 11 waits for
7DaysToDieServerS3Backupand7DaysToDieServerAutoUpdate, named in$WaitFor. If yours are named differently, change$WaitForto match. A name that doesn’t match is skipped, so the VM can deallocate under a running backup or update.
2. Lay out the window
Five things happen in the window, in this order. Each one starts only after the one before it has had time to finish.
| UTC | What runs |
|---|---|
| 10:45 | The Logic App starts the VM. |
| After boot | The autostart task brings the game up. |
| 11:00 | The backup task runs. |
| 11:30 | The update task runs. |
| 12:00 | The maintenance task installs Windows updates, then deallocates the VM. |
If you move one time, keep the same order. Keep the start before the backup by enough to cover the boot and the game’s startup. Keep the maintenance task after the update task, so it never stops the game under an update. The maintenance task also waits for the backup and update tasks if they are still running, but it gives up after 60 minutes and leaves the VM on.
The window runs about an hour or two a day instead of 24, depending on the work. A game update to download or Windows updates to install make it longer.
When a log line ends “VM left on”, the VM stays on, and tomorrow’s run doesn’t turn it off either, because the schedule didn’t start that boot. Read the log and deallocate it by hand.
3. Create the custom role
The role lets an identity read a VM, start it and deallocate it, and nothing else. Virtual Machine Contributor would work, but it also lets an identity resize the VM, swap its disks and reset its password, which neither of these identities needs.
Steps 3 to 6 and 13 share variables. Run the commands meant for your own machine in one PowerShell session, starting with your subscription ID, which step 5 needs too:
$subscription = az account show --query id -o tsvBefore you create the role, check whether it already exists with az role definition list --name 'Game Server VM Power' --custom-role-only true. If that returns the role, skip the create. Otherwise this makes the role definition in your subscription:
$definition = @{
Name = 'Game Server VM Power'
Description = 'Read, start and deallocate a virtual machine. Nothing else.'
Actions = @(
'Microsoft.Compute/virtualMachines/read'
'Microsoft.Compute/virtualMachines/start/action'
'Microsoft.Compute/virtualMachines/deallocate/action'
)
AssignableScopes = @("/subscriptions/$subscription")
}
$definition | ConvertTo-Json | Set-Content role.json -Encoding ascii
az role definition create --role-definition "@role.json" -o noneThe role is assignable anywhere in the subscription, so one definition serves every VM you add later.
4. Enable the VM’s identity and assign the role
Your VM needs a system-assigned managed identity: an Azure identity that belongs to the VM itself, with credentials Azure keeps for you. With it, the VM can ask Azure to deallocate it. This turns the identity on and grants it the role on that one VM:
$rg = '<resource-group>'
$vmName = '<vm-name>'
$vm = az vm show -g $rg -n $vmName -o json | ConvertFrom-Json
$vmPrincipal = (az vm identity assign -g $rg -n $vmName -o json | ConvertFrom-Json).systemAssignedIdentity
az role assignment create --assignee-object-id $vmPrincipal --assignee-principal-type ServicePrincipal `
--role 'Game Server VM Power' --scope $vm.id -o none--assignee-object-id with --assignee-principal-type skips the directory lookup that --assignee does in Microsoft Graph. That lookup fails for a few minutes after you create an identity. See Cannot find user or service principal in graph database.
5. Create the Logic App that starts the VM
The start half is a Logic App, Azure’s hosted workflow service, on the pay-per-run Consumption plan. It has a Recurrence trigger and one HTTP action. The action POSTs to the VM’s start endpoint and authenticates as the Logic App’s own system-assigned identity. Use a Logic App rather than Azure Automation, which needs a script (a runbook) that you keep up to date. A Logic App is only a timer and one web request.
Other clouds have their own timer for the start half. On AWS, an EventBridge Scheduler universal target (opens in a new tab) can call the EC2 StartInstances API directly. On Google Cloud, a Compute Engine instance schedule (opens in a new tab) starts and stops a VM at set times.
First check that your subscription is registered for the Logic App resource provider. If it has never had a Logic App, it isn’t, and Azure refuses the create with MissingSubscriptionRegistration:
az provider show --namespace Microsoft.Logic --query registrationState -o tsvIf that prints anything other than Registered, register it. This took under two minutes for me:
az provider register --namespace Microsoft.Logic --waitThis creates the Logic App and turns on its identity. It starts the VM at 10:45 UTC. Change hours and minutes for another time:
$appName = "$vmName-daily-start"
# One minute in the past, with the Z. See Common failures for why each part matters.
$first = [datetime]::UtcNow.AddMinutes(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
$uri = "https://management.azure.com/subscriptions/$subscription/resourceGroups/$rg/providers/Microsoft.Logic/workflows/$($appName)?api-version=2019-05-01"
$workflow = @{
location = $vm.location
identity = @{ type = 'SystemAssigned' }
properties = @{
state = 'Enabled'
definition = @{
'$schema' = 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#'
contentVersion = '1.0.0.0'
triggers = @{
Daily = @{
type = 'Recurrence'
recurrence = @{
frequency = 'Day'
interval = 1
timeZone = 'UTC'
startTime = $first
schedule = @{ hours = @(10); minutes = @(45) }
}
}
}
actions = @{
Start_VM = @{
type = 'Http'
inputs = @{
method = 'POST'
uri = "https://management.azure.com$($vm.id)/start?api-version=2024-07-01"
authentication = @{ type = 'ManagedServiceIdentity'; audience = 'https://management.azure.com/' }
}
}
}
}
}
}
$workflow | ConvertTo-Json -Depth 20 | Set-Content workflow.json -Encoding ascii
$app = az rest --method put --uri $uri --body "@workflow.json" -o json | ConvertFrom-JsonBecause startTime is in the past, nothing runs when you save it. Without a startTime, it runs at once and fails with Forbidden, because its identity holds no role until the next step.
6. Assign the role to the Logic App’s identity
The Logic App can’t start the VM until its own identity holds the role. List the assignments on the VM by scope and role and filter on principalId. That form works on an identity that is seconds old; --assignee doesn’t.
$appPrincipal = $app.identity.principalId
$held = az role assignment list --scope $vm.id --role 'Game Server VM Power' -o json | ConvertFrom-Json |
Where-Object { $_.principalId -eq $appPrincipal }
if (-not $held) {
az role assignment create --assignee-object-id $appPrincipal --assignee-principal-type ServicePrincipal `
--role 'Game Server VM Power' --scope $vm.id -o none
}7. Check the player count over telnet
From here you work on the VM. Steps 7 to 11 build one script, maintenance.ps1, which runs there in Windows PowerShell 5.1. Step 11 gives the order you assemble it in.
While the game is running, the decision to turn the machine off rests on what the server reports, not on the time of day. The server’s lp command lists connected players and ends with a line such as Total of 1 in the game. The function returns that number. For anything else it returns $null: telnet off, no connection, a wrong password, or no Total of line. Your script treats $null as “someone might be on”.
The function takes the last Total of line in the reply, because the console can echo an earlier listing that was still in its buffer. It connects to 127.0.0.1 and sends exit, which closes only the telnet session. It never sends shutdown.
function ConvertFrom-PlayerListing {
param([AllowEmptyString()][string]$Text)
if (-not $Text -or $Text -match 'Password incorrect') { return $null }
$found = [regex]::Matches($Text, 'Total of (\d+) in the game')
if ($found.Count -eq 0) { return $null }
[int]$found[$found.Count - 1].Groups[1].Value
}
function Get-ServerPlayerCount {
param(
[int]$Port = 8081,
[string]$Password,
[int]$TimeoutSeconds = 15
)
$client = [Net.Sockets.TcpClient]::new()
try {
if (-not $client.ConnectAsync('127.0.0.1', $Port).Wait($TimeoutSeconds * 1000)) { return $null }
$stream = $client.GetStream()
$writer = [IO.StreamWriter]::new($stream)
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
if ($Password) { $writer.WriteLine($Password); Start-Sleep -Milliseconds 500 }
$writer.WriteLine('lp')
$buffer = New-Object byte[] 4096
$text = ''
$count = $null
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
while ($stream.DataAvailable) {
$read = $stream.Read($buffer, 0, $buffer.Length)
$text += [Text.Encoding]::UTF8.GetString($buffer, 0, $read)
}
if ($text -match 'Password incorrect') { break }
$count = ConvertFrom-PlayerListing -Text $text
if ($null -ne $count) { break }
Start-Sleep -Milliseconds 250
}
try { $writer.WriteLine('exit') } catch { }
$count
} catch {
$null
} finally {
$client.Dispose()
}
}On my server with nobody connected, my module’s version of this function returned 0.
8. Test whether the schedule started this boot
The task turns the VM off only when the schedule started this boot. A VM you start by hand to play stays on. The test checks whether the boot falls in a window that opens at the scheduled start time. The script in step 11 sets that window to 30 minutes.
function Test-ScheduledBoot {
param(
[Parameter(Mandatory)][datetime]$BootTimeUtc,
[Parameter(Mandatory)][string]$StartAtUtc,
[Parameter(Mandatory)][int]$WindowMinutes
)
$time = [datetime]::ParseExact($StartAtUtc, 'HH:mm', [Globalization.CultureInfo]::InvariantCulture)
$start = $BootTimeUtc.Date.Add($time.TimeOfDay)
$BootTimeUtc -ge $start -and $BootTimeUtc -lt $start.AddMinutes($WindowMinutes)
}With a start of 10:45 and a window of 30, a boot at 10:47 counts as scheduled. Boots at 10:44, 11:15 and 19:30 don’t. A boot you start by hand at 11:00 counts as scheduled, because the function sees only the time. Keep your own play sessions outside that window.
9. Search for and install Windows updates
A VM that is deallocated most of the day never reaches the operating system’s own update schedule, so the window installs them itself. PowerShell can call the Windows Update Agent, the update component built into Windows, directly. The task runs as SYSTEM, and you install no extra module.
function Test-PendingReboot {
$keys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
)
foreach ($key in $keys) { if (Test-Path $key) { return $true } }
$false
}
function Install-PendingWindowsUpdate {
param([switch]$SearchOnly)
$session = New-Object -ComObject Microsoft.Update.Session
$search = $session.CreateUpdateSearcher().Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
$titles = @()
for ($i = 0; $i -lt $search.Updates.Count; $i++) { $titles += $search.Updates.Item($i).Title }
$result = [pscustomobject]@{ Found = $titles; Installed = @(); Failed = @(); RebootRequired = $false }
if ($titles.Count -eq 0 -or $SearchOnly) {
$result.RebootRequired = Test-PendingReboot
return $result
}
# Untested from here to the end of the function: no update was offered.
$collection = New-Object -ComObject Microsoft.Update.UpdateColl
for ($i = 0; $i -lt $search.Updates.Count; $i++) {
$update = $search.Updates.Item($i)
if (-not $update.EulaAccepted) { $update.AcceptEula() }
[void]$collection.Add($update)
}
$downloader = $session.CreateUpdateDownloader()
$downloader.Updates = $collection
[void]$downloader.Download()
$installer = $session.CreateUpdateInstaller()
$installer.Updates = $collection
$outcome = $installer.Install()
# ResultCode 2 is succeeded and 3 is succeeded with errors; 3 counts as installed.
for ($i = 0; $i -lt $collection.Count; $i++) {
$code = $outcome.GetUpdateResult($i).ResultCode
if ($code -in 2, 3) { $result.Installed += $collection.Item($i).Title }
else { $result.Failed += "$($collection.Item($i).Title) (result $code)" }
}
$result.RebootRequired = $outcome.RebootRequired -or (Test-PendingReboot)
$result
}The function never restarts the machine.
10. Deallocate the VM from inside
Shutting Windows down from inside leaves an Azure VM in the Stopped state, which still bills for compute. Only Azure can deallocate it, releasing its hardware so compute billing stops. So the VM asks Azure, using its managed identity. The first two calls go to the instance metadata service at 169.254.169.254, a local endpoint that tells a VM about itself and answers only from inside it. The last goes to Azure itself:
- Read the VM’s resource ID from the compute metadata.
- Get a token for
management.azure.com. - POST to the resource’s
deallocateendpoint with that token.
Windows PowerShell 5.1 sends a POST with no body and no Content-Length header, so the deallocate call always sends a body, even an empty {}.
function Get-AzureResourceId {
try {
(Invoke-RestMethod -Headers @{ Metadata = 'true' } -TimeoutSec 5 -ErrorAction Stop `
-Uri 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01').resourceId
} catch {
$null
}
}
function Invoke-AzureSelfDeallocate {
param([Parameter(Mandatory)][string]$ResourceId)
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$token = Invoke-RestMethod -Headers @{ Metadata = 'true' } -TimeoutSec 10 -ErrorAction Stop `
-Uri 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F'
$response = Invoke-WebRequest -UseBasicParsing -Method Post -ErrorAction Stop `
-Uri "https://management.azure.com$ResourceId/deallocate?api-version=2024-07-01" `
-Headers @{ Authorization = "Bearer $($token.access_token)" } `
-ContentType 'application/json' -Body '{}'
[int]$response.StatusCode
}Azure answers 202 when it accepts the request. Azure shuts the operating system down a few seconds later, so write anything you still need before you call it.
11. Chain the checks in one script
The order of the file is what makes the pieces safe. Build one file, saved as C:\7DaysToDieServer\maintenance.ps1 or wherever your tasks live, in this order:
- The
param([switch]$DryRun)line from the top of the script below. It must be the first line of the file: PowerShell readsparamas the script’s parameters only when nothing but comments comes before it. Anywhere else, PowerShell ignores-DryRunand your rehearsal runs for real. - The functions from steps 7 to 10.
- The
Stop-GameServerfunction from step 4 of Start a 7 Days to Die Dedicated Server at Boot on Windows and Stop It Cleanly. The task starts a freshpowershell.exeas SYSTEM, so a function defined only in your own session isn’t there when the task runs. - The rest of the script below, starting at
$ErrorActionPreference.
Set the values at the top of the script to match your server and your tasks. When it runs, the script does this, in order:
- Exit if the machine booted under 15 minutes ago. That is the startup trigger’s run in step 12.
- Wait for the backup and update tasks.
- Check the players.
- Search for and install Windows updates.
- Stop if the schedule didn’t start this boot.
- Check the players again, because someone could have joined during the updates.
- Stop the server over telnet, so the world is saved.
- Deallocate.
This script leaves out one branch that my own module has: restarting the machine to finish updates that need it, and resuming the run afterward. When Windows reports a pending restart, this script logs that and carries on. The startup trigger exists for that branch, so without it the trigger does nothing.
param([switch]$DryRun) # the first line of the file, above the functions
$ErrorActionPreference = 'Stop'
$StartAtUtc = '10:45'
$WindowMinutes = 30
$TelnetPort = 8081
$TelnetPassword = ''
$GameTask = '7DaysToDieServer'
$WaitFor = '7DaysToDieServerS3Backup', '7DaysToDieServerAutoUpdate'
$LogFile = 'C:\ProgramData\MaintenanceWindow\maintenance.log'
New-Item -ItemType Directory -Force (Split-Path $LogFile) | Out-Null
function Write-Log([string]$Message) {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message"
}
try {
$os = Get-CimInstance Win32_OperatingSystem
if (((Get-Date) - $os.LastBootUpTime).TotalMinutes -lt 15) {
Write-Log 'Under 15 minutes since boot: the startup run. Nothing to do.'
return
}
$bootUtc = $os.LastBootUpTime.ToUniversalTime()
$scheduled = Test-ScheduledBoot -BootTimeUtc $bootUtc -StartAtUtc $StartAtUtc -WindowMinutes $WindowMinutes
Write-Log "Booted $($bootUtc.ToString('yyyy-MM-dd HH:mm')) UTC. Started by: $(if ($scheduled) { 'the schedule' } else { 'hand' })"
# The backup must finish before the machine goes away.
$waitEnd = (Get-Date).AddMinutes(60)
while ($true) {
$busy = @($WaitFor | Where-Object { (Get-ScheduledTask -TaskName $_ -ErrorAction SilentlyContinue).State -eq 'Running' })
if (-not $busy) { break }
if ((Get-Date) -gt $waitEnd) { Write-Log "Still running after 60 minutes: $($busy -join ', '). VM left on."; return }
Write-Log "Waiting for $($busy -join ', ')"
Start-Sleep -Seconds 30
}
# Anything but a clean 0 counts as occupied.
$running = [bool](Get-Process -Name '7DaysToDieServer' -ErrorAction SilentlyContinue)
$players = 0
if ($running) { $players = Get-ServerPlayerCount -Port $TelnetPort -Password $TelnetPassword }
Write-Log "Players online: $(if ($null -eq $players) { 'unknown' } else { $players })"
if ($players -ne 0) { Write-Log 'Someone is or may be playing. VM left on.'; return }
try { $updates = Install-PendingWindowsUpdate -SearchOnly:$DryRun }
catch {
Write-Log "WARNING: Windows Update did not run: $($_.Exception.Message)"
$updates = [pscustomobject]@{ Found = @(); Installed = @(); Failed = @(); RebootRequired = (Test-PendingReboot) }
}
Write-Log "Windows Update offered: $($updates.Found.Count). Restart required: $($updates.RebootRequired)"
foreach ($title in $updates.Installed) { Write-Log " installed: $title" }
foreach ($title in $updates.Failed) { Write-Log " FAILED: $title" }
if (-not $scheduled) { Write-Log 'Started by hand, so it stays on.'; return }
$resourceId = Get-AzureResourceId
if (-not $resourceId) { Write-Log 'No Azure instance metadata. Nothing to deallocate.'; return }
if ($running) {
$late = Get-ServerPlayerCount -Port $TelnetPort -Password $TelnetPassword
if ($late -ne 0) { Write-Log 'A player may have joined. VM left on.'; return }
}
if ($DryRun) { Write-Log "Dry run: would stop the server and deallocate $resourceId."; return }
# Stop-GameServer, pasted into this file from the start-at-boot article,
# throws unless exactly one server process is running. A game that is
# already down has nothing to save, so go straight to the deallocate.
if (Get-Process -Name '7DaysToDieServer' -ErrorAction SilentlyContinue) {
$secure = $null
if ($TelnetPassword) { $secure = ConvertTo-SecureString $TelnetPassword -AsPlainText -Force }
Write-Log (Stop-GameServer -Port $TelnetPort -Password $secure)
}
Write-Log "Deallocating $resourceId"
try {
$status = Invoke-AzureSelfDeallocate -ResourceId $resourceId
Write-Log "Azure accepted the deallocate (HTTP $status)."
} catch {
# Bring the game back rather than leave a running VM with nothing on it.
Write-Log "ERROR: deallocate refused: $($_.Exception.Message)"
Start-ScheduledTask -TaskName $GameTask
throw
}
} catch {
Write-Log "ERROR: $($_.Exception.Message)"
exit 1
}
exit 012. Register the maintenance task
This registers the task on the VM. It runs daily at 12:00 and again 5 minutes after startup, as SYSTEM, with a three-hour limit. MultipleInstances IgnoreNew stops a second copy starting while one runs. It leaves out StartWhenAvailable on purpose. If the VM is off at 12:00, you don’t want that missed run to fire when you boot it at 20:00 to play.
The machine clock on my VM was UTC. If yours isn’t, -At '12:00' means 12:00 on its clock, so convert the time.
$script = 'C:\7DaysToDieServer\maintenance.ps1'
$daily = New-ScheduledTaskTrigger -Daily -At '12:00'
$startup = New-ScheduledTaskTrigger -AtStartup
$startup.Delay = 'PT5M'
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy RemoteSigned -File `"$script`""
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 3) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName '7DaysToDieServerMaintenance' -Action $action -Trigger @($daily, $startup) `
-Settings $settings -User 'SYSTEM' -RunLevel HighestBoth triggers run the same action, so the script can’t tell which one fired. It uses uptime instead: under 15 minutes since boot means the startup run, and the script exits. It skips a daily run that lands within 15 minutes of a boot the same way, such as when you start the VM by hand at 11:58.
13. Confirm the window works
Run these in order. Each proves one claim.
A hand-started VM stays on. Start the VM yourself outside the 30-minute window, wait more than 15 minutes, then run the task on the VM and read the log:
Start-ScheduledTask -TaskName '7DaysToDieServerMaintenance'
Get-Content 'C:\ProgramData\MaintenanceWindow\maintenance.log' -Tail 5The log ends with Started by hand, so it stays on. and the VM keeps running. To rehearse a run without installing or deallocating anything, run maintenance.ps1 -DryRun from an elevated session instead.
A scheduled boot deallocates. Use a VM that has been on and idle for more than 15 minutes. Set $StartAtUtc in maintenance.ps1 to two minutes before the VM’s boot time in UTC, so the boot counts as scheduled, and run the task. Read the boot time on the VM with (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime(). Then poll from your own machine:
az vm get-instance-view -g $rg -n $vmName --query "instanceView.statuses[1].displayStatus" -o tsvThe log shows Azure accepted the deallocate (HTTP 202). and the status reads VM deallocated. For me that took under a minute from the start of the run. Put $StartAtUtc back afterward.
The Logic App starts the VM. With the VM deallocated, fire the trigger by hand instead of waiting for 10:45. This boots the VM outside the window, so the maintenance task leaves it on. Deallocate it by hand with az vm deallocate -g $rg -n $vmName when the test is done.
az rest --method post --uri "https://management.azure.com$($app.id)/triggers/Daily/run?api-version=2019-05-01"In the Logic App’s run history in the Azure portal, the Start_VM action shows Running, then Accepted, and the VM boots.
The right identities did it. Open the VM’s activity log in the Azure portal. The deallocate shows the VM’s own identity as its caller and the start shows the Logic App’s.
The schedule runs on its own. The next morning, read the logs instead of firing anything. My first scheduled window went the way the table in step 2 lays out. The Logic App started the VM a minute after 10:45, the backup uploaded, and the update found the server already on the published build. At 12:00 my module’s maintenance log read Started by: the schedule (10:45 UTC), Players online: 0, Offered: 0, then Azure accepted the deallocate (HTTP 202)., and the VM deallocated. Those lines come from my module’s log; the script in step 11 words the update line as Windows Update offered: 0.
Now the VM rises at 10:45, does its chores, and is back in the ground by lunch.
Tested on
Tested with
- VM size
- Standard_B4ls_v2
- OS
- Windows Server 2019 Datacenter, image 17763.5206.231202
- PowerShell on the VM
- Windows PowerShell 5.1
- Machine clock
- UTC
- Azure CLI
- on the admin machine
- Game
- 7 Days to Die dedicated server (294420)
- Date
- November 2026
What I didn’t test
My own server runs a module version of this logic, not the single script in step 11, so run the confirmation steps in step 13 before you trust your copy with the schedule.
Three things matter if your server differs from mine:
- Installing updates. No update was waiting on the mornings I ran this, so only the search has run. The first morning one is offered, read the maintenance log afterward.
- A player online. I only ever saw a count of 0. If people play during your window, test that the VM stays on before you rely on it.
- A telnet password. I didn’t run it with one set. If your server has one, check that the player count and the stop both still work.
Common failures
Logic App run at creation failed with Forbidden
The run history shows a run at the moment you created the Logic App, with status Failed and the Start_VM action returning Forbidden. You created the Logic App without a startTime, so it ran once on save, before its identity held a role. Assign the role in step 6 and set startTime as in step 5. The scheduled runs after that aren’t affected.
InvalidWorkflowTriggerRecurrence: invalid start time
Error
InvalidWorkflowTriggerRecurrence ... invalid start time '2026-11-02T10:45:00.0000000' zone designator for time zone 'UTC'. Expected start time format with zone designator is 'yyyy-MM-ddTHH:mm:ssZ'.With timeZone set to UTC the service requires the trailing Z. Format the time like this:
[datetime]::UtcNow.AddMinutes(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")Logic App skipped the first day
There is no error. You set startTime to the scheduled minute, and nextExecutionTime comes back a day late. The service reads that occurrence as already taken. Set startTime to one minute in the past, as in step 5.
MissingSubscriptionRegistration for Microsoft.Logic
Error
MissingSubscriptionRegistration ... The subscription is not registered to use namespace 'Microsoft.Logic'The subscription has never had a Logic App. Register the provider and run the create again:
az provider register --namespace Microsoft.Logic --waitCannot find user or service principal in graph database
Error
Cannot find user or service principal in graph databaseaz role assignment list --assignee <principal-id> looks the identity up in Microsoft Graph, and a new identity isn’t there yet. List by scope and role and filter on principalId, and create the assignment with --assignee-object-id and --assignee-principal-type ServicePrincipal, as in steps 4 and 6.