Sometimes the schedule belongs in Linux, but the work belongs everywhere. I hit this pattern when a small Hugo site lived in WSL, supporting scripts lived on the Windows host, and the final deployment needed to touch both sides: copy files, run PowerShell scripts, restart the Hugo development service in WSL, and restart IIS on Windows.
Cron can handle the schedule cleanly. PowerShell can handle the orchestration cleanly. The useful trick is to let cron call pwsh.exe or powershell.exe, then let the PowerShell script decide what needs to happen on Windows and what needs to happen inside WSL.
This is not the right pattern for every production system. If you already have systemd timers, GitHub Actions, Jenkins, Azure DevOps, Ansible, or a proper deployment tool, use that. But for a single Windows machine with WSL installed, this approach is simple, visible, and easy to troubleshoot.
Quick answer
Create a cron entry inside WSL that calls Windows PowerShell or PowerShell 7 by full path, and point it at a Windows .ps1 script. In that script, use normal Windows paths for host-side work, use \\wsl.localhost\DistroName\path or wsl.exe for WSL-side work, copy files with Copy-Item or robocopy, run other Windows PowerShell scripts with the call operator, restart Linux services with wsl.exe -d Distro --exec sudo systemctl restart service, and restart Windows services with Restart-Service or iisreset.exe. Always use full paths and write logs because cron runs with a small environment.
The basic flow
The pattern has three layers:
| Layer | Owns | Example |
|---|---|---|
| WSL cron | Schedule | Run every day at 2 AM |
| Windows PowerShell | Orchestration | Copy files, call scripts, restart IIS |
| WSL commands | Linux-side actions | Restart Hugo or another systemd service |
The cron line should stay boring. Avoid putting complicated quoting, file copy logic, and service restarts directly in crontab. Put the real logic in a .ps1 file where you can test it, version it, and log from it.
In WSL, the cron entry might look like this:
15 2 * * * /mnt/c/Program\ Files/PowerShell/7/pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Run-WslWindowsMaintenance.ps1" >> /home/sea/cron-logs/wsl-windows-maintenance.log 2>&1
If you use Windows PowerShell instead of PowerShell 7, call the built-in executable:
15 2 * * * /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Run-WslWindowsMaintenance.ps1" >> /home/sea/cron-logs/wsl-windows-maintenance.log 2>&1
I prefer PowerShell 7 when it is available because it is the current cross-platform shell and usually behaves more consistently with modern scripts. Windows PowerShell is still fine for Windows-only tasks, especially when you need older modules that were built for it.
Create a Windows orchestration script
Start with a normal Windows script path:
C:\Scripts\Run-WslWindowsMaintenance.ps1Here is a complete starter script. It copies files from WSL to Windows, copies files from Windows back into WSL, runs another Windows PowerShell script, restarts a WSL service, and restarts IIS on the Windows host.
Adjust the distro name, paths, and service names for your machine.
$ErrorActionPreference = 'Stop'
$distro = 'Ubuntu-24.04'
$logRoot = 'C:\Logs\PwshTips'
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$windowsSiteRoot = 'C:\inetpub\wwwroot\pwshtips'
$windowsScript = 'C:\Scripts\After-Copy.ps1'
$wslProjectUnc = "\\wsl.localhost\$distro\home\sea\project\pwshtips\pwshtips.com"
$wslPublicUnc = Join-Path $wslProjectUnc 'public'
$wslStaticUnc = Join-Path $wslProjectUnc 'static'
New-Item -Path $logRoot -ItemType Directory -Force | Out-Null
$logPath = Join-Path $logRoot 'wsl-windows-maintenance.log'
function Write-JobLog {
param(
[Parameter(Mandatory)]
[string] $Message
)
$line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
$line | Tee-Object -FilePath $logPath -Append
}
Write-JobLog "Starting WSL and Windows maintenance job"
Write-JobLog "Cron started this job at $timestamp"
if (-not (Test-Path $wslProjectUnc)) {
throw "WSL project path was not found: $wslProjectUnc"
}
if (-not (Test-Path $windowsSiteRoot)) {
New-Item -Path $windowsSiteRoot -ItemType Directory -Force | Out-Null
}
Write-JobLog "Copying Hugo public files from WSL to Windows"
robocopy $wslPublicUnc $windowsSiteRoot /MIR /R:2 /W:5 /NP /LOG+:$logPath
$robocopyExit = $LASTEXITCODE
if ($robocopyExit -ge 8) {
throw "Robocopy failed with exit code $robocopyExit"
}
Write-JobLog "Copying Windows-hosted files back into WSL static folder"
$windowsAssets = 'C:\SiteAssets\pwshtips'
if (Test-Path $windowsAssets) {
Copy-Item -Path (Join-Path $windowsAssets '*') -Destination $wslStaticUnc -Recurse -Force
}
else {
Write-JobLog "Skipping asset copy because $windowsAssets does not exist"
}
if (Test-Path $windowsScript) {
Write-JobLog "Running follow-up Windows script: $windowsScript"
& $windowsScript -SiteRoot $windowsSiteRoot -LogPath $logPath
}
else {
Write-JobLog "Skipping follow-up script because $windowsScript does not exist"
}
Write-JobLog "Restarting Hugo service inside WSL"
& wsl.exe -d $distro --exec sudo systemctl restart hugo.service
Write-JobLog "Restarting IIS on Windows"
Restart-Service -Name W3SVC -Force
Write-JobLog "Finished WSL and Windows maintenance job"This script deliberately uses a log file under C:\Logs\PwshTips instead of relying only on cron redirection. The cron log tells you whether cron started PowerShell. The PowerShell log tells you what the script actually did.
Copy files between Windows and WSL
PowerShell on Windows can reach WSL files through the UNC path:
\\wsl.localhost\Ubuntu-24.04\home\sea\project\pwshtips\pwshtips.comThat makes normal Windows tools work:
$distro = 'Ubuntu-24.04'
$wslRoot = "\\wsl.localhost\$distro\home\sea\project\pwshtips\pwshtips.com"
Copy-Item `
-Path "C:\SiteAssets\pwshtips\*" `
-Destination (Join-Path $wslRoot 'static') `
-Recurse `
-ForceFor larger Windows-facing copies, I usually use robocopy:
$source = "\\wsl.localhost\Ubuntu-24.04\home\sea\project\pwshtips\pwshtips.com\public"
$dest = 'C:\inetpub\wwwroot\pwshtips'
$log = 'C:\Logs\PwshTips\robocopy.log'
robocopy $source $dest /MIR /R:2 /W:5 /NP /LOG+:$log
if ($LASTEXITCODE -ge 8) {
throw "Robocopy failed with exit code $LASTEXITCODE"
}Robocopy exit codes are not like most command-line tools. Exit code 1 can mean files were copied successfully. I only treat 8 and higher as failure for this kind of job.
The reverse direction also works. From WSL, Windows drives appear under /mnt/c, /mnt/d, and so on:
cp -r /mnt/c/SiteAssets/pwshtips/* ~/project/pwshtips/pwshtips.com/static/For this pattern, I still prefer putting the file copy in PowerShell because the same script is also restarting Windows services and running Windows scripts.
Run other PowerShell scripts on the Windows host
One useful reason to make PowerShell the orchestrator is that you can call existing Windows scripts without rewriting them for Bash.
For example:
$script = 'C:\Scripts\After-Copy.ps1'
if (Test-Path $script) {
& $script `
-SiteRoot 'C:\inetpub\wwwroot\pwshtips' `
-EnvironmentName 'Local'
}Use the call operator & instead of trying to build a long command string. It is easier to quote correctly, and parameters stay parameters.
If the child script writes errors, let them fail the parent job:
$ErrorActionPreference = 'Stop'
& 'C:\Scripts\After-Copy.ps1' -SiteRoot 'C:\inetpub\wwwroot\pwshtips'If a child script is older and does not use terminating errors, check its output or exit code explicitly. Scheduled jobs are only useful when failure is visible.
Restart services inside WSL
From Windows PowerShell, use wsl.exe to run Linux commands inside a specific distro:
wsl.exe -d Ubuntu-24.04 --exec sudo systemctl restart hugo.serviceThat command assumes systemd is enabled in WSL and that hugo.service exists. A simple Hugo service might look like this:
[Unit]
Description=Hugo local server
After=network.target
[Service]
WorkingDirectory=/home/sea/project/pwshtips/pwshtips.com
ExecStart=/usr/local/bin/hugo server --bind 0.0.0.0 --baseURL http://localhost:1313/
Restart=on-failure
[Install]
WantedBy=default.targetAfter creating or changing the service file inside WSL, reload systemd and enable the service:
sudo systemctl daemon-reload
sudo systemctl enable hugo.service
sudo systemctl restart hugo.service
sudo systemctl status hugo.serviceIf your WSL distro does not use systemd, restart the process with a shell script instead:
wsl.exe -d Ubuntu-24.04 --exec bash -lc "pkill -f 'hugo server' || true; nohup hugo server --bind 0.0.0.0 --baseURL http://localhost:1313/ >/tmp/hugo.log 2>&1 &"Systemd is cleaner when it is available. The shell-script approach works, but process matching can restart more than you intended if the pattern is too broad.
Restart Windows services such as IIS
For IIS, restart only what you need. If you only need the web publishing service:
Restart-Service -Name W3SVC -ForceIf you need a full IIS reset:
iisreset.exe /restartFor application pools, use the WebAdministration module:
Import-Module WebAdministration
Restart-WebAppPool -Name 'DefaultAppPool'The account running the cron-triggered PowerShell process must have permission to do this. If cron calls Windows PowerShell through your signed-in Windows session, it will usually run in that user context. If you need reliable service-level permissions after reboot, consider using a Windows Scheduled Task as the privileged runner and have cron trigger that task instead.
Permissions and sudo
The most common failure in this setup is permissions. There are two separate permission models:
| Action | Permission needed |
|---|---|
Copy to C:\inetpub\wwwroot |
Windows file permission |
| Restart IIS | Windows administrator rights |
| Copy into WSL home | WSL filesystem access through the current Windows user |
| Restart a WSL systemd service | Linux sudo or service permission |
For WSL service restarts, cron is calling Windows PowerShell, and Windows PowerShell is calling wsl.exe, so any sudo prompt will break unattended execution. Use a narrow sudoers rule if this is an admin box and you understand the risk.
Inside WSL, edit sudoers safely:
sudo visudoExample rule for one service command:
sea ALL=(root) NOPASSWD: /usr/bin/systemctl restart hugo.serviceDo not use broad passwordless sudo unless you actually want that account to run anything as root without a password.
Test manually before adding cron
Before you add the cron entry, run the same command from WSL manually:
/mnt/c/Program\ Files/PowerShell/7/pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Run-WslWindowsMaintenance.ps1"Then check both logs:
Get-Content C:\Logs\PwshTips\wsl-windows-maintenance.log -Tail 50tail -50 ~/cron-logs/wsl-windows-maintenance.logAfter the manual test works, edit the WSL crontab:
crontab -eAdd the cron line:
15 2 * * * /mnt/c/Program\ Files/PowerShell/7/pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Scripts\Run-WslWindowsMaintenance.ps1" >> /home/sea/cron-logs/wsl-windows-maintenance.log 2>&1
Use absolute paths. Cron will not load the same profile, PATH, aliases, or environment variables that your interactive shell uses.
Practical checklist
Before trusting the job, I check these items:
- The cron command works when pasted into a WSL shell.
- The PowerShell script uses
$ErrorActionPreference = 'Stop'. - Every path is absolute.
- The script writes its own log.
- Robocopy exit codes below
8are handled as non-fatal. - The Windows account can write to the destination folder.
- The Windows account can restart IIS or the selected Windows service.
- The WSL user can restart the selected Linux service without an interactive sudo prompt.
- The cron log and the PowerShell log are both checked after the first scheduled run.
- WSL is started automatically after Windows boots if the schedule needs to survive reboots.
This setup is small, but it is not toy automation. Once cron can call Windows PowerShell, the job can change files and restart services on both sides of the host. Keep the script readable, keep the permissions narrow, and make failure obvious in the logs.
💬 Comments