WSL2 Backup via PowerShell Scheduled Task¶
WSL2 distributions are stored as a VHDX file on disk. Unlike traditional VMs, there's no
built-in snapshot or backup mechanism. This article covers the weekly backup strategy in use
on MajorRig: wsl --export on a PowerShell scheduled task, coordinated with the
MajorRig-WSL-KeepAlive task that normally holds the distro open, plus a pre-warning /
skip / completion notification layer added 2026-08-10.
Rewritten 2026-08-10. The previous version of this article (username
majli, a barewsl --shutdown, no logging, no KeepAlive awareness) had drifted badly from what's actually deployed and was actively misleading — e.g. it estimated backup size at "~500MB–1GB"; the real FedoraLinux-43 export is ~84–88 GB. See Incident History below for how this was found and fixed.
Current Setup¶
Two scheduled tasks, both Interactive only logon mode, Run As User: majorlinux:
| Task | Trigger | Script |
|---|---|---|
WSL2 Backup - PreWarning |
Sundays 1:50 AM | C:\Users\majorlinux\Scripts\wsl-backup-prewarn.ps1 |
WSL2 Backup - FedoraLinux43 |
Sundays 2:00 AM | C:\Users\majorlinux\Scripts\backup-wsl.ps1 |
Backs up to D:\WSL\Backups\FedoraLinux-43-<date>.tar, keeps the last 3 ($MaxBackups).
Backblaze picks up D:\WSL\Backups\ automatically if D: is in scope for offsite backup.
Console output is captured to D:\WSL\Backups\backup-log.txt via Start-Transcript (added
2026-08-10 — see below).
Why Export Needs to Terminate the Distro First¶
wsl --export fails with ERROR_SHARING_VIOLATION (or, if the script never gets that far,
a generic exit code) if the target distro is running. MajorRig also runs
MajorRig-WSL-KeepAlive, a task that holds FedoraLinux-43 open via wsl --exec sleep infinity
so background jobs and SSH stay reachable. Backup must pause KeepAlive, terminate the distro,
export, then restart KeepAlive — in that order, and the KeepAlive restart must happen even if
export fails (hence the try/catch/finally-style structure below).
wsl --terminate kills the entire WSL2 VM instance for that distro, including any active
SSH session hosted inside it (which is exactly how ssh majorrig reaches this box — the sshd
serving that connection lives inside the distro being terminated). Don't run this manually from
a session that's itself inside the target distro; it'll hang the connection. Trigger the real
scheduled task (schtasks /Run /TN "WSL2 Backup - FedoraLinux43") instead if you need to force
a reproduction.
Backup Script — backup-wsl.ps1¶
$BackupDir = "D:\WSL\Backups"
$LogFile = "$BackupDir\backup-log.txt"
$Date = Get-Date -Format "yyyy-MM-dd"
$BackupFile = "$BackupDir\FedoraLinux-43-$Date.tar"
$MaxBackups = 3
$DistroName = "FedoraLinux-43"
$KeepAliveTask = "MajorRig-WSL-KeepAlive"
$FlagFile = "C:\Users\majorlinux\Scripts\skip-backup-this-week.flag"
$NtfyTopic = "<your-random-topic>"
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
function Send-Ntfy {
param($Title, $Message, $Priority = "default", $Tags = "")
try {
$headers = @{ Title = $Title; Priority = $Priority }
if ($Tags) { $headers["Tags"] = $Tags }
Invoke-RestMethod -Uri "https://ntfy.sh/$NtfyTopic" -Method Post -Body $Message -Headers $headers -TimeoutSec 15 | Out-Null
} catch {
Write-Host "ntfy send failed: $($_.Exception.Message)"
}
}
# Skip-this-week flag, set by the 1:50 AM pre-warning popup if the user clicks "Yes, skip".
# Checked before anything else -- if present, don't touch KeepAlive/the distro at all.
if (Test-Path $FlagFile) {
Start-Transcript -Path $LogFile -Append -IncludeInvocationHeader
Write-Host "Skip flag found -- skipping this week's backup per user request."
Remove-Item -Path $FlagFile -Force
Send-Ntfy -Title "WSL Backup Skipped" -Message "This week's FedoraLinux-43 backup was skipped per your request. Next run: next Sunday, 2:00 AM." -Priority "default"
Stop-Transcript
exit 0
}
Start-Transcript -Path $LogFile -Append -IncludeInvocationHeader
try {
Write-Host "Pausing $KeepAliveTask and terminating $DistroName before export..."
schtasks /Change /TN $KeepAliveTask /DISABLE | Out-Null
wsl --terminate $DistroName
Write-Host "wsl --terminate exit code: $LASTEXITCODE"
Write-Host "Backing up $DistroName to $BackupFile..."
$StartTime = Get-Date
wsl --export $DistroName $BackupFile
$ExportExitCode = $LASTEXITCODE
$Duration = [math]::Round(((Get-Date) - $StartTime).TotalMinutes, 1)
Write-Host "wsl --export exit code: $ExportExitCode (took $Duration min)"
Write-Host "Restarting $KeepAliveTask..."
schtasks /Change /TN $KeepAliveTask /ENABLE | Out-Null
schtasks /Run /TN $KeepAliveTask | Out-Null
if ($ExportExitCode -eq 0) {
Write-Host "Backup complete: $BackupFile"
$SizeGB = [math]::Round((Get-Item $BackupFile).Length / 1GB, 1)
Get-ChildItem "$BackupDir\FedoraLinux-43-*.tar" |
Sort-Object LastWriteTime -Descending |
Select-Object -Skip $MaxBackups |
Remove-Item -Force
Send-Ntfy -Title "WSL Backup Complete" -Message "FedoraLinux-43 backup finished OK ($SizeGB GB, $Duration min). WSL is back up and usable." -Priority "default" -Tags "white_check_mark"
} else {
Write-Host "ERROR: Backup failed! wsl --export exited with code $ExportExitCode"
Send-Ntfy -Title "WSL Backup FAILED" -Message "FedoraLinux-43 export failed (exit $ExportExitCode). Check backup-log.txt on MajorRig." -Priority "urgent" -Tags "warning,x"
$wshell = New-Object -ComObject Wscript.Shell
$wshell.Popup("WSL backup FAILED (exit $ExportExitCode). Check D:\WSL\Backups\backup-log.txt", 30, "WSL Backup Failed", 0 + 16) | Out-Null
Stop-Transcript
exit 1
}
} catch {
Write-Host "EXCEPTION: $($_.Exception.Message)"
Write-Host $_.ScriptStackTrace
schtasks /Change /TN $KeepAliveTask /ENABLE | Out-Null
schtasks /Run /TN $KeepAliveTask | Out-Null
Send-Ntfy -Title "WSL Backup FAILED" -Message "FedoraLinux-43 backup hit an exception: $($_.Exception.Message)" -Priority "urgent" -Tags "warning,x"
Stop-Transcript
exit 1
}
Stop-Transcript
Pre-Warning Script — wsl-backup-prewarn.ps1¶
Fires 10 minutes before the backup (1:50 AM Sunday). Sends a push notification immediately (so it reaches the phone even if nobody's at the machine), then shows a local popup with a Yes/No skip button and a 9.5-minute timeout — if it times out or "No" is clicked, the backup proceeds normally; "Yes" writes the skip flag the main script checks for.
$NtfyTopic = "<your-random-topic>"
$FlagFile = "C:\Users\majorlinux\Scripts\skip-backup-this-week.flag"
$LogFile = "D:\WSL\Backups\backup-log.txt"
function Send-Ntfy {
param($Title, $Message, $Priority = "default", $Tags = "")
try {
$headers = @{ Title = $Title; Priority = $Priority }
if ($Tags) { $headers["Tags"] = $Tags }
Invoke-RestMethod -Uri "https://ntfy.sh/$NtfyTopic" -Method Post -Body $Message -Headers $headers -TimeoutSec 15 | Out-Null
} catch {
Add-Content -Path $LogFile -Value "$(Get-Date -Format o) [prewarn] ntfy send failed: $($_.Exception.Message)"
}
}
Add-Content -Path $LogFile -Value "$(Get-Date -Format o) [prewarn] Pre-warning fired."
Send-Ntfy -Title "WSL Backup in 10 min" -Message "FedoraLinux-43 backup starts at 2:00 AM and will briefly disconnect any active session. On MajorRig's screen you have ~10 min to skip this week." -Priority "high" -Tags "warning"
$wshell = New-Object -ComObject Wscript.Shell
$result = $wshell.Popup("WSL backup starts in 10 minutes (2:00 AM) and will disconnect FedoraLinux-43 briefly.`n`nSkip this week's backup?", 570, "WSL Backup - 10 Minute Warning", 4 + 32)
if ($result -eq 6) {
New-Item -ItemType File -Path $FlagFile -Force | Out-Null
Add-Content -Path $LogFile -Value "$(Get-Date -Format o) [prewarn] User chose to SKIP this week's backup."
Send-Ntfy -Title "WSL Backup Skipped" -Message "Confirmed: this week's backup will be skipped. Next scheduled run: next Sunday, 2:00 AM." -Priority "default"
} else {
Add-Content -Path $LogFile -Value "$(Get-Date -Format o) [prewarn] No skip requested (result=$result) - backup will proceed."
}
Popup() return codes used above: 6 = Yes (skip), 7 = No, -1 = timed out. Button/icon
flags 4 + 32 = Yes/No buttons + question icon.
Push Notifications via ntfy.sh¶
No self-hosted notification service existed in the fleet, so this uses the free public
ntfy.sh service — no account needed, just a topic name acting as a shared channel.
Pick a long random topic name — e.g. [guid]::NewGuid().ToString("N") in PowerShell — and
put it in place of <your-random-topic> in both scripts.
Treat the topic like a password. On the public server a topic name is the only access control: anyone who knows it can read these alerts or post fake ones. That's why the real topic MajorRig uses isn't published here — it lives only in the two scripts on the machine.
To receive alerts on your phone: install the ntfy app (iOS/Android) and subscribe to your
topic. No further setup needed — the scripts post directly to
https://ntfy.sh/<your-random-topic>, no inbound firewall changes required.
Four events push a notification: 10-min pre-warning, skip confirmation, completion (success, with size/duration), and failure (urgent priority, plus a local popup).
Register the Scheduled Tasks¶
# Main backup task
$Action = New-ScheduledTaskAction -Execute "PowerShell.exe" `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File C:\Users\majorlinux\Scripts\backup-wsl.ps1"
$Trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 2am
Register-ScheduledTask -TaskName "WSL2 Backup - FedoraLinux43" `
-Action $Action -Trigger $Trigger -RunLevel Highest -Force
# Pre-warning task -- clone the main task's Principal/Settings, different trigger + action
$Existing = Get-ScheduledTask -TaskName "WSL2 Backup - FedoraLinux43"
$PreAction = New-ScheduledTaskAction -Execute "PowerShell.exe" `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File C:\Users\majorlinux\Scripts\wsl-backup-prewarn.ps1"
$PreTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 1:50AM
Register-ScheduledTask -TaskName "WSL2 Backup - PreWarning" `
-Action $PreAction -Trigger $PreTrigger -Principal $Existing.Principal -Settings $Existing.Settings
-ExecutionPolicy Bypass on the action itself is not optional. See Gotchas below — omitting
it is what actually caused every failure between 2026-04-19 and 2026-08-09.
Restore from Backup¶
wsl --unregister FedoraLinux-43
wsl --import FedoraLinux-43 D:\WSL\Fedora43 D:\WSL\Backups\FedoraLinux-43-YYYY-MM-DD.tar
Then fix the default user — after import WSL resets to root. See
WSL2 Instance Migration for the /etc/wsl.conf fix.
Gotchas¶
- Missing
-ExecutionPolicy Bypasson the scheduled task's action silently blocks the whole script from ever loading. Windows PowerShell 5.1's default policy resolves to effectivelyRestrictedwhen all scopes (MachinePolicy/UserPolicy/Process/CurrentUser/LocalMachine) showUndefinedviaGet-ExecutionPolicy -List. The task'sLast Resultjust shows a generic1(HRESULT0x80070001) either way — indistinguishable from any other exit-code-1 failure without checking the actualTask To Runstring or enablingMicrosoft-Windows-TaskScheduler/Operational(wevtutil set-log ... /enabled:true, disabled by default) and still getting nothing more specific than the exit code. The only way this surfaced was running the script by hand and seeing the real"running scripts is disabled on this system"error. wsl --exportfails if the distro is running or ifMajorRig-WSL-KeepAliveis holding it open — see "Why Export Needs to Terminate the Distro First" above. This was the fix applied 2026-08-07, real and necessary, but it was masked by the execution-policy issue above and never actually got exercised until that was also fixed 2026-08-10.- Task Scheduler does not capture a scheduled process's console output anywhere by default.
Write-Hostcalls vanish silently unless the script wraps itself inStart-Transcript/Stop-Transcript(added 2026-08-10, logs tobackup-log.txt). - Each backup tar is ~84–88 GB (FedoraLinux-43, Aug–Sep 2026), not the "~500MB–1GB" the old version of this article claimed
— check
D:\free space before assuming headroom for$MaxBackups = 3. wsl --terminatekills any SSH session hosted inside the distro being terminated, including the one you might be running the check from. Trigger the real task instead of replaying its steps manually inside a livessh majorrigsession.- While an export is running, any new WSL session attempt fails with
"An install, uninstall, or conversion is in progress... Error code: Wsl/Service/CreateInstance/0x8000000d". Expected, not a bug — just means diagnostics have to wait for the export to finish.
Incident History¶
- 2026-04-19 → 2026-08-09: weekly backup silently failing (
Last Result: 1) for ~16 consecutive weeks with no alerting to notice. Root cause was two independent, stacked problems: (1) KeepAlive/live-export conflict (fixed 2026-08-07, but never actually reached because of...) (2) missing-ExecutionPolicy Bypasson the task action (fixed 2026-08-10). Verified fixed via a real Sunday-task trigger producingLast Result: 0and a genuine 87.5 GBFedoraLinux-43-2026-08-10.tar. - 2026-08-10: added
Start-Transcriptlogging, the skip-flag mechanism, and the ntfy.sh pre-warning/completion notification layer — direct response to the multi-week silent-failure history above, so a future failure (or a Sunday-night work session) is never silent again. - Held since (checked 2026-09-11): both tasks report
Last Result: 0for their latest run (2026-09-06), and the three retained tars are the 2026-08-23, 08-30 and 09-06 weekly backups (83.5–84.1 GB), so rotation works too.