This is cron for Windows, and it's clunkier than cron. The Task Scheduler GUI is a maze of tabs, but the ScheduledTasks PowerShell module turns a recurring job into four objects you compose and register — repeatable, scriptable, no clicking.
TL;DR:
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\job.ps1" `
-WorkingDirectory "C:\scripts"
$trigger = New-ScheduledTaskTrigger -Daily -At 3:00am
$princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
Register-ScheduledTask -TaskName "Nightly-Job" -Action $action -Trigger $trigger `
-Principal $princ -Description "Runs job.ps1 every night at 03:00"
Details below.
The four pieces
A registered task is built from four object types. You make each one, then hand them all to Register-ScheduledTask:
- Action — what runs (
New-ScheduledTaskAction) - Trigger — when it runs (
New-ScheduledTaskTrigger) - Principal — who it runs as (
New-ScheduledTaskPrincipal) - Settings — optional tuning (
New-ScheduledTaskSettingsSet)
Compose, register, done. Compared to a one-line crontab entry this is verbose, but the upside is each piece is a real object you can inspect and reuse.
The action — run a script
Don't point the action at your .ps1 directly. Point it at powershell.exe and pass the script as an argument. That way -NoProfile and the execution policy override actually apply:
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\backup.ps1" `
-WorkingDirectory "C:\scripts"
-NoProfile matters. SYSTEM and service accounts can have profile scripts that hang or change your environment. Skip them. -WorkingDirectory matters too — without it the task runs from C:\Windows\System32, and every relative path in your script breaks. Set it explicitly or use full paths everywhere. Use both, honestly.
Triggers — daily, startup, every N minutes
The three I reach for:
# Every day at a fixed time
New-ScheduledTaskTrigger -Daily -At 3:00am
# At boot (no logged-in user needed)
New-ScheduledTaskTrigger -AtStartup
# Every 15 minutes, forever — cron's */15 equivalent
$t = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 15) `
-RepetitionDuration ([TimeSpan]::MaxValue)
The every-N-minutes pattern is the awkward one. There's no -Every flag — you start a -Once trigger now and attach a repetition interval. [TimeSpan]::MaxValue means "run indefinitely." This is the closest thing to a */15 * * * * crontab line, and it takes three parameters to say it.
You can pass multiple triggers to one task — just give -Trigger an array.
The principal — run as SYSTEM, highest privileges
Who the task runs as. For unattended jobs that need to run whether or not anyone is logged in, SYSTEM is the cleanest — it's a local account with no password to store or rotate:
$princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
-RunLevel Highest runs elevated (past UAC). Without it, an admin-context script silently fails on anything that needs elevation.
If the job must run as a specific domain user — say it touches a network share that SYSTEM can't reach — you have two logon types, and this is the gotcha that bites everyone:
# S4U: run whether logged on or not, NO stored password
$princ = New-ScheduledTaskPrincipal -UserId "DOMAIN\svc_backup" `
-LogonType S4U -RunLevel Highest
# Password: needs creds, can access network resources
Register-ScheduledTask -TaskName "Net-Job" -Action $a -Trigger $t `
-User "DOMAIN\svc_backup" -Password "P@ss" -RunLevel Highest
The exception: S4U (service-for-user) lets a task run without a stored password — great, because you never have to update the task when the account's password rotates. But S4U tokens cannot authenticate to other machines. If your script reads \\fileserver\share, S4U gives you access-denied. For network access you need -LogonType Password with real credentials, which means storing a password the task vault has to keep and you have to rotate. SYSTEM sidesteps all of this for local-only work, which is why I default to it.
Register it
Everything comes together here:
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-DontStopOnIdleEnd -ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask -TaskName "Nightly-Backup" `
-Action $action -Trigger $trigger -Principal $princ `
-Settings $settings -Description "restic backup, runs 03:00 daily" -Force
-StartWhenAvailable runs a missed job once the box comes back up (the closest thing to anacron). -ExecutionTimeLimit kills a hung job — set it, or a stuck task blocks the next run forever. -Force overwrites an existing task of the same name, which makes the whole script idempotent: re-run it and the task is updated in place.
Capture output — Task Scheduler history is weak
Task Scheduler's own history is terse and disabled by default. Don't rely on it. Make the script log itself:
# top of job.ps1
Start-Transcript -Path "C:\logs\job_$(Get-Date -f yyyyMMdd_HHmmss).log"
try {
# ... work ...
exit 0
} catch {
Write-Error $_
exit 1
} finally {
Stop-Transcript
}
The task records your script's exit code in LastTaskResult — but only if the script actually sets one. exit 0 for success, non-zero for failure. Without explicit exits you get 0x0 for everything and can't tell a real success from a silent crash.
Manage running tasks
# List (filter by folder path)
Get-ScheduledTask -TaskPath "\" | Where-Object State -ne "Disabled"
# Did it run? When's next? What did it return?
Get-ScheduledTaskInfo -TaskName "Nightly-Backup" |
Select-Object LastRunTime, LastTaskResult, NextRunTime, NumberOfMissedRuns
# Fire it manually, right now
Start-ScheduledTask -TaskName "Nightly-Backup"
# Toggle
Disable-ScheduledTask -TaskName "Nightly-Backup"
Enable-ScheduledTask -TaskName "Nightly-Backup"
# Remove
Unregister-ScheduledTask -TaskName "Nightly-Backup" -Confirm:$false
Get-ScheduledTaskInfo is the one I run most. LastTaskResult is your exit code; 0 is success. NextRunTime confirms the trigger fires when you think it does.
Export / import as XML
For repeatability across servers, dump a task to XML and re-register it elsewhere. This is how I move a task from a test box to prod without re-typing anything:
# Export
Export-ScheduledTask -TaskName "Nightly-Backup" |
Out-File C:\scripts\nightly-backup.xml -Encoding utf8
# Import on another machine
$xml = Get-Content C:\scripts\nightly-backup.xml -Raw
Register-ScheduledTask -TaskName "Nightly-Backup" -Xml $xml `
-User "SYSTEM"
The XML is the full task definition — triggers, action, principal, settings. Commit it to your config repo and the task becomes infrastructure-as-code, the same way I treat my server setup checklist.
Gotchas
-NoProfilealways. Service accounts with profile scripts will surprise you.- Full paths everywhere. The task runs from
System32, not your script's folder. Set-WorkingDirectoryand use absolute paths. - Exit code
0x41303= "task has not yet run." Not an error — the trigger just hasn't fired.Start-ScheduledTaskto test immediately instead of waiting. - History is off by default. Enable it once if you want the event-log trail:
wevtutil set-log Microsoft-Windows-TaskScheduler/Operational /enabled:true. But still log in-script. - S4U can't hit the network. Covered above — the single most common "why does it fail only as a task" cause.
-RunLevel Highestor your elevated steps fail silently.
Verify
After registering, confirm the task is real and the trigger is sane:
Get-ScheduledTask -TaskName "Nightly-Backup" | Format-List TaskName, State
Get-ScheduledTaskInfo -TaskName "Nightly-Backup" |
Format-List LastTaskResult, NextRunTime
# Force a run and watch the log
Start-ScheduledTask -TaskName "Nightly-Backup"
Get-Content C:\logs\job_*.log -Tail 20 -Wait
If NextRunTime is populated and a manual Start-ScheduledTask produces a log with exit 0, you're done.
Versus cron and systemd
cron is one line in a crontab and you're finished — terser by far. systemd timers split the what (a .service) from the when (a .timer), much like Task Scheduler splits action from trigger; if you've written a systemd service unit the two-object model here will feel familiar. Where Task Scheduler genuinely wins is the principal model — SYSTEM, S4U, and run-whether-logged-on-or-not are first-class, where on Linux you're reaching for service accounts and User= directives to get the same.
It's more verbose than cron at every step. But it's scriptable, the objects are inspectable, and the XML export makes tasks portable. Wire these cmdlets into your provisioning and Windows scheduling stops being a GUI chore. For the rest of the cmdlets I lean on daily, see my PowerShell cheatsheet.