---
title: "Windows"
description: "The binary under Task Scheduler, or a service wrapper."
url: "https://saved.sh/docs/workers/install/windows"
---

Windows has no built-in supervisor for an arbitrary executable, so the choice is Task
Scheduler, which is already there, or a service wrapper, which behaves more like a real
service. Both need the same two directories underneath them.

Run every block below from an **elevated PowerShell**.

## Download and verify [#download-and-verify]

```powershell
$version = "v0.1.0"
$arch    = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "amd64" }
$asset   = "local-worker-windows-$arch.exe"
$base    = "https://github.com/savedhq/local-worker/releases/download/$version"

curl.exe -fsSLO "$base/$asset"
curl.exe -fsSLO "$base/SHA256SUMS"

$expected = (Select-String -Path SHA256SUMS -Pattern ([regex]::Escape($asset) + '$')).Line.Split()[0]
$actual   = (Get-FileHash ".\$asset" -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) { throw "checksum mismatch" }
"OK"
```

`curl.exe` has shipped with Windows since 10 build 1803. The `.exe` matters: bare `curl` is a
PowerShell alias for `Invoke-WebRequest` and takes different arguments.

## Two directories [#two-directories]

The executable and the config do not live in the same place. The config is the one that holds
secrets, and it is the one the working directory has to point at.

```powershell
New-Item -ItemType Directory -Force "$env:ProgramFiles\saved" | Out-Null
Move-Item -Force ".\$asset" "$env:ProgramFiles\saved\local-worker.exe"

New-Item -ItemType Directory -Force "$env:ProgramData\saved" | Out-Null
Copy-Item .\config.yaml "$env:ProgramData\saved\config.yaml"
```

<Callout type="warn">
  `config.yaml` holds your source passwords and the worker key. Strip inherited permissions so
  ordinary users cannot read it:

  ```powershell
  icacls "$env:ProgramData\saved" /inheritance:r `
    /grant:r "SYSTEM:(OI)(CI)(F)" "Administrators:(OI)(CI)(F)"
  ```

  The `(OI)(CI)` flags push the same restriction onto the files inside, so the config inherits
  it rather than needing its own rule.
</Callout>

## Run it once by hand [#run-it-once-by-hand]

The worker reads `config.yaml` **from its working directory** and takes no path argument.

```powershell
Push-Location "$env:ProgramData\saved"
& "$env:ProgramFiles\saved\local-worker.exe"
Pop-Location
```

Check the `external tool resolved` lines and that `backups=` matches what this worker should
serve, then stop it with `Ctrl-C`. See
[what healthy looks like](/docs/workers/troubleshooting#what-healthy-looks-like).

## Register the task [#register-the-task]

The worker logs to **stderr**, and Task Scheduler captures neither stream. Running it through
`cmd.exe` with a redirect is what gives you something to read afterwards.

```powershell
$exe = "$env:ProgramFiles\saved\local-worker.exe"
$log = "$env:ProgramData\saved\worker.log"

$action = New-ScheduledTaskAction -Execute "cmd.exe" `
  -Argument ('/c ""{0}" 2>> "{1}""' -f $exe, $log) `
  -WorkingDirectory "$env:ProgramData\saved"

$trigger = New-ScheduledTaskTrigger -AtStartup

$settings = New-ScheduledTaskSettingsSet `
  -ExecutionTimeLimit ([TimeSpan]::Zero) `
  -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
  -MultipleInstances IgnoreNew `
  -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries

Register-ScheduledTask -TaskName "saved-worker" -Action $action -Trigger $trigger `
  -Settings $settings -User "SYSTEM" -RunLevel Highest
```

```powershell
Start-ScheduledTask -TaskName "saved-worker"
Get-ScheduledTaskInfo -TaskName "saved-worker"
Get-Content "$env:ProgramData\saved\worker.log" -Tail 20 -Wait
```

Four of those settings are load-bearing:

| Setting                                | Why                                                                                                                |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `-ExecutionTimeLimit [TimeSpan]::Zero` | The default kills a task after three days. The worker is meant to run indefinitely                                 |
| `-MultipleInstances IgnoreNew`         | One process per worker credential. A second copy [breaks runs](/docs/workers/lifecycle#one-process-per-credential) |
| `-AllowStartIfOnBatteries`             | Without it a task does not start on battery power at all                                                           |
| `-AtStartup` with `-User SYSTEM`       | Runs whether or not anyone logs in                                                                                 |

<Callout>
  **`SYSTEM` authenticates to the network as the machine account.** That is fine for a source
  reached with a password in `config.yaml`, and not fine for one that expects a domain user.
  For those, register with `-User "DOMAIN\svc-saved" -Password $cred` and grant that account
  **Log on as a batch job**, then re-run the `icacls` line for it.
</Callout>

## Or use a service wrapper [#or-use-a-service-wrapper]

Task Scheduler restarts a crashed worker but gives you no service to query, no dependency
ordering and no log rotation. [WinSW](https://github.com/winsw/winsw) and
[NSSM](https://nssm.cc) both wrap an executable as a real Windows service and handle the
logging properly.

Whichever you use, two settings carry over unchanged: the working directory must be
`%ProgramData%\saved`, and the service account must be able to read `config.yaml` and reach
your sources.

## Next [#next]

<Cards>
  <Card href="/docs/workers/configuration" title="Configuration" description="Every key the config file accepts." />

  <Card href="/docs/workers/troubleshooting" title="Troubleshooting" description="When the task runs and nothing happens." />
</Cards>
