Skip to content

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 bare wsl --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 Bypass on the scheduled task's action silently blocks the whole script from ever loading. Windows PowerShell 5.1's default policy resolves to effectively Restricted when all scopes (MachinePolicy/UserPolicy/Process/CurrentUser/LocalMachine) show Undefined via Get-ExecutionPolicy -List. The task's Last Result just shows a generic 1 (HRESULT 0x80070001) either way — indistinguishable from any other exit-code-1 failure without checking the actual Task To Run string or enabling Microsoft-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 --export fails if the distro is running or if MajorRig-WSL-KeepAlive is 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-Host calls vanish silently unless the script wraps itself in Start-Transcript/Stop-Transcript (added 2026-08-10, logs to backup-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 --terminate kills 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 live ssh majorrig session.
  • 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 Bypass on the task action (fixed 2026-08-10). Verified fixed via a real Sunday-task trigger producing Last Result: 0 and a genuine 87.5 GB FedoraLinux-43-2026-08-10.tar.
  • 2026-08-10: added Start-Transcript logging, 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: 0 for 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.

See Also