Our CI pipeline was taking 45 minutes. Developers would push a PR, go for coffee, come back, find a failure, fix it, push again, and go for another coffee. We cut it to under 10 minutes. Here is every optimization that mattered.
Incremental Builds with NuGet Cache
The single biggest win was caching NuGet packages and build artifacts between runs. Every CI run was downloading 400+ NuGet packages from scratch, which alone took 3-4 minutes.
# GitHub Actions example
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: nuget-
- name: Cache build output
uses: actions/cache@v4
with:
path: |
**/bin
**/obj
key: build-${{ hashFiles('**/*.cs', '**/*.csproj') }}
Parallelize Test Execution
Our test suite had 2,400 tests across 19 projects. Running them sequentially took 25 minutes. Running them in parallel with dotnet test maxing out available cores brought it down to 8 minutes.
<!-- Directory.Build.props -->
<PropertyGroup>
<TestRunnerParallelization>true</TestRunnerParallelization>
</PropertyGroup>
# Run tests across projects in parallel
dotnet test Postnomic.slnx --parallel --configuration Release
We also profiled individual test projects to find the slow ones. Two projects were using real database connections in tests. Switching them to in-memory providers cut 5 minutes off the suite.
Build Only What Changed
For large solutions, building everything on every commit is wasteful. We use dotnet build with the --no-incremental flag only on the main branch. PR builds use incremental builds combined with path filters.
# Only run API tests when API code changes
on:
pull_request:
paths:
- 'src/Postnomic.Api/**'
- 'src/Postnomic.Domain/**'
- 'src/Postnomic.Data/**'
- 'tests/Postnomic.Api.Tests/**'
jobs:
test-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: dotnet test tests/Postnomic.Api.Tests/Postnomic.Api.Tests.csproj
Docker Layer Caching for Container Builds
Our Dockerfile was rebuilding from scratch on every push. A multi-stage Dockerfile with proper layer ordering reduced container build time from 8 minutes to 90 seconds.
# Stage 1: Restore (cached unless csproj files change)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore
WORKDIR /src
COPY *.sln .
COPY src/*/*.csproj ./
RUN for file in *.csproj; do mkdir -p src/${file%.*} && mv $file src/${file%.*}/; done
RUN dotnet restore
# Stage 2: Build (cached unless source files change)
FROM restore AS build
COPY src/ src/
RUN dotnet publish src/Postnomic.Api/Postnomic.Api.csproj -c Release -o /app
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Postnomic.Api.dll"]
The Compound Effect
No single optimization was dramatic. NuGet caching saved 4 minutes. Parallel tests saved 17 minutes. Incremental builds saved 6 minutes. Docker layer caching saved 7 minutes. Together, they transformed the developer experience. PRs get feedback in under 10 minutes, developers stay in flow, and the team ships faster. Pipeline speed is a direct multiplier on engineering productivity.
Comments (2)
Could you elaborate on this topic in a follow-up post?
I agree, great article!
Good question, I'd like to know too.
This is exactly what I was looking for, thank you!
Leave a Comment