Automatically shut down, scale down, and clean up non-production Azure resources on a schedule. Runs as an Azure Automation PowerShell 7.2 runbook authenticating with a managed identity — no stored credentials, no service principal secrets to rotate.
New deployment
Upgrade an existing deployment (refreshes the script and modules only, leaves your identity, schedule, and role assignments alone)
The runbook ships in report-only mode. dryRun defaults to true, which logs every
action it would take and changes nothing. Deploy it, let one job run, read the output, and
only then redeploy with dryRun: false.
Three independent exemption mechanisms protect anything you care about:
| Exemption | Effect |
|---|---|
Resource group or resource tagged env = production |
Skipped entirely. Tag name and value are configurable. |
Any resource tagged CostOptimizerExempt = true / yes / 1 |
Skipped entirely. |
Resource group with a ManagedBy value |
Skipped — covers AKS node pools, Databricks, and similar service-managed groups. |
AKS node resource groups are additionally discovered and excluded even when Azure does not
stamp them with ManagedBy.
Destructive actions are marked ⚠.
| # | Action | Default |
|---|---|---|
| 1 | Deallocate running VMs | on |
| 2 | Deallocate running VM Scale Sets | on |
| 3 | Stop running AKS clusters | on |
| 4 | Stop running Container Instances | on |
| 5 | Stop running Application Gateways | on |
| 6 | Deallocate Azure Firewalls (VNet-based only) | on |
| 7 | Downsize Azure SQL databases to the cheapest SKU that still fits the data | on |
| 8 | Suspend Synapse / standalone SQL Data Warehouse pools | on |
| 9 | Convert Premium managed disks on deallocated VMs to Standard | on |
| 10 | ⚠ Delete managed disks unattached for more than 7 days | on |
| 11 | ⚠ Delete unattached public IP addresses | on |
| 12 | ⚠ Delete App Service Plans hosting zero sites | on |
| 13 | ⚠ Delete snapshots older than 90 days | off |
| 14 | ⚠ Delete orphaned NICs | off |
| 15 | Report on expensive resources needing a human decision (VPN gateways, Bastion, Redis, Cosmos DB, idle load balancers, premium storage) | always |
The SQL pass sizes each database from its actual 12-hour peak storage metric, never moves a database up a tier, and refuses to touch elastic-pool members or Hyperscale databases — those are reported instead.
Use the Deploy to Azure button above. At minimum supply an Automation account name; the defaults handle everything else.
az group create --name rg-cost-optimizer --location eastus2
az deployment group create \
--resource-group rg-cost-optimizer \
--template-file infra/main.bicep \
--parameters infra/main.bicepparam \
--parameters automationAccountName=shutdown-azure-resourcesPreview first with az deployment group what-if in place of create.
New-AzResourceGroup -Name rg-cost-optimizer -Location eastus2
New-AzResourceGroupDeployment `
-ResourceGroupName rg-cost-optimizer `
-TemplateFile ./infra/main.bicep `
-TemplateParameterFile ./infra/main.bicepparam `
-automationAccountName shutdown-azure-resourcesThe schedule's first job runs the day after deployment, so start one by hand to see the dry-run output immediately:
$job = Start-AzAutomationRunbook `
-ResourceGroupName rg-cost-optimizer `
-AutomationAccountName shutdown-azure-resources `
-Name ShutdownAzureResources
Get-AzAutomationJobOutput -ResourceGroupName rg-cost-optimizer `
-AutomationAccountName shutdown-azure-resources -Id $job.JobId -Stream OutputOr use Automation account → Runbooks → ShutdownAzureResources → Start in the portal. A
manual start uses the script's own defaults, which include DryRun = $true.
Once you are happy with the dry-run output, re-register the schedule link with
DryRun = $false:
$common = @{
ResourceGroupName = 'rg-cost-optimizer'
AutomationAccountName = 'shutdown-azure-resources'
RunbookName = 'ShutdownAzureResources'
ScheduleName = 'Nightly-Shutdown'
}
Unregister-AzAutomationScheduledRunbook @common -Force
Register-AzAutomationScheduledRunbook @common -Parameters @{ DryRun = $false }Why not just redeploy? Azure Automation job schedules are not idempotent in ARM. Redeploying a template containing one fails with "A job schedule for the specified runbook and schedule already exists" — and a fresh GUID does not help, because the conflict is on the runbook-plus-schedule pair rather than the name. Re-registering is the reliable path.
If you do need to redeploy
main.bicepagainst an account that already has the link, passcreateJobSchedule=falseso the template skips it:az deployment group create \ --resource-group rg-cost-optimizer \ --template-file infra/main.bicep \ --parameters infra/main.bicepparam \ --parameters automationAccountName=shutdown-azure-resources createJobSchedule=falseRoutine script and module upgrades use
infra/upgrade.bicep, which contains no schedule resources and is safe to redeploy as often as you like.
The deployment creates a system-assigned managed identity and grants it Contributor on
the subscription you deploy into. That role assignment requires you to be Owner or User
Access Administrator; if you are not, deploy with assignSubscriptionRole=false and have
someone grant it separately:
PRINCIPAL_ID=$(az deployment group show \
--resource-group rg-cost-optimizer --name main \
--query properties.outputs.runbookPrincipalId.value -o tsv)
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role Contributor \
--scope /subscriptions/<subscription-id>The runbook processes every enabled subscription its identity can see. Repeat the command above for each additional subscription, or grant the role once at a management group scope:
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role Contributor \
--scope /providers/Microsoft.Management/managementGroups/<mg-id>Restrict the scope explicitly with the subscriptionIds parameter if you would rather not
process everything the identity can reach.
The deployment wires the common settings (dryRun, subscriptionIds, the exemption tags)
into the schedule directly. Every other Runbook.ps1 parameter goes through
runbookParameterOverrides:
param runbookParameterOverrides = {
DeleteOldSnapshots: 'true'
SnapshotMinAgeDays: '180'
TargetDiskSku: 'Standard_LRS'
UseServerlessForSql: 'true'
}Azure Automation parses each value as JSON, so booleans must be the bare literals true or
false and numbers must be unquoted digits — all wrapped in Bicep strings as shown.
Available switches: StopVirtualMachines, StopScaleSets, StopAksClusters,
StopContainerInstances, StopApplicationGateways, DeallocateFirewalls,
DownsizeSqlDatabases, SuspendDataWarehouses, ConvertDisksToStandard,
DeleteUnattachedDisks, DeleteUnattachedPublicIps, DeleteEmptyAppServicePlans,
DeleteOldSnapshots, DeleteOrphanedNics. Tuning knobs: UnattachedDiskMinAgeDays,
SnapshotMinAgeDays, TargetDiskSku, DeallocationWaitMinutes, UseServerlessForSql.
| Parameter | Default | Notes |
|---|---|---|
automationAccountName |
(required) | 6–50 characters. |
location |
resource group location | Any region supporting Azure Automation. |
sku |
Free |
Free includes 500 job minutes/month. Use Basic for large estates. |
dryRun |
true |
Report-only. Set false to let it act. |
subscriptionIds |
[] |
Empty means every enabled subscription the identity can see. |
exemptTagName / exemptTagValue |
env / production |
Exemption tag pair. |
resourceExemptTagName |
CostOptimizerExempt |
Per-resource opt-out tag. |
runbookParameterOverrides |
{} |
Any other runbook parameter. See above. |
createSchedule |
true |
Creates the recurring schedule. |
createJobSchedule |
true |
Links the runbook to the schedule. Set false on redeploys — see Going live. |
scheduleFrequency |
Day |
Hour, Day, Week, or Month. |
scheduleTimeOfDay |
19:00:00 |
First run is the day after deployment at this time. |
scheduleTimeZone |
Etc/UTC |
IANA zone, e.g. America/New_York, to follow daylight saving. |
assignSubscriptionRole |
true |
Needs Owner / User Access Administrator. |
roleDefinitionId |
Contributor GUID | Swap for a narrower custom role if you have one. |
userAssignedIdentityResourceId |
'' |
Use an existing user-assigned identity instead of system-assigned. |
logAnalyticsWorkspaceId |
'' |
Set to collect JobLogs and JobStreams. |
powerShellModules |
pinned Az.* set |
Empty version on any entry pulls latest from the gallery. |
disableLocalAuth |
true |
Blocks key-based auth. Entra ID auth is unaffected. |
runbookContentVersion |
2.0.0.0 |
Bump this to make Automation re-fetch the script. |
The runbook records restore hints as tags before it changes anything:
| Tag | Written on |
|---|---|
costopt-stoppedOn |
VMs, before deallocation |
costopt-originalSku, costopt-originalMaxSizeBytes |
SQL databases, before downsizing |
costopt-originalDiskSku |
Managed disks, before conversion to Standard |
Deletions are not reversible — that is why disk deletion has a minimum age, and snapshot and NIC deletion are off by default.
infra/main.bicep Source of truth for a new deployment
infra/upgrade.bicep Refreshes script + modules on an existing account
infra/modules/role-assignment.bicep Subscription-scope RBAC grant
infra/main.bicepparam Example parameter file
azuredeploy.json Compiled from infra/main.bicep — do not edit by hand
azureUpgrade.json Compiled from infra/upgrade.bicep — do not edit by hand
azuredeploy.parameters.json ARM-format parameter file
Runbook.ps1 The runbook itself
azuredeploy.json and azureUpgrade.json are committed compiled artifacts so the portal
buttons keep working. After editing any .bicep file, regenerate them:
bicep build infra/main.bicep --outfile azuredeploy.json
bicep build infra/upgrade.bicep --outfile azureUpgrade.jsonCI fails the build if they drift.
Version 2 is a breaking change. The old deployment stored an Azure username and password plus a SendGrid credential, and side-loaded AzureRM 3.5.0 module zips from a public blob container. None of that is used any more.
| v1 | v2 |
|---|---|
| AzureRM modules from a blob container | Az.* modules from the PowerShell Gallery, imported serially |
| Username/password Automation credential | System-assigned managed identity |
| SendGrid credential for email | Removed — use Log Analytics or an Action Group |
$SubscriptionFilter wildcard string |
subscriptionIds array (empty = all visible) |
| PowerShell 5.1 runbook | PowerShell 7.2 runbook |
| Runbook location limited to 7 regions | Any region supporting Azure Automation |
| Acted immediately | dryRun defaults to on |
To migrate, deploy fresh with the button above, confirm a dry run looks right, then delete
the old Automation account and its stored credentials. The old
azuredeploy.parameters.json is not compatible.
Do not point the v2 upgrade template at a v1 Automation account: the old account has AzureRM modules and a PowerShell 5.1 runbook of the same name, and mixing the two runtimes causes assembly conflicts.
bicep build infra/main.bicep --stdout # compile and lint
bicep build-params infra/main.bicepparam # validate the parameter file
az deployment group what-if \
--resource-group rg-cost-optimizer \
--template-file infra/main.bicep \
--parameters infra/main.bicepparam \
--parameters automationAccountName=shutdown-azure-resources
pwsh -c "Invoke-ScriptAnalyzer -Path ./Runbook.ps1 -Severity Error,Warning"GitHub Actions runs all of the above on every push and pull request.
- Job schedules are not idempotent in ARM. Redeploy
main.bicepwithcreateJobSchedule=false, or useupgrade.bicep, which has no schedule resources. - Azure sandbox jobs are killed at the 3-hour fair-share limit. For large estates, run on
a Hybrid Runbook Worker or shard by subscription using
subscriptionIds. - The
FreeSKU includes 500 job minutes per month. A nightly run across a modest estate fits comfortably; a large one will not. - Module imports are deliberately serial and take several minutes on first deployment. Azure Automation is unreliable when many imports run concurrently.
- Stopping a web app does not reduce App Service Plan cost — the plan is billed. Only empty plans are deleted; populated ones are reported so you can scale them down.
- Secured-hub (Virtual WAN) firewalls cannot be deallocated and are reported instead.
- Ultra and Premium v2 disks cannot be converted in place; they are reported.
MIT. See LICENSE.