Skip to content

Repository files navigation

ShutdownAzureResources

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

Deploy to Azure

Upgrade an existing deployment (refreshes the script and modules only, leaves your identity, schedule, and role assignments alone)

Deploy to Azure


Safety first

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.

What it does

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.

Quick start

Portal

Use the Deploy to Azure button above. At minimum supply an Automation account name; the defaults handle everything else.

Azure CLI

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-resources

Preview first with az deployment group what-if in place of create.

Azure PowerShell

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-resources

Run it now

The 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 Output

Or use Automation account → Runbooks → ShutdownAzureResources → Start in the portal. A manual start uses the script's own defaults, which include DryRun = $true.

Going live

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.bicep against an account that already has the link, pass createJobSchedule=false so 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=false

Routine script and module upgrades use infra/upgrade.bicep, which contains no schedule resources and is safe to redeploy as often as you like.

Permissions

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>

Covering more than one subscription

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.

Tuning the runbook

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.

Deployment parameters

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.

Undoing changes

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.

Repository layout

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.json

CI fails the build if they drift.

Upgrading from v1 (AzureRM)

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.

Local development

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.

Limits and caveats

  • Job schedules are not idempotent in ARM. Redeploy main.bicep with createJobSchedule=false, or use upgrade.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 Free SKU 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.

License

MIT. See LICENSE.

About

Automatically shutdown (or scale down) Azure resources on a schedule every day.

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages