The box: Windows 11 Pro, Ubuntu 26.04 in WSL2, Docker Engine 29 inside the distro, Frigate 0.17.2. It has run an eleven-camera config (ten enabled) since August 2026, and the files below are its files. Read top to bottom once, then paste; placeholders like <YOUR_USER> are yours to fill. The reasoning behind the arrangement is on Frigate on Windows.
/etc/wsl.conf — inside the distro
[boot]
systemd=true
[user]
default=<YOUR_USER>
systemd=true is the line that makes everything else possible: it is why docker.service and the detector unit come up on their own when the distro boots, and why systemctl works at all. Change it, then wsl --shutdown from Windows; the setting is read at distro start. [user] default just means wsl.exe -d Ubuntu lands you in your own account; the scripts below pass -u root explicitly when they need it.
%USERPROFILE%\.wslconfig — on the Windows side
[wsl2]
networkingMode=mirrored
firewall=false
dnsTunneling=true
autoProxy=true
vmIdleTimeout=-1
networkingMode=mirrored— the distro shares the Windows network stack. Containers publishing5000and8554appear on127.0.0.1from Windows; the cameras'192.168.1.xaddresses are directly reachable from inside the distro; the detector's port is reachable from docker's bridge.vmIdleTimeout=-1— never shut the VM down for being idle. On its own this is not enough (see the anchor below), but without it nothing else helps.firewall=false— read this one carefully, because it is the opposite of what it sounds like. Microsoft's.wslconfigreference saystrue"allows the Windows Firewall rules, as well as rules specific to Hyper-V traffic, to filter WSL network traffic". Sofalsemeans Windows Defender Firewall does not filter anything the distro listens on. With mirrored networking that is every port a container publishes (5000,8554, Home Assistant's8123) and the detector's5555: all of them reachable from any device on the LAN, with nothing but the router's refusal to forward them between them and the internet. We run it this way because the boot script, the keepalive, the Shorts pipeline and the cam proxy over Tailscale all need those ports open, and nothing is on this LAN that we do not own. The safer setting is to leavefirewallat its default oftrueand add Hyper-V firewall rules for exactly the ports you need (New-NetFirewallHyperVRule, on Microsoft's WSL networking page). Either way, never forward any of these ports at the router.dnsTunneling=true,autoProxy=true— DNS and proxy settings follow Windows. Harmless, and they remove a class of "works in Windows, not in WSL" surprises.
Any change here needs wsl --shutdown too, and shutting WSL down stops Frigate. Do it once, then let the boot script bring it back.
/etc/docker/daemon.json
{
"data-root": "/srv/frigate/docker"
}
Docker's data-root (images, container layers, the overlay filesystem) stays on the WSL ext4 virtual disk. Never point it at /mnt/d or /mnt/c: those are 9p mounts of NTFS, overlayfs does not work on them, and Docker will either refuse or corrupt itself slowly. The recordings, which are just files Frigate writes, are fine on /mnt/d. Keeping those two apart matters more than anything else on this page; the boot script's header records what happened when we got it wrong.
docker-compose.yml
services:
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:stable
restart: unless-stopped
shm_size: "256mb"
devices:
- /dev/dxg
environment:
- LD_LIBRARY_PATH=/usr/lib/wsl/lib
ports:
- "5000:5000"
- "8554:8554"
volumes:
- /mnt/d/frigate/config:/config
- /mnt/d/frigate/media:/media/frigate
- /usr/lib/wsl/lib:/usr/lib/wsl/lib
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
devices: /dev/dxgand the/usr/lib/wsl/libbind +LD_LIBRARY_PATHare the WSL2 GPU passthrough recipe from Microsoft's GPU compute guide. Keep them: they are what a future Frigate build would need to use the iGPU directly. Today Frigate's own OpenVINO does not enumerate the device through/dev/dxg, which is why detection runs out of process.- AI’s partThose three lines were added on 2026-08-29 by a coding assistant for an attempt that failed: with them in place the container's OpenVINO still listed only
CPU, and switching the detector totype: openvinocrash-looped Frigate. They stayed because they are inert with azmqdetector; the detector page has that afternoon. shm_size: "256mb"— Frigate sizes its own need and reports it in the logs at start: on our box it saysmin_shm: 234(from a reportedcamera_frame_size: 9.2andshm_frame_count: 22). 256 MB clears that. If you add cameras or raise the detect resolution, read the new number and raise this.- The
tmpfsat/tmp/cache, 1 GB — Frigate assembles recording segments here before moving them to/media/frigate. Memory-backed so the 9p drive only sees finished files. /mnt/d/frigate/media:/media/frigate— recordings on the NTFS D: drive. Frigate reports the mount asmount_type: 9p; on 2026-09-01 the nine recording cameras wrote 107 GiB through it in a day, and it has kept up for weeks. Retention covers the sizing.- Two ports, and one of them needs a warning.
8554is go2rtc's RTSP restream, which the 24/7 YouTube streams pull from.5000is Frigate's internal, unauthenticated UI and API. Frigate's installation docs describe it as "Internal unauthenticated UI and API access. Access to this port should be limited", and the official compose file ships it commented out with "Expose carefully". We publish it on purpose: the boot script's health poll, the keepalive, the Shorts pipeline and the read-only cam proxy on the web server (over Tailscale) all talk to it without a login. If a person is going to open the Frigate UI in a browser, publish"8971:8971"instead, which is the authenticated port, and drop5000unless something on the LAN needs it. Rememberfirewall=falseabove: whatever is published here is open to the whole LAN, so the router must not forward it.
ports:
- "8971:8971" # authenticated UI + API: the port for people and reverse proxies
# - "5000:5000" # internal, unauthenticated. Expose carefully. Ours is published for the LAN-side scripts.
- "8554:8554" # go2rtc RTSP restreams
frigate-boot.ps1 — the boot script
Runs at startup and at logon, as your user. The header is left in because the three reasons the first version never worked are the three mistakes everyone makes.
<#
frigate-boot.ps1 - make Frigate reliably come back after a Windows reboot.
Why the first version of this script could never work:
1. The task pointed at a path that did not exist. powershell.exe -File <missing>
exits 0xFFFD0000 - exactly the failure code Task Scheduler had been logging.
2. The task ran as SYSTEM. wsl.exe refuses to run as LOCAL SYSTEM
(Wsl/WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED), so every wsl call failed even
with the right path. Run it as your user.
3. It mounted a spare .vhdx over /srv/frigate. Docker's data-root IS
/srv/frigate/docker on the WSL root disk and holds the live container.
Mounting anything over it hides the container. Clips live on D:\frigate\media,
bind-mounted as /mnt/d/frigate/media. DO NOT MOUNT THE VHDX.
What this does instead: wait for D:, boot the Ubuntu distro (systemd starts
docker; frigate is restart=unless-stopped), then belt-and-braces `docker start`.
Home Assistant lives in the same docker, so it is started here too.
#>
$ErrorActionPreference = 'Continue'
$dir = 'C:\ProgramData\FrigateBoot'
$log = Join-Path $dir 'frigate-boot.log'
New-Item -ItemType Directory -Path $dir -Force | Out-Null
function L($m) {
$line = "{0} {1}" -f (Get-Date -Format s), $m
$line | Out-File -FilePath $log -Append -Encoding ascii
Write-Output $line
}
# keep the log from growing without bound
if ((Test-Path $log) -and (Get-Item $log).Length -gt 2MB) {
Move-Item $log "$log.1" -Force -ErrorAction SilentlyContinue
}
L "=== frigate-boot start (user=$env:USERNAME session=$((Get-Process -Id $PID).SessionId)) ==="
# 1. wait for the D: volume that holds the clips (up to 5 min)
$ok = $false
for ($i = 0; $i -lt 60; $i++) {
if (Test-Path 'D:\frigate\media') { $ok = $true; break }
Start-Sleep -Seconds 5
}
if (-not $ok) { L "FATAL: D:\frigate\media never appeared - aborting"; exit 1 }
L "D:\frigate\media present"
# 2. boot the Ubuntu distro. systemd (wsl.conf boot.systemd=true) starts docker,
# and restart=unless-stopped brings frigate back by itself.
for ($i = 1; $i -le 10; $i++) {
$r = (wsl.exe -d Ubuntu -u root -- bash -lc "echo UP" 2>&1 | Out-String).Replace("`0","").Trim()
L "wsl boot attempt ${i}: exit=$LASTEXITCODE out=$r"
if ($LASTEXITCODE -eq 0 -and $r -match 'UP') { break }
Start-Sleep -Seconds 15
}
if ($LASTEXITCODE -ne 0) { L "FATAL: could not start the Ubuntu distro"; exit 1 }
# 3. make sure docker is up, then start Frigate.
for ($i = 1; $i -le 10; $i++) {
$r = (wsl.exe -d Ubuntu -u root -- bash -lc "systemctl start docker >/dev/null 2>&1; systemctl is-active docker" 2>&1 | Out-String).Replace("`0","").Trim()
L "docker attempt ${i}: $r"
if ($r -match 'active') { break }
Start-Sleep -Seconds 10
}
$r = (wsl.exe -d Ubuntu -u root -- bash -lc "docker start frigate 2>&1; docker start homeassistant 2>&1; docker ps --format '{{.Names}} {{.Status}}'" 2>&1 | Out-String).Replace("`0","").Trim()
L "docker start (frigate + homeassistant): $r"
# 4. verify the API actually answers (up to 3 min - frigate takes a while to warm up)
$verified = $false
for ($i = 1; $i -le 18; $i++) {
try {
$stats = Invoke-RestMethod -Uri 'http://127.0.0.1:5000/api/stats' -TimeoutSec 10 -ErrorAction Stop
$cams = @($stats.cameras.PSObject.Properties.Name)
L ("API OK - {0} cameras: {1}" -f $cams.Count, ($cams -join ','))
$verified = $true
break
} catch {
Start-Sleep -Seconds 10
}
}
if (-not $verified) { L "WARNING: frigate API did not answer on 127.0.0.1:5000 within 3 min"; exit 1 }
# 5. Home Assistant lives in the same Ubuntu docker. Do not start Docker Desktop.
$haOk = $false
for ($i = 1; $i -le 12; $i++) {
try {
$resp = Invoke-WebRequest -Uri 'http://127.0.0.1:8123' -TimeoutSec 8 -UseBasicParsing -ErrorAction Stop
L "Home Assistant 8123 -> HTTP $($resp.StatusCode)"
$haOk = $true
break
} catch { Start-Sleep -Seconds 10 }
}
if (-not $haOk) { L "WARNING: Home Assistant did not answer on 127.0.0.1:8123 within 2 min" }
# 6. (site-specific) bring up the reverse tunnel that lets the web server reach HA.
$tun = (wsl.exe -d Ubuntu -u root -- bash /mnt/c/ProgramData/FrigateBoot/ha-wsl-tunnel.sh 2>&1 | Out-String).Replace("`0","").Trim()
L "ha tunnel: $tun"
if ($haOk) { L "=== frigate-boot done OK (frigate + HA) ==="; exit 0 }
L "=== frigate-boot done OK (frigate up; HA warning) ==="
exit 0
Step 6 is specific to this site (the web server reaches Home Assistant through a tunnel the boot script opens). Delete it if you have no such thing. Steps 1 to 4 are the part every Windows Frigate box needs: wait for the drive, wake the distro, make sure Docker is active, then poll /api/stats until it lists cameras. Each step retries with a ceiling, and each writes one line to the log so a failed boot is diagnosable from the log alone.
The anchor and the keepalive
WSL tears the distro down a few seconds after the last wsl.exe client exits, and vmIdleTimeout=-1 does not fully prevent it. The anchor is a hidden wsl.exe that never exits. The keepalive checks every two minutes that the anchor exists, that dockerd answers, and that the container is running, and repairs whichever is missing.
' wsl_anchor.vbs -- pins the Ubuntu WSL2 distro up permanently (hidden, no console window).
' Without an anchor, WSL tears the distro down seconds after the last wsl.exe client exits,
' which kills dockerd and therefore the frigate container.
Dim sh
Set sh = CreateObject("WScript.Shell")
sh.Run "C:\Windows\System32\wsl.exe -d Ubuntu -u root -e /bin/sh -c ""echo helen-wsl-anchor; exec sleep infinity""", 0, True
# wsl_keepalive.ps1 -- keeps the Ubuntu WSL2 distro (and therefore dockerd + the
# frigate container) permanently alive. Idempotent: safe to run every couple of minutes.
$ErrorActionPreference = "SilentlyContinue"
$env:PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSF;.MSC"
$wsl = "C:\Windows\System32\wsl.exe"
$log = "C:\ProgramData\helen\wsl_keepalive.log"
function L($m) { "$(Get-Date -f 'yyyy-MM-dd HH:mm:ss') $m" | Out-File -Append -Encoding ASCII $log }
# 1. Is an anchor already holding the distro open?
$anchor = Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" |
Where-Object { $_.CommandLine -like "*helen-wsl-anchor*" }
if (-not $anchor) {
L "anchor missing - launching"
Start-Process -WindowStyle Hidden -FilePath "C:\Windows\System32\wscript.exe" `
-ArgumentList '//B','//Nologo','C:\ProgramData\helen\wsl_anchor.vbs'
Start-Sleep -Seconds 25
}
# 2. Is dockerd up inside the distro?
$dockerOk = (& $wsl -d Ubuntu -u root -e /bin/sh -c "docker info >/dev/null 2>&1 && echo OK") 2>$null
if ("$dockerOk" -notmatch "OK") {
L "dockerd down - starting"
& $wsl -d Ubuntu -u root -e /bin/sh -c "systemctl start docker" 2>$null | Out-Null
Start-Sleep -Seconds 15
}
# 3. Is the frigate stack running? If not, compose it back up.
$running = (& $wsl -d Ubuntu -u root -e /bin/sh -c "docker ps --format '{{.Names}}'") 2>$null
foreach ($pair in @(@("frigate","/mnt/d/frigate"))) {
if ("$running" -notmatch [regex]::Escape($pair[0])) {
L "$($pair[0]) not running - compose up"
& $wsl -d Ubuntu -u root -e /bin/sh -c "cd $($pair[1]) && docker compose up -d" 2>$null | Out-Null
}
}
# keep the log from growing forever
if ((Test-Path $log) -and (Get-Item $log).Length -gt 1MB) {
Get-Content $log -Tail 500 | Set-Content $log -Encoding ASCII
}
The VBScript wrapper exists only to launch wsl.exe with no window; Start-Process -WindowStyle Hidden on wsl.exe directly still flashes a console. The helen-wsl-anchor string in the command line is how the keepalive finds it again.
Registering the tasks
# Elevated PowerShell. /RU is YOUR account, never SYSTEM. You will be prompted for the
# password so the task can run "whether user is logged on or not".
$boot = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\ProgramData\FrigateBoot\frigate-boot.ps1"'
$keep = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\ProgramData\helen\wsl_keepalive.ps1"'
schtasks /Create /F /TN FrigateNvrBoot /SC ONSTART /RL HIGHEST /RU <YOUR_USER> /TR $boot
schtasks /Create /F /TN FrigateNvrBootLogon /SC ONLOGON /RL HIGHEST /RU <YOUR_USER> /TR $boot
schtasks /Create /F /TN HelenWslAnchor /SC MINUTE /MO 2 /RL HIGHEST /RU <YOUR_USER> /TR $keep
schtasks /Query /TN FrigateNvrBoot /V /FO LIST | Select-String 'Run As User','Task To Run','Schedule Type'
The third task on this box, HelenOvGpuDetector (at boot, wsl.exe -d Ubuntu -u root -- systemctl start ov-gpu-detector), belongs to the detector page; the full inventory for a Frigate-on-Windows box is those three: boot, keepalive, detector. Three settings matter and Task Scheduler's defaults get all three wrong: run as your user (not SYSTEM, which wsl.exe refuses), run whether logged on or not (so a reboot with no one at the keyboard still counts), and highest privileges. If you prefer the GUI, those are the three checkboxes on the General tab. Look in C:\ProgramData\FrigateBoot\frigate-boot.log after the next reboot; a healthy run ends with API OK - 11 cameras.
Verification
# from Windows (mirrored networking) or from inside the distro - same address
curl -s http://127.0.0.1:5000/api/version
# 0.17.2-3d4dd3a
curl -s http://127.0.0.1:5000/api/stats | python -c "import sys,json; s=json.load(sys.stdin); [print(k, v['camera_fps'], v['skipped_fps'], v['detection_fps']) for k,v in s['cameras'].items()]; print(s['detectors'])"
# cat_water_bowl 2.1 0.0 1.3
# playroom_cam 2.1 0.0 2.1
# cat_food_bowl 2.1 0.0 4.3
# cat_treat_dispenser 2.1 0.0 2.5
# front_door 3.1 0.0 0.0
# back_yard 0.0 0.0 0
# carport 3.0 0.0 0.0
# cat_scratching_pad 2.1 0.0 3.4
# helen_food_face 2.1 0.0 3.1
# bed_cam_2 0 0 0
# bed_cam 2.0 0.0 0.0
# {'ov_gpu': {'inference_speed': 12.57, 'detection_start': 0.0, 'pid': 1073}}
skipped_fps: 0.0 on every camera is the number to care about: it means Frigate is keeping up with every frame it asked for. detection_fps is lower than camera_fps because Frigate only runs the detector on regions with motion; a camera pointed at an empty bowl will sit near zero for hours. detection_fps can also run above camera_fps on a busy camera (cat_food_bowl, 4.3 on 2.1 fps), because it counts inference calls and a frame with several regions costs several. inference_speed is the detector's round trip, 12.57 ms here on the iGPU. back_yard at 0.0 is what a dead camera looks like: camera_fps 0.0 with the process still alive (it had been offline for days). bed_cam_2 at all zeros is the camera-level enabled: false in the config: Frigate never starts its process, so its pid is null. A configured camera never disappears from /api/stats; if one is missing, it is not in the config Frigate loaded.