GitHub Actions Windows Runners: VHDX Caching to the Rescue

by Chad
Published February 25, 2025
Last updated February 25, 2025

Waves Waves

In this follow-up post about slow GitHub Actions workflows on Windows hosted runners, I'll walk through a virtual hard disk (VHDX) caching technique that can drastically cut your Windows CI times.

GitHub Actions Windows Runners: VHDX Caching to the Rescue

Remember when I wrote about how slow GitHub's Windows hosted runners can be? I've been tinkering with the VHDX approach I mentioned, and I wanted to share it. In this follow-up post, I'll walk through a practical implementation that can drastically cut your Windows build times.

TL;DR

  • VHDX caching can significantly speed up GitHub Actions Windows workflows
  • By storing pre-installed tools in a VHDX file and caching it between runs, you avoid reinstalling everything
  • I've created a sample repo that demonstrates this technique

The Problem, Revisited

In my previous post, I wrote how Windows runners suffer from I/O bottlenecks, especially on the C: drive backed by Azure remote storage. We explored some mitigations like moving I/O operations to the D: drive (faster, but temporary storage) and tweaking .NET builds via environment variables. But what if we could take this a small step further?

The fundamental issue remains: every time a GitHub Actions workflow runs, your workflows may need to download and install the same tools over and over again. This repetitive setup is particularly painful on Windows, where installing development tools is notoriously I/O intensive.

Enter VHDX Caching

Virtual Hard Disks (VHDX) offer an elegant solution to this problem. Here's the general idea:

  1. Create a VHDX file once
  2. Install all your tools inside it
  3. Cache the entire VHDX between workflow runs
  4. Mount the cached VHDX in subsequent runs

This approach effectively lets you "freeze" your entire development setup environment and skip the lengthy setup process on each run.

The Implementation

I've created a sample repo that showcases this technique using two workflows:

1. The Cache Creation Workflow

The first workflow (cache-vhdx.yml) handles the heavy lifting:

- name: Create and Mount VHDX
  run: |
    $Drive = New-VHD -Path D:\dev_drive.vhdx -SizeBytes 50GB -Dynamic |
        Mount-VHD -PassThru |
        Initialize-Disk -PassThru |
        New-Partition -UseMaximumSize |
        Format-Volume -FileSystem NTFS -Confirm:$false -Force
    $Drive | Get-Partition | Set-Partition -NewDriveLetter 'X'

It then installs common tools like PowerShell 7, Node.js, Git, Visual Studio Build Tools, and .NET SDK directly to the X: drive. Finally, it caches the entire VHDX file for future use:

- name: Save VHDX to Cache
  uses: actions/cache/save@v4
  with:
    path: D:\dev_drive.vhdx
    key: vhdx-cache-${{ github.run_id }}

Depending on what software is being installed, this can be time consuming. Ideally, workflows consuming the cached VHDX file will be run much more frequently than needing to recreate a new VHDX file.

2. The Test Workflow

The second workflow (test-cache-vhdx.yml) is where the magic happens:

- name: Restore VHDX from Cache
  uses: actions/cache/restore@v4
  with:
    path: D:\dev_drive.vhdx
    key: vhdx-cache
    fail-on-cache-miss: true

- name: Mount VHDX
  run: |
    $Drive = Mount-VHD -Path D:\dev_drive.vhdx -PassThru
    $Drive | Get-Disk | Get-Partition | Where-Object { $_.Type -eq 'Basic' } | Set-Partition -NewDriveLetter 'X'

Just like that, all tools are available without any installation steps!

Real-World Considerations

Before you rush off to implement this in your production workflows, there are some things to keep in mind:

  1. Tool Compatibility: Not all tools work gracefully with this approach. Some may still require manual installation or repair steps. Test carefully!
  2. Cache Size Limits: GitHub Actions has a 10GB cache size limit per repository, so you'll need to be mindful of what you include in your VHDX.
  3. Cache Invalidation: You'll need a strategy for updating tools. I recommend using a specific cache key pattern that includes version information.
  4. Windows Server 2025: This example uses windows-2025 runners, which include built-in support for VHDX operations. For older runner versions, you might need additional setup steps. Windows Server 2025 includes WinGet out of the box, so I personally found that very helpful for this approach.
  5. Tool Organization: Think carefully about how you organize tools within the VHDX to make path management simpler.
  6. Software Bill of Materials (SBOM): Consider generating an SBOM during VHDX creation to track exactly what versions of tools are cached. This becomes necessary for security compliance, as you'll need to know when vulnerabilities are discovered in cached components and when to invalidate your cache. Without proper tracking, you might be using outdated, vulnerable tools without realizing it.

Getting Started

Want to try this yourself? It's surprisingly straightforward:

  1. Create the two workflow files in your repository
  2. Run the cache creation workflow once
  3. Enjoy lightning-fast setup times in all subsequent workflows!

Check out my sample for code examples and instructions.

What's Next?

GitHub, at the time of this writing, has it in their public roadmap to implement a Custom Images feature that allows producing custom VM images that can be used as the base of workflows and developer environments. While the techniques in this article are a clever workaround for now, I welcome this blog post becoming obsolete!

Until next time, happy building! 👋

Read Next

Multi-Tenanted Entity Framework Core Migration Deployment image

April 11, 2021 by Chad

Multi-Tenanted Entity Framework Core Migration Deployment

There's many ways to deploy pending Entity Framework Core (EF Core) migrations, especially for multi-tenanted scenarios. In this post, I'll demonstrate a strategy to efficiently apply pending EF Core 6 migrations using a .NET 6 console app.

Read Article
Multi-Tenanted Entity Framework 6 Migration Deployment image

April 10, 2021 by Chad

Multi-Tenanted Entity Framework 6 Migration Deployment

There's many ways to deploy pending Entity Framework 6 (EF6) migrations, especially for multi-tenanted production scenarios. In this post, I'll demonstrate a strategy to efficiently apply pending migrations using a .NET 6 console app.

Read Article