The problem
Almost every estate has a VM that runs dozens of scheduled scripts: cleanups, reports, certificate checks, exports. It often runs under a personal account, isn't backed up, and nobody notices when a job fails.
1. Inventory
Get-ScheduledTask | Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
ForEach-Object {
$info = $_ | Get-ScheduledTaskInfo
[pscustomobject]@{
Name = $_.TaskName; RunAs = $_.Principal.UserId
Action = ($_.Actions | ForEach-Object { "$($_.Execute) $($_.Arguments)" }) -join '; '
LastRun = $info.LastRunTime; LastResult = $info.LastTaskResult
}
} | Export-Csv C:\temp\tasks.csv -NoTypeInformation
Linux: crontab -l for each user and /etc/cron.d/*. Record for each job: what it does, what it touches, run time, owner.
2. Choose a target per job
| Job type | Target |
|---|---|
| Short PowerShell/Python against Azure APIs | Azure Automation runbook or Functions timer trigger |
| Needs on-prem access (file shares, AD, servers) | Automation runbook on a Hybrid Runbook Worker |
| Integration: "when X, call API Y, send mail" | Logic Apps |
| Long-running or custom dependencies (>10 min, big libraries) | Container Apps job |
| SQL maintenance | SQL Agent / elastic jobs, not a script server |
| No longer needed | Delete it — usually 20–30% of the list |
3. Example: timer-triggered Function
# function_app.py (Python v2 programming model)
import azure.functions as func, logging
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient
from datetime import datetime, timedelta, timezone
app = func.FunctionApp()
@app.timer_trigger(schedule="0 0 2 * * *", arg_name="timer") # 02:00 UTC daily
def purge_old_exports(timer: func.TimerRequest) -> None:
svc = BlobServiceClient("https://stexports.blob.core.windows.net", credential=DefaultAzureCredential())
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
c = svc.get_container_client("exports")
for b in c.list_blobs():
if b.last_modified < cutoff:
c.delete_blob(b.name)
logging.info("deleted %s", b.name)
The schedule is NCRONTAB with six fields (seconds first). Use a managed identity with a narrow role, not a stored key.
4. Example: Automation runbook
# Runbook: uses the Automation account's managed identity
Connect-AzAccount -Identity
Get-AzVM -Status | Where-Object { $_.Tags['env'] -eq 'dev' -and $_.PowerState -eq 'VM running' } |
Stop-AzVM -Force -NoWait
az automation schedule create -g rg-ops --automation-account-name aa-ops -n nightly-1900 \
--frequency Day --interval 1 --start-time "2026-10-25T19:00:00+00:00" --time-zone "Europe/London"
5. Add what was missing
- Alerting on failure: Functions → Application Insights alert on failed executions; Automation → alert on job status
Failedvia Azure Monitor. - Source control: scripts in Git, deployed by pipeline.
- Secrets: Key Vault, accessed by managed identity.
- Ownership: tag each job with an owner.
6. Cut over
Run the new job in parallel (or dry-run mode) for a cycle, compare outputs, disable the old task, and finally decommission the server.
Checklist
- Every task inventoried with owner and purpose
- Obsolete jobs deleted
- Each job moved to the right service with managed identity
- Failure alerts working
- Old server retired