boot-persistence-windows.md 7.0 KB

Boot Persistence on Windows

Process Compose has no built-in service install (unlike portless). On Windows, register a Task Scheduler entry.

Key Constraints

  1. Task Scheduler runs with a minimal PATH — Python, uv, Git tools, custom binaries won't be found unless we set PATH explicitly
  2. Tasks running at boot-before-logon need LogonType S4U (no stored password, no interactive logon)
  3. Hidden window style avoids console flash on login

Two-File Pattern

Use a wrapper script that sets the environment, then have Task Scheduler launch the wrapper. Keeps task definition simple and lets you tweak env without re-registering.

File 1 — boot-start.ps1 (wrapper)

<#
.SYNOPSIS
    Boot-time launcher for Process Compose. Sets PATH and launches headless.
#>

[CmdletBinding()]
param()

$ErrorActionPreference = 'Continue'

$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$root      = (Resolve-Path (Join-Path $scriptDir '..')).Path
$pcExe     = Join-Path $root 'bin\process-compose.exe'
$pcYaml    = Join-Path $root 'process-compose.yaml'
$logFile   = Join-Path $root 'logs\process-compose.log'
$bootLog   = Join-Path $root 'logs\boot-start.log'

New-Item -ItemType Directory -Force -Path (Join-Path $root 'logs') | Out-Null

"[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')] boot-start invoked. User: $env:USERNAME" | Out-File -FilePath $bootLog -Append

# Build PATH explicitly. Tune for your machine.
$pathParts = @(
    "$root\bin"                                                       # PC + any committed binaries
    "C:\Program Files\Git\usr\bin"                                    # openssl, bash, coreutils
    "C:\Program Files\Git\bin"                                        # git
    "C:\Users\$env:USERNAME\AppData\Local\Programs\Python\Python313"  # python, pythonw
    "C:\Users\$env:USERNAME\AppData\Local\Programs\Python\Python313\Scripts"  # uv, pip, etc.
    "C:\Program Files (x86)\cloudflared"                              # optional: cloudflared
    "C:\Windows\System32"
    "C:\Windows"
    $env:PATH
)
$env:PATH = ($pathParts -join ';')

# Optional: load secrets from gitignored .env (e.g. API keys)
$envFile = Join-Path $root '.env'
if (Test-Path $envFile) {
    Get-Content $envFile | ForEach-Object {
        if ($_ -match '^\s*([A-Z_]+)\s*=\s*(.+?)\s*$') {
            [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')
        }
    }
}

# Ensure incompatible env vars are unset (example: OAuth-only services that
# refuse to start with stale API keys)
# [Environment]::SetEnvironmentVariable('SOME_API_KEY', $null, 'Process')

"[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')] Starting process-compose..." | Out-File -FilePath $bootLog -Append

# -p 8888    API port (pick something free, avoid 8080 if you have other tools there)
# -t=false   no TUI (headless daemon mode)
# -L         PC's own log file
& $pcExe -p 8888 -t=false -L $logFile up -f $pcYaml

"[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK')] process-compose exited code $LASTEXITCODE" | Out-File -FilePath $bootLog -Append

File 2 — boot-task-install.ps1 (registers the task)

[CmdletBinding()]
param()

$ErrorActionPreference = 'Stop'

# Must be admin to create scheduled tasks
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal   = New-Object Security.Principal.WindowsPrincipal($currentUser)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw "Run as Administrator."
}

$scriptDir  = Split-Path -Parent $MyInvocation.MyCommand.Path
$root       = (Resolve-Path (Join-Path $scriptDir '..')).Path
$bootScript = Join-Path $scriptDir 'boot-start.ps1'

$taskName = "ProcessCompose-MyStack"   # rename per project

# Idempotent: remove existing if present
Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue |
    Unregister-ScheduledTask -Confirm:$false

$action = New-ScheduledTaskAction `
    -Execute "powershell.exe" `
    -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$bootScript`"" `
    -WorkingDirectory $root

$trigger = New-ScheduledTaskTrigger -AtStartup

$settings = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
    -AllowStartIfOnBatteries `
    -DontStopIfGoingOnBatteries `
    -RestartCount 3 `
    -RestartInterval (New-TimeSpan -Minutes 1)

# S4U: run at boot as user without interactive logon or stored password
$taskPrincipal = New-ScheduledTaskPrincipal `
    -UserId $env:USERNAME `
    -LogonType S4U `
    -RunLevel Highest

Register-ScheduledTask -TaskName $taskName `
    -Action $action -Trigger $trigger -Settings $settings -Principal $taskPrincipal `
    -Description "Starts Process Compose at boot."

LogonType Trade-offs

LogonType Runs at boot before logon? Needs password? Capability
Interactive No — waits for user logon No Full user context (UI, network shares)
S4U Yes No User context but no UI, no network shares
Password Yes Yes (stored encrypted) Full user context
ServiceAccount Yes (as Local System / Network Service) No Limited to service account perms — typically can't read user files

For Process Compose managing user-scoped dev services, S4U is usually the right choice: services run as the user (can read C:\Users\<user>\...) without requiring an interactive logon.

Verify After Registration

# Check task exists
Get-ScheduledTask -TaskName "ProcessCompose-MyStack" |
    Format-List TaskName, State, Triggers, Principal

# Manually run the task to test before reboot
Start-ScheduledTask -TaskName "ProcessCompose-MyStack"

# Wait, then check PC is up
Start-Sleep -Seconds 10
process-compose -p 8888 process list

Troubleshooting Boot Failures

After a reboot, if services don't come up:

  1. Check the boot log: <root>/logs/boot-start.log — confirm the wrapper actually ran
  2. Check PC's log: <root>/logs/process-compose.log — confirm PC started and look for process-spawn errors
  3. Check Task Scheduler history: Right-click the task → History tab. Look for failure reasons.
  4. Reproduce manually: open elevated PS, run .\scripts\boot-start.ps1 and watch what happens.

Common failures:

  • PATH missing a tool → add to pathParts array
  • Working dir not absolute → ensure all paths in process-compose.yaml are absolute
  • Secrets not loaded → .env file not in expected location
  • Port collision (PC API port 8888 occupied) → check netstat -ano | findstr :8888

Pair with portless service install

portless has its own boot task. The two are independent — register both:

portless service install              # registers portless's task
.\scripts\boot-task-install.ps1       # registers PC's task

# Verify both
Get-ScheduledTask | Where-Object {
    $_.TaskName -like "*ortless*" -or $_.TaskName -like "*ompose*"
}

Uninstall

# In the same script:
Get-ScheduledTask -TaskName "ProcessCompose-MyStack" -ErrorAction SilentlyContinue |
    Unregister-ScheduledTask -Confirm:$false

portless service uninstall