Skip to content
compiler.dev

Speed Up .NET in GitHub Actions

Last updated

To speed up .NET in GitHub Actions, cache the NuGet package folder with actions/setup-dotnet, restore once, then build and test with --no-restore and --no-build so each step does only its own work. Most slow .NET pipelines run restore and build three times without noticing, because dotnet test restores and builds by default.

This guide covers caching, the build and test commands, parallel tests and database services. For the general version, see how to speed up GitHub Actions.

Stop repeating restore and build

dotnet build runs dotnet restore first. dotnet test runs build, which runs restore. If your workflow has separate steps for restore, build, test and publish with no flags, each repeats work. Run each stage once:

- run: dotnet restore --locked-mode
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build -c Release
- run: dotnet publish src/Api -c Release --no-build -o out

--no-restore skips the implicit restore. --no-build skips the implicit build and uses the output already there, so the configuration (-c Release) must match across steps. On a solution with many projects, this alone often removes several minutes.

Cache the NuGet package folder

actions/setup-dotnet has built-in caching. Per its README, it is off by default and enabled with cache: true. It caches NuGet's global packages folder, and the key is built from packages.lock.json. The README states that if no lock file exists, the action throws an error, so you need lock files. Use cache-dependency-path when they live in subdirectories.

- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
  with:
    dotnet-version: 9.0.x
    cache: true
    cache-dependency-path: "**/packages.lock.json"

To generate lock files, add <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> to your project or a Directory.Build.props, run dotnet restore once locally, and commit the packages.lock.json files. In CI, dotnet restore --locked-mode then fails if the lock file is out of date, which also makes builds reproducible. The README notes that only the packages folder is restored, so a restore may print warning NU1403 unless you apply the setting it describes; read that section if you hit it.

If you do not want lock files, use actions/cache on ~/.nuget/packages with a key from **/*.csproj and Directory.Packages.props:

- uses: actions/cache@v6
  with:
    path: ~/.nuget/packages
    key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
    restore-keys: nuget-${{ runner.os }}-

Parallelism in build and test

  • Build. MSBuild builds projects in parallel with -m (--maxcpucount). dotnet build enables it by default, using all cores. If you call msbuild directly, pass -m.
  • Tests across assemblies. dotnet test runs test projects in parallel according to MSBuild settings, and runs tests in one assembly using the framework's own rules.
  • xUnit. Test classes are separate collections and run in parallel with each other by default; tests in one class run in order. Tune it in xunit.runner.json with maxParallelThreads. Tests that share state should be put in a [Collection] so they run serially.
  • NUnit and MSTest. Add [Parallelizable] or [assembly: Parallelize]; they are serial by default.

Check CPU use during the test step. If it sits near one core, you are leaving speed on the table.

Services for integration tests

An ASP.NET Core app with SQL Server often uses a service container:

services:
  sql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    env:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "Your_password123"
    ports: ["1433:1433"]
    options: >-
      --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P Your_password123 -Q 'SELECT 1'"
      --health-interval 10s --health-timeout 5s --health-retries 12

SQL Server needs at least 2 GB of memory and takes longer to start than Postgres, so a health check is essential. The image path to sqlcmd has changed between versions, so confirm it for the tag you pin. If you use Testcontainers, the same startup costs apply per container; share one container per test run through a collection fixture, and make each test create its own database or schema.

A complete workflow

name: dotnet
on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: dotnet-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

env:
  DOTNET_NOLOGO: true
  DOTNET_CLI_TELEMETRY_OPTOUT: true
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          dotnet-version: 9.0.x
          cache: true
          cache-dependency-path: "**/packages.lock.json"
      - run: dotnet restore --locked-mode
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release --logger "trx;LogFileName=test.trx"

The DOTNET_* variables turn off first-run work and telemetry, which saves a little on each fresh VM. For release builds, ReadyToRun and trimming add real time, so reserve them for the publish job on main.

What more CPU cores buy you

MSBuild and the Roslyn compiler use all cores, and parallel test frameworks use them too. Our benchmarks include React + ASP.NET Core + SQL Server: 3m34s on a GitHub-hosted runner and 1m41s on compiler.dev (2.1x). That was a full CI run with 10 test shards and a real database, measured 8 October 2026. SQL Server's memory appetite is part of why a machine with 16 GB helps; a 7 GB runner leaves less room for the database and the build.

To try yours, change one line to runs-on: [self-hosted, Linux, compiler-ci]; see pricing and compare. Related: the .NET template, GitHub Actions caching and test sharding.

FAQ

Does setup-dotnet cache NuGet packages?

Yes, with cache: true. It needs a packages.lock.json and will error without one. Use cache-dependency-path for lock files in subfolders.

What does dotnet test --no-build do?

It runs tests from the existing build output without building again. The configuration flag must match the earlier build.

Why is dotnet restore slow in CI?

A fresh VM has an empty NuGet cache and downloads every package. Cache the packages folder and use --locked-mode for reproducible restores.

Can I run .NET tests in parallel?

Yes. xUnit parallelizes test collections by default; NUnit and MSTest need an attribute. Isolate shared state first.

Run this workflow on compiler.dev 200 free minutes

Start free