Skip to content

Building Windows releases on a Linux-only Forgejo runner

The Forgejo runner on the router only runs Linux job containers, and flutter build windows refuses to run anywhere but Windows. This guide adds a Windows 11 VM as a Composer-managed container on the same host, provisions it as a headless build box, and drives it over ssh from an ordinary ubuntu-latest job. The VM is started at the beginning of each release run and stopped at the end, so its 8 GB of guest RAM is only held while a build is in progress.

The first consumer was a fork of the Fladder Jellyfin client, kept so a Windows deadlock fix could ship without waiting for upstream. The pattern does not depend on Flutter: anything that needs MSVC, a Windows SDK or an installer compiler on Windows fits the same shape. Prerequisites: a Linux host with /dev/kvm, the runner from the Forgejo Actions runner reference, and Composer from the Composer GitOps reference. Every timing below was measured on 2026-10-09.

FactValueWhy it matters
Flutter Windows targetWindows host only: "build windows" only supported on Windows hosts. (flutter#110585)No cross-compile path from the Linux runner.
Windows toolchainVisual Studio “Desktop development with C++” workload (Flutter Windows setup)Installed by the provisioning script as Build Tools, no IDE.
HostMS-01 router, 32 GB RAM, KVM availableThe VM gets 8 GB / 6 vCPU, container limit 10 GB / 6 CPU.
Imagedockurr/windows:6.06Windows under QEMU/KVM inside one container (dockur/windows).
VM addressa static address (written VM_IP below) on the forge network forgejo_forgejo, below the network’s dynamic .128/25 rangeRunner job containers already join that network, so no firewall change.
Cold boot to ssh10-16 sCheap enough to boot per build.
Stopabout 10 s, an ACPI shutdownThe container turns SIGTERM into a guest shutdown.
Build5m54s (runs 11 and 12)Clean source tree each run.
Release run with start and stop7m02s (run 14)Start, ssh wait, build, stop.
Artifactsinstaller 64,535,441 bytes, portable zip 88,696,199 bytesPlus SHA256SUMS.txt.
git tag v0.11.1-erfi.Npushed to Forgejoforgejo-runner(router)ubuntu-latest job containeron forgejo_forgejoComposer APIcontainers/winbuild/start|stop1. start / 6. stopwinbuild containerstatic IP on forgejo_forgejoQEMU/KVM Windows 112. ssh wait3. scp src.tar4. build-windows.ps15. scp out.tarForgejo releasezip + Setup.exe + SHA256SUMS7. create + uploaddocker start/stop

In order: the tag push starts the job; the job asks Composer to start the winbuild container; it polls ssh until the guest answers; it copies a git archive of the tagged commit to the guest and runs the build script there; it copies the artifacts back as one tar; it stops the container; it creates the Forgejo release and uploads the files.

The container NATs the guest and DNATs every port it does not use itself (8006 for the web viewer, 5900 for VNC) to Windows, so ssh build@VM_IP reaches the guest’s OpenSSH server directly. That forwarding is the image’s default behaviour, set up in qemus/qemu src/network.sh.

One service, joined to the forge’s network as an external network rather than given one of its own:

services:
winbuild:
image: dockurr/windows:6.06
restart: unless-stopped
stop_grace_period: 2m
environment:
VERSION: "11"
USERNAME: ${WIN_USERNAME:?required}
PASSWORD: ${WIN_PASSWORD:?required}
RAM_SIZE: 8G
CPU_CORES: "6"
DISK_SIZE: 128G
MAC: "02:57:42:00:06:14"
devices: [/dev/kvm, /dev/net/tun, /dev/vhost-net]
cap_add: [NET_ADMIN]
volumes:
- /var/lib/winbuild/storage:/storage
- /var/lib/composer/stacks/winbuild/oem:/oem:ro
deploy:
resources:
limits: { cpus: "6", memory: 10G }
networks:
forgejo:
ipv4_address: VM_IP # a free static below the dynamic range
networks:
forgejo:
name: forgejo_forgejo
external: true
  • The static address sits below .128 because the forge network’s ip_range hands out .128/25 dynamically.
  • A fixed MAC keeps the guest’s network adapter identity across container recreates.
  • stop_grace_period: 2m gives the guest time to finish an ACPI shutdown before Docker kills it.
  • WIN_PASSWORD is SOPS-encrypted in the stack’s .env. It is only needed for the web console; ssh is key-only.
  • In Composer, the stack is a git stack on the router host with depends-on: forgejo, so the network exists before the VM is started.

The first up downloads the Windows 11 image, installs it unattended and logs in as USERNAME. The image copies the /oem folder to C:\OEM and runs install.bat from it “during the final step of the automatic installation” (dockur/windows README). It does this once only, so later edits to oem/ never reach a running guest.

install.bat only calls PowerShell, so the real work lives in a script that can be rerun later:

Terminal window
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\OEM\provision.ps1" > "C:\OEM\provision.log" 2>&1

provision.ps1 checks before it installs each piece, so rerunning it after a version bump only installs what changed. Versions are pinned in a sibling versions.ps1. In order:

  1. Disable sleep, hibernation and automatic Windows Update reboots. Enable long paths and Developer Mode, which Flutter needs to create plugin symlinks. Turn off Defender real-time scanning, which slows MSVC and cargo builds.
  2. Install OpenSSH Server from the Win32-OpenSSH MSI, key-only. The account is an Administrator, so its keys go in C:\ProgramData\ssh\administrators_authorized_keys, readable only by Administrators and SYSTEM (Microsoft Learn). Windows PowerShell becomes the default shell.
  3. Install Git, with core.longpaths set.
  4. Install VS 2022 Build Tools with VCTools, VC.Tools.x86.x64, VC.CMake.Project and VC.ATL (component IDs). If Build Tools are already installed without a listed component, the script runs setup.exe modify to add it.
  5. Install Rust via rustup, MSVC host. Fladder’s media-controls plugin compiles a Rust crate through cargokit during the Flutter build.
  6. Install Flutter at the version in the repo’s .fvmrc (3.35.7), then precache --windows.
  7. Install Inno Setup 6.

Every installer goes through one wrapper that fails on a bad exit code. The same wrapper retries exit 1618 (ERROR_INSTALL_ALREADY_RUNNING, MSI error codes) every 30 s, because at first logon Windows is still running its own installers. A full provisioning run took 9 minutes; VS Build Tools is most of that.

To rerun it after changing versions.ps1, copy the files in and run the script over ssh:

Terminal window
scp -J router -i ~/.ssh/id_winbuild oem/versions.ps1 oem/provision.ps1 build@VM_IP:C:/OEM/
ssh -J router -i ~/.ssh/id_winbuild build@VM_IP \
'powershell -ExecutionPolicy Bypass -File C:\OEM\provision.ps1'

The release repo needs three things:

NameKindContents
WINBUILD_SSH_KEYsecretprivate half of an ed25519 key whose public half is in oem/authorized_keys
WINBUILD_HOST_KEYvariablethe guest’s ssh-ed25519 AAAA... host key line, so the job runs with StrictHostKeyChecking yes
COMPOSER_WINBUILD_KEYsecreta Composer API key, role operator, minted for this one consumer

Mint the Composer key with POST /api/v1/keys and pipe the response straight into the Forgejo secret, so the plaintext key is never printed:

Terminal window
curl -sf -X POST -H "X-API-Key: $COMPOSER_API_KEY" -H 'Content-Type: application/json' \
-d '{"name":"forgejo-ci-winbuild","role":"operator"}' \
https://composer.erfi.io/api/v1/keys \
| jq -r .plaintext_key | fjctl secret set COMPOSER_WINBUILD_KEY -R erfi/fladder

The operator role can start and stop containers without being able to change users, keys or stacks. Give each consumer repo its own key so one can be revoked without breaking the others.

Key parts of .forgejo/workflows/release.yml, in order:

on:
push:
tags: ["v*-erfi.*"]
workflow_dispatch: {}
concurrency:
group: winbuild
cancel-in-progress: false
  • v0.11.1-erfi.1 means the upstream version from pubspec.yaml, then a fork counter. The workflow refuses a tag whose base does not match pubspec.yaml.
  • A manual dispatch builds the artifacts without publishing a release.
  • The concurrency group is shared by every repo that builds on this VM. That keeps one run’s stop step from shutting the VM down under another run’s build.

Upload the source as a git archive tar rather than cloning on the guest, so the VM never needs git credentials. Every build starts from a fresh tree:

Terminal window
git archive --format=tar -o /tmp/src.tar HEAD
scp -q /tmp/src.tar "$WINBUILD:C:/build/src.tar"
ssh "$WINBUILD" 'if (Test-Path C:\build\fladder) { Remove-Item -Recurse -Force C:\build\fladder };
New-Item -ItemType Directory C:\build\fladder | Out-Null;
tar -xf C:\build\src.tar -C C:\build\fladder; if ($LASTEXITCODE) { exit $LASTEXITCODE }'

The build script on the guest runs flutter pub get, flutter build windows --release --build-number=N, then ISCC.exe /DFLADDER_VERSION=... on the repo’s Inno Setup script. It zips build\windows\x64\runner\Release and writes SHA256SUMS.txt. The script also reloads PATH from the machine environment first, because an ssh session that predates provisioning has a stale one.

Pack the artifacts into a tar on the guest and copy that back. Do not scp a Windows wildcard path:

Terminal window
ssh "$WINBUILD" 'tar -cf C:\build\out.tar -C C:\build\out .; if ($LASTEXITCODE) { exit $LASTEXITCODE }'
scp -q "$WINBUILD:C:/build/out.tar" /tmp/out.tar && tar -xf /tmp/out.tar -C out

The release is created with the run’s own ${{ github.token }} against ${{ forgejo.server_url }}/api/v1, as in lane 2 of the releases reference. The notes list the fork’s commits on top of the upstream merge-base, excluding fork-only CI files, and paste the checksums.

The VM is normally stopped. The job’s first step starts it:

Terminal window
curl -sf -X POST -H "X-API-Key: $COMPOSER_KEY" "$COMPOSER_API/containers/winbuild/start"

Starting a container that is already running also returns 204, so this step does not need to check state first. The ssh setup step then polls for up to 5 minutes:

Terminal window
for i in $(seq 1 60); do
ssh -o ConnectTimeout=5 -o BatchMode=yes "$WINBUILD" hostname 2>/dev/null && exit 0
sleep 5
done
exit 1

The last step runs whether the build passed or failed:

- name: Stop winbuild VM
if: always()
run: |
curl -sf -X POST -H "X-API-Key: $COMPOSER_KEY" "$COMPOSER_API/containers/winbuild/stop" \
&& echo "winbuild stopped" || echo "::warning::winbuild stop failed - VM left running"

The stop call sends SIGTERM to the container. The image turns that into an ACPI shutdown and waits for it, logging Received SIGTERM signal, sending ACPI shutdown signal..., a few Waiting for Windows to shut down... lines, then Shutdown completed!. The whole call took 12 s. A failed stop only produces a warning: the build has already succeeded, and a VM left running costs RAM, not correctness.

Use the per-container endpoint, not the stack lifecycle endpoints. On this Composer version those act on the whole stack and silently ignore a services filter.

Two scheduled pieces keep the fork current, and fork-only files stay under .forgejo/ so rebases do not conflict:

  • upstream-drift.yml runs daily. It fetches upstream develop and reports how many commits behind the fork is. It runs git cherry to list which fork patches upstream has already merged, lists files changed on both sides, and does a test rebase in a throwaway worktree. While the fork is behind, it keeps one tracking issue open, comments only when upstream’s head moves (it reads back a <!-- upstream-head: SHA --> marker), and closes the issue on the first clean run.
  • sync-upstream.sh --push rebases the patched branch onto upstream develop and pushes with --force-with-lease. A fork patch that upstream has merged drops out of the rebase.

A checks workflow (flutter analyze, flutter test) runs on every push to patched in the ghcr.io/cirruslabs/flutter image matching .fvmrc. That image has no node, so actions/checkout cannot run inside it and the job fetches the commit with plain git.

CheckHowResult (2026-10-09)
Job network reaches the guestdocker run --rm --network forgejo_forgejo alpine:3 sh -c "nc -z -w5 VM_IP 22"open
Key-only sshssh -J router -i ~/.ssh/id_winbuild build@VM_IP hostnamewinbuild
Toolchainflutter doctor -v on the guestWindows and Visual Studio sections green
Cold startstop, then the start call, then poll sshssh up after 10-16 s
Clean stopcontainer log after the stop callShutdown completed!
Releasepush v0.11.1-erfi.1run 12 green, release with 3 assets
Start/stop round tripdispatch with the VM stoppedrun 14 green in 7m02s, container Exited afterwards
Artifactssha256sum -c SHA256SUMS.txt on the downloadsboth OK
Fix in the build5 cold launches of the installed app, 20 s each0 seconds “Not Responding”
Drift issuedispatch on a branch 5 commits behind, twice, then on patchedissue opened, second run skipped the comment, clean run closed it
  • The first-logon installer race skips OpenSSH silently. On the first run, the OpenSSH MSI returned immediately with 1618 because Windows was still installing its own packages. There was no error, so the next step failed with Cannot find any service with service name 'sshd'. Check every installer’s exit code, and retry 1618.
  • Write-Output inside a PowerShell helper becomes part of its return value. A Log function using Write-Output, called inside a download helper, turned the helper’s returned path into an array: Cannot convert 'System.Object[]' to the type 'System.String' required by parameter 'FilePath'. Log with Write-Host.
  • Plugins can need more of Visual Studio than Flutter’s docs list. flutter_local_notifications_windows includes atlbase.h, so the first build failed with C1083 until VC.ATL was added. Checking the components on an existing install with vswhere -requires lets the script repair that install instead of reinstalling.
  • ssh refuses a config file the job wrote with default permissions. chmod 600 the config and known_hosts after writing them in a heredoc.
  • The guest has no console you can read from CI. For debugging an install, tunnel VNC from the host (ssh -L 15900:VM_IP:5900 router) and grab frames with uvx --from vncdotool vncdo -s 127.0.0.1::15900 capture shot.png. The same tool can type into a PowerShell window.
  • Upstream’s template test fails on its own. Fladder’s test/widget_test.dart is the stock “Counter increments” smoke test and throws No ProviderScope found. Upstream CI never runs flutter test. The fork’s checks skip that one file and run the rest.
  • A tags-only workflow fired on a tag at an already-built commit. On 2026-09-18 a tag pushed to a commit that already had a push run fired nothing, three times over, including a delete and re-push. Here the tag’s commit had a push run of a different workflow (checks.yml), and the tags-only release.yml still fired. The deduplication, if that is what it is, looks per-workflow.
  • The image’s HOST variable equals the container name. A secret scanner that hashes every container environment value will then mask the container’s name everywhere. Exclude the image’s sizing and naming variables from it.
  • Windows stays unactivated. It shows a watermark and locks personalisation, which does not matter on a headless build host.
FileRepoPurpose
compose.yamlwinbuildThe VM container, network join, resource limits
oem/install.batwinbuildFirst-logon hook, calls provision.ps1
oem/provision.ps1winbuildIdempotent toolchain install
oem/versions.ps1winbuildVersion pins (Flutter, OpenSSH, Git, Inno Setup)
oem/authorized_keyswinbuildCI key and admin key
.envwinbuildSOPS-encrypted Windows account
.forgejo/workflows/release.ymlfladderStart VM, build, publish, stop VM
.forgejo/scripts/build-windows.ps1fladderBuild, installer, zip, checksums on the guest
.forgejo/workflows/checks.ymlfladderanalyze + test on push
.forgejo/workflows/upstream-drift.ymlfladderDaily drift report and tracking issue
.forgejo/scripts/upstream-drift.shfladderThe drift report; exit 0 clean, 1 drift
.forgejo/scripts/sync-upstream.shfladderRebase onto upstream and push