Speeding Up Windows Builds on GitHub Actions

Windows GitHub Actions jobs stall on file-heavy checkouts, Defender scans, and NuGet restores. Diagnose each cause, then size the WarpBuild runner.

Last verified:

A slow Windows job on GitHub Actions almost always comes down to five causes: NTFS file-copy overhead on a large checkout, Microsoft Defender scanning every file the job writes, NuGet restore going to the network on every run, MSBuild compiling one project at a time, and a runner too small for the project graph. Fix them in that order, then move the job to a larger WarpBuild Windows label such as warp-windows-2025-x64-8x, which lists at $0.032 per minute against $0.042 per minute for the 8-core GitHub-hosted Windows larger runner at the same 8 vCPU and 32 GB shape, a 24 percent lower list price (GitHub billing reference, checked on 2026-08-13).

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. This guide covers the Windows fleet: how to find the real cause of a slow Windows job, what to change in the workflow, which image and size to land on, and what the change does to the monthly bill.

Diagnosis

Before changing a label, find out where the minutes go. Open a recent Windows run, expand the job, and read the per-step durations that GitHub prints next to every step name. Sort them mentally into four buckets: queue time before the job starts, checkout, dependency restore, and compile plus test. Each bucket has a different fix, and applying the wrong one wastes a week.

If the wait happens before any step logs appear, the problem is queue time rather than build time, and the fix lives in a different place. Read diagnosing GitHub Actions queue times first and come back here once jobs start promptly.

WarpBuild's CI observability collects OpenTelemetry system metrics from the runner agent and correlates them with GitHub Actions job logs. That correlation is how you separate a CPU-bound compile from an IO-bound restore without guessing: a step pinned at 100 percent on one core with idle disk is a serialized MSBuild, while a step with heavy disk activity and low CPU is file churn or scanning.

NTFS file-copy overhead on large checkouts

actions/checkout writes every tracked file to disk. On NTFS, each file creation carries more metadata work than the equivalent operation on ext4, so a repository with 40,000 tracked files costs meaningfully more wall-clock time on Windows than on Linux for the identical clone. Line-ending conversion adds to it when core.autocrlf is left at the Git for Windows default, because Git rewrites file contents on the way to disk.

Three signals point at this bucket: the checkout step is among the top three steps by duration, the repository has a large working tree or deep history, and the same checkout on an Ubuntu job in the same workflow finishes much sooner.

Microsoft Defender real-time scanning

Windows Server images ship Defender with real-time protection on. Every file that checkout writes, every package that NuGet extracts, and every assembly that MSBuild emits gets inspected as it lands. A build that produces tens of thousands of files pays that inspection tens of thousands of times.

This one is invisible in step names. It shows up as uniform slowness spread across checkout, restore, and build rather than a single slow step, and as high system CPU that does not belong to dotnet.exe or MSBuild.exe.

NuGet restore over the network

WarpBuild runners are ephemeral. The virtual machine is fresh at the start of the job and destroyed at the end, so the global packages folder under the runner user profile is empty on every run unless you restore it from a cache. A solution with 300 package references downloads and extracts all 300 every time, and the extraction step lands back in the Defender bucket above.

Two more restore-side costs are easy to miss. Restore evaluates the project graph, and on a large solution the non-static-graph evaluation walks projects repeatedly. Floating versions, meaning any package reference written with a wildcard, force NuGet to contact the feed to resolve the newest match even when a cached copy would have served.

MSBuild building one project at a time

msbuild.exe runs a single node unless you pass -m. A 60-project solution on a 16 vCPU runner then compiles one project at a time while 15 cores idle. The same trap applies when a build script pins -maxcpucount:1, and when node reuse is left on, since reused MSBuild worker processes are pointless on a machine that gets destroyed at the end of the job.

Test execution has its own version of the problem. dotnet test on a solution runs test assemblies in sequence by default, so a suite split across 12 assemblies serializes 12 times even though the machine has cores to spare.

An undersized runner

Windows itself consumes a share of the machine that Linux does not: the operating system services, Defender, and the GitHub Actions runner agent all want CPU and RAM before your build gets any. On a small runner that overhead is a large fraction of the total. WarpBuild removed Windows 2vCPU runners on June 8, 2026, so the smallest Windows label available today is 4 vCPU with 16 GB of RAM, and heavy Visual Studio solutions want more than that.

Fix

Work down this list in order. The first three cost nothing per minute and often return more time than the runner change does. The fourth and fifth cost money, so apply them once the cheap fixes are in. The platform-neutral versions of these steps, such as job graph shape and artifact handling, are covered in the general guide to speeding up GitHub Actions; everything below is Windows-specific.

1. Trim the checkout. Set fetch-depth: 1 so the job clones one commit instead of the full history. If the build only touches part of a monorepo, add a sparse checkout in cone mode so the runner never writes the directories the build ignores. Set core.autocrlf to false in the job when the repository does not need conversion, so Git writes bytes straight through.

2. Exclude the work directory from Defender real-time scanning. Add exclusions as the first step of the job, before checkout, so nothing that follows gets inspected. Exclude the workspace path, the NuGet packages folder, and the compiler processes. WarpBuild Windows runners run under the runneradmin user, the same user GitHub's Windows runners use, so the Add-MpPreference cmdlet works without an extra elevation step. The runner is ephemeral and destroyed after the job, so the exclusions never outlive the run.

3. Cache the NuGet packages folder. Point NUGET_PACKAGES at a short path such as C:\nuget, key an actions/cache entry on the hash of your packages.lock.json files, and restore it before dotnet restore. Lock files are what make the key stable, so turn on RestorePackagesWithLockFile and run restore with --locked-mode in the workflow so a drifted lock file fails the job instead of silently re-resolving. Add -p:RestoreUseStaticGraphEvaluation=true so restore evaluates the project graph once.

Two constraints matter here. WarpBuild caching is enabled by default on Linux runners and is documented as unsupported on Windows runners, so the cache you use on Windows is GitHub's Actions cache service through actions/cache. That action works from a WarpBuild runner with no extra configuration, since the runner receives the same cache service credentials any GitHub Actions job receives.

4. Turn on MSBuild parallelism and turn off the parts that do not help. Pass -maxcpucount explicitly rather than relying on an SDK default that changes between versions. Set MSBUILDDISABLENODEREUSE=1, because worker reuse buys nothing on a machine that is about to be destroyed and occasionally holds file locks that break later steps. Split restore and build into separate steps and pass --no-restore to build so the graph is evaluated once. Add RunConfiguration.MaxCpuCount=0 to dotnet test so the test host uses every available core.

5. Size the runner to the project graph, then shard. Move up one size at a time and read the observability metrics after each move. When the build stops scaling with cores, stop paying for cores and split the work across jobs instead: a test matrix of four shards runs four 8 vCPU runners in parallel rather than one 32 vCPU runner that a serialized test host cannot fill. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps.

6. Keep Linux work on Linux. Cross-platform .NET projects often run their entire pipeline on Windows out of habit. Push the parts that do not need Windows toolsets onto warp-ubuntu-latest-x64-8x at $0.016 per minute and keep Windows for the Windows-only targets. The toolchain detail for .NET pipelines lives in running .NET builds on GitHub Actions.

Configuration

Choosing the image

WarpBuild publishes three Windows image families. All three carry the same tooling as the equivalent GitHub-hosted runner image, documented on the preinstalled software page, plus Tailscale, which stays inactive until networking is configured.

Image familyLabelsVisual StudioUse it when
Windows Server 2022warp-windows-latest-x64-<size>, alias warp-windows-2022-x64-<size>Visual Studio 2022The pipeline currently targets windows-2022 and you want a like-for-like move
Windows Server 2025warp-windows-2025-x64-<size>Visual Studio 2022You want the newer Windows Server base with the toolset you already build against
Windows Server 2025 with VS 2026warp-windows-2025-vs2026-x64-<size>Visual Studio 2026The solution needs Visual Studio 2026 toolsets or SDKs

The vs2026 labels are transitional. They use the same Windows Server 2025 base image and differ only in the installed Visual Studio version, and Visual Studio 2026 may become the default on the warp-windows-2025-x64 labels in a later update, which tracks GitHub's own rollout of the Windows Server 2025 with Visual Studio 2026 image.

Pin the explicit version label rather than latest when the build depends on a specific toolset. warp-windows-latest-x64-8x follows Windows Server 2022 today, and a floating label that moves under a pinned MSBuild version is a bad trade for a release pipeline.

The Windows catalog

LabelOSvCPURAMStoragePrice per minute
warp-windows-latest-x64-4xWindows Server 2022416 GB256GB SSD$0.016
warp-windows-latest-x64-8xWindows Server 2022832 GB256GB SSD$0.032
warp-windows-latest-x64-16xWindows Server 20221664 GB256GB SSD$0.064
warp-windows-latest-x64-32xWindows Server 202232128 GB256GB SSD$0.128
warp-windows-2025-x64-4xWindows Server 2025416 GB256GB SSD$0.016
warp-windows-2025-x64-8xWindows Server 2025832 GB256GB SSD$0.032
warp-windows-2025-x64-16xWindows Server 20251664 GB256GB SSD$0.064
warp-windows-2025-x64-32xWindows Server 202532128 GB256GB SSD$0.128
warp-windows-2025-vs2026-x64-4xWindows Server 2025 (VS 2026)416 GB256GB SSD$0.016
warp-windows-2025-vs2026-x64-8xWindows Server 2025 (VS 2026)832 GB256GB SSD$0.032
warp-windows-2025-vs2026-x64-16xWindows Server 2025 (VS 2026)1664 GB256GB SSD$0.064
warp-windows-2025-vs2026-x64-32xWindows Server 2025 (VS 2026)32128 GB256GB SSD$0.128

Rates and shapes come from the WarpBuild cloud runners documentation, checked on 2026-08-13. The full catalog with regions and aliases lives on WarpBuild Windows runners for GitHub Actions.

The workflow

This is the whole fix expressed as a workflow. It applies the Defender exclusions first, restores the NuGet folder from the Actions cache, and builds and tests with explicit parallelism.

name: windows-build

on:
  pull_request:
  push:
    branches: [main]

env:
  DOTNET_NOLOGO: "true"
  DOTNET_CLI_TELEMETRY_OPTOUT: "true"
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true"
  MSBUILDDISABLENODEREUSE: "1"
  NUGET_PACKAGES: C:\nuget

jobs:
  build:
    runs-on: warp-windows-2025-x64-8x
    timeout-minutes: 30
    steps:
      - name: Exclude build paths from Defender real-time scanning
        shell: powershell
        run: |
          Add-MpPreference -ExclusionPath "${{ github.workspace }}"
          Add-MpPreference -ExclusionPath "C:\nuget"
          Add-MpPreference -ExclusionProcess "MSBuild.exe"
          Add-MpPreference -ExclusionProcess "dotnet.exe"
          Add-MpPreference -ExclusionProcess "VBCSCompiler.exe"

      - name: Check out one commit
        uses: actions/checkout@v4
        with:
          fetch-depth: 1
          sparse-checkout: |
            src
            tests
          sparse-checkout-cone-mode: true

      - name: Keep line endings as committed
        shell: powershell
        run: git config --global core.autocrlf false

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"

      - name: Restore the NuGet packages folder from the Actions cache
        uses: actions/cache@v4
        with:
          path: C:\nuget
          key: nuget-windows-${{ hashFiles('**/packages.lock.json') }}
          restore-keys: |
            nuget-windows-

      - name: Restore
        run: >
          dotnet restore Contoso.sln
          --locked-mode
          -p:RestoreUseStaticGraphEvaluation=true

      - name: Build
        run: >
          dotnet build Contoso.sln
          --configuration Release
          --no-restore
          -maxcpucount

      - name: Test
        run: >
          dotnet test Contoso.sln
          --configuration Release
          --no-build
          --logger trx
          -- RunConfiguration.MaxCpuCount=0

Two details in that file are easy to get wrong. The Defender step runs before actions/checkout, because an exclusion added after the files land does nothing for the files already scanned. And NUGET_PACKAGES is set at the workflow level so the cache path, the restore, and the build all agree on one folder.

Sharding the test suite

Once the build is fast and the tests are the tail, split them. Four 8 vCPU runners cost the same per minute in total as one 32 vCPU runner at $0.128, and a serialized test host fills four small machines far better than one large one.

jobs:
  test:
    runs-on: warp-windows-2025-x64-8x
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"
      - name: Test shard ${{ matrix.shard }}
        run: >
          dotnet test Contoso.sln
          --configuration Release
          --filter "Shard=${{ matrix.shard }}"
          --logger trx
          -- RunConfiguration.MaxCpuCount=0

Cost or Time Model

Windows runner rates are linear in vCPU on WarpBuild: every Windows vCPU-minute lists at $0.004, so a 4 vCPU minute is $0.016 and a 32 vCPU minute is $0.128. That linearity is what makes the sizing decision arithmetic instead of opinion.

List price at identical shapes

WarpBuild labelShapeWarpBuild per minuteGitHub-hosted larger runnerGitHub per minuteLower list price
warp-windows-latest-x64-4x4 vCPU, 16 GB$0.0164-core Windows larger runner$0.02227 percent
warp-windows-latest-x64-8x8 vCPU, 32 GB$0.0328-core Windows larger runner$0.04224 percent
warp-windows-latest-x64-16x16 vCPU, 64 GB$0.06416-core Windows larger runner$0.08222 percent
warp-windows-latest-x64-32x32 vCPU, 128 GB$0.12832-core Windows larger runner$0.16221 percent

WarpBuild rates come from the cloud runners documentation and the pricing page. GitHub rates come from GitHub's published per-minute prices for Windows larger runners on the Actions billing reference and github.com/pricing, both checked on 2026-08-13.

A worked model

Assumptions, all of them inputs you should replace with numbers from your own run history rather than measured results published here:

  • 1,000 Windows jobs per month, roughly 45 pull request and merge builds per weekday.
  • A baseline job that takes 20 minutes on a 4 vCPU Windows runner.
  • Billing at one minute of granularity, which is how WarpBuild bills GitHub Actions runners.

Step one, same shape, lower rate. 1,000 jobs times 20 minutes is 20,000 Windows minutes per month. At $0.016 that is $320. The same 20,000 minutes on the 4-core GitHub-hosted Windows larger runner at $0.022 is $440. Nothing about the build changed, and the monthly line is $120 lower.

Step two, the free fixes. Suppose the Defender exclusions, the NuGet cache, and -maxcpucount take the job from 20 minutes to 15. That is 15,000 minutes, or $240 per month on the 4 vCPU label. Minutes are the multiplier on every rate in the table above, so this step improves whichever runner you are on.

Step three, decide whether a bigger runner pays. An 8 vCPU minute costs $0.032, exactly twice a 4 vCPU minute. So the break-even is simple: the job has to finish in under half the time to cost less. Break-even minutes equal 15 times 0.016 divided by 0.032, which is 7.5 minutes. If the 8 vCPU job lands at 10 minutes, the bill goes from $240 to $320 per month and buys 5 minutes off every run.

Price that time. 1,000 jobs times 5 minutes is 5,000 minutes, or roughly 83 hours per month of engineers waiting on a pull request check. $80 per month is a cheap trade for 83 hours, which is the point: a bigger Windows runner is a wall-clock purchase, and the list price gap is what keeps the purchase small.

If your Windows fleet is large enough that compute economics dominate, BYOC runs on AWS, GCP, and Azure, with Windows BYOC runners listed at $0.002 per minute in WarpBuild fees on AWS and Azure and the compute billed by your own cloud account.

FAQ

Why is the same repository slower to build on Windows than on Linux in GitHub Actions?

Windows pays more per file operation. A checkout, a NuGet package extraction, and a build output tree each write thousands of small files, every one of those writes passes through Microsoft Defender real-time scanning, and the Windows image carries a larger operating system working set than the Ubuntu image. Trim the checkout, exclude the work directory and the package folder from real-time scanning, and cache the NuGet packages folder before you touch the runner size.

Which WarpBuild Windows runner label should I start with?

Start on warp-windows-2025-x64-8x at $0.032 per minute, which gives the job 8 vCPU and 32 GB. Move up to warp-windows-2025-x64-16x only after the job logs show the build saturating 8 vCPU. The smallest Windows label is 4 vCPU because Windows 2vCPU runners were removed on June 8, 2026.

Does WarpBuild caching work on Windows runners?

No. WarpBuild caching is enabled by default on Linux runners and is documented as unsupported on Windows runners. On Windows, cache the NuGet packages folder with actions/cache, which stores entries in GitHub's own Actions cache service and works from a WarpBuild runner without extra configuration.

When should I use the vs2026 Windows labels?

Use warp-windows-2025-vs2026-x64-4x and its larger siblings when the solution needs Visual Studio 2026 toolsets. Those labels are transitional. They carry Visual Studio 2026 on the same Windows Server 2025 base image, while the warp-windows-2025-x64 labels still carry Visual Studio 2022.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.