How Helen Gets Made A making-of, not the live show Watch her live

Frigate · Windows · the files

The exact files that keep Frigate alive on a Windows PC

Seven files and three scheduled tasks, pasted from the running box with two edits: your user name is a placeholder, and the Home Assistant container is called homeassistant here where ours has a house name (change that line, or delete it if you have no Home Assistant). The reason for every line that is not obvious is next to it.

What the files are for, and why the box is arranged this way, is on Frigate on Windows.

Helen, a dilute tortoiseshell calico, on a camera whose recordings land on a Windows NTFS drive through WSL2
Her clips go container → 9p → NTFS. Docker's own files never do; that split is most of what this page is about.

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

/etc/wsl.conf
[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

C:\Users\<YOUR_USER>\.wslconfig
[wsl2]
networkingMode=mirrored
firewall=false
dnsTunneling=true
autoProxy=true
vmIdleTimeout=-1
  • networkingMode=mirrored — the distro shares the Windows network stack. Containers publishing 5000 and 8554 appear on 127.0.0.1 from Windows; the cameras' 192.168.1.x addresses 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 .wslconfig reference says true "allows the Windows Firewall rules, as well as rules specific to Hyper-V traffic, to filter WSL network traffic". So false means 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's 8123) and the detector's 5555: 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 leave firewall at its default of true and 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

/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

D:\frigate\docker-compose.yml (/mnt/d/frigate/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/dxg and the /usr/lib/wsl/lib bind + LD_LIBRARY_PATH are 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 to type: openvino crash-looped Frigate. They stayed because they are inert with a zmq detector; 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 says min_shm: 234 (from a reported camera_frame_size: 9.2 and shm_frame_count: 22). 256 MB clears that. If you add cameras or raise the detect resolution, read the new number and raise this.
  • The tmpfs at /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 as mount_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. 8554 is go2rtc's RTSP restream, which the 24/7 YouTube streams pull from. 5000 is 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 drop 5000 unless something on the LAN needs it. Remember firewall=false above: whatever is published here is open to the whole LAN, so the router must not forward it.
The ports block Frigate's docs recommend (what you should paste unless scripts need 5000)
    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.

C:\ProgramData\FrigateBoot\frigate-boot.ps1
<#
  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.

C:\ProgramData\helen\wsl_anchor.vbs
' 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
C:\ProgramData\helen\wsl_keepalive.ps1
# 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

Register the tasks (ours is one FrigateNvrBoot task with two triggers, boot and logon, which schtasks cannot express in one line, so it is two tasks here)
# 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

What healthy looks like (captured 2026-09-03 00:17 UTC, 3.9 days of uptime)
# 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.