Skip to content
← All posts
· 10 min read· By

Migrating .NET 8 to .NET 10: Breaking Changes, EF Core, and Docker

From net8.0 to net10.0: TFM swap, EF Core migrations, the new Ubuntu base image, and breaking changes in ASP.NET Core 10 — one coherent worked example.

.NET.NET 10EF CoreDockerASP.NET CoreMigrationGitHub ActionsDevOps

This article assumes you have already made the decision: .NET 10 LTS. If you are still in the "why now" stage, read the overview .NET 8 and .NET 9 reaching end of life in November 2026 first. This is about the how: net8.0 → net10.0, working through a concrete solution — Minimal API with EF Core 10 and SQL Server, a BackgroundService, multi-stage Dockerfile, Traefik as reverse proxy, and GitHub Actions for CI. The order of steps follows the order in which errors actually appear.

The example: one solution, two projects

The solution contains two projects going through the same upgrade path:

  • Api — Minimal API with EF Core 10 and SQL Server, behind Traefik
  • Worker — BackgroundService with PeriodicTimer, reading from the same database
+-------------------+     +--------------------+
|  Browser / Client |     |  Worker Container  |
+--------+----------+     +--------+-----------+
         |                         |
    +----+------+            +-----+------+
    |  Traefik  |            |  SQL Server|
    |   :443    |            |   :1433    |
    +----+------+            +-----+------+
         |                         ^
  +------+---------+               |
  |  Api Container  +---EF Core ---+
  |  :8080 internal|
  +----------------+

The following steps assume you have a working .NET 8 codebase.

TFM and SDK: the change that sets everything in motion

The only mandatory change according to the official upgrade guide is swapping the Target Framework Moniker. For a multi-project solution, centralise this in Directory.Build.props at the solution root:

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

The TFM change automatically moves the language version from C# 12 to C# 14. In most codebases this is invisible — except when a property accessor uses an identifier named field. In C# 14, field is the keyword for the synthesised backing field; a local symbol with that name now produces:

error CS9272: 'field' is a keyword within a property accessor.
Rename the variable or use the identifier '@field' instead.

Renaming or using @field resolves this in one line.

NETSDK1045 — when the SDK doesn't follow along

Anyone who changes the TFM without updating the SDK will immediately see:

NETSDK1045: The current .NET SDK does not support 'net10.0' as a target.
Either target 'net8.0' or lower, or use a version of the .NET SDK that supports 'net10.0'.

The most common cause: global.json is still pinned to an 8.x version. dotnet --list-sdks shows what is installed. After installing SDK 10:

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
  }
}

rollForward: latestFeature accepts newer feature bands (10.0.200, etc.) without giving up version pinning.

Go directly to .NET 10, not via .NET 9 — and why not wait for .NET 11

.NET 8 and .NET 9 both reach end of life on 10 November 2026 — the same day. Stopping at .NET 9 means a second migration within weeks, with zero additional support time gained. Waiting for .NET 11 is not a more stable choice: it ships as STS with only two years of support, and its GA date is 10.11.2026 — the exact day .NET 8 expires, with a shorter lifespan than .NET 10 LTS (which runs until November 2028). The third reason is EF Core: "EF10 will not run on earlier .NET versions" — EF Core 10 requires .NET 10. Anyone running EF Core 9 on net8.0 should complete the upgrade to .NET 10 now.

Packages, NuGet audit, and a new silent source of build failures

dotnet list package --outdated

Bump all Microsoft.AspNetCore.*, Microsoft.EntityFrameworkCore.*, Microsoft.Extensions.*, and System.Net.Http.Json to 10.0.x. The EF Core tools must match the same major version:

dotnet tool update --global dotnet-ef

With net10.0 as the TFM, NuGet switches the audit mode to all — transitive packages are now checked for vulnerabilities. With TreatWarningsAsErrors=true this breaks the build immediately. The cleaner solution over downgrading to NuGetAuditMode=direct is to selectively exclude the four audit warning codes, then use dotnet nuget why <package> to trace the dependency chain and fix the actual issue:

<PropertyGroup>
  <WarningsNotAsErrors>
    NU1901;NU1902;NU1903;NU1904;$(WarningsNotAsErrors)
  </WarningsNotAsErrors>
</PropertyGroup>

Remove System.Linq.Async

System.Linq.AsyncEnumerable is part of the standard library as of .NET 10. Anyone who directly referenced the community package System.Linq.Async will get ambiguity errors on LINQ methods for IAsyncEnumerable<T> after the upgrade. Remove the package reference from the csproj; for transitive usage, add <ExcludeAssets>compile</ExcludeAssets>.

EF Core 10: keeping migrations clean

What PendingModelChangesWarning means during an upgrade

The package upgrade changes EF Core's internal model snapshot. Starting with EF Core 9, MigrateAsync() throws an exception if the runtime model diverges from the last stored migration:

The model for context 'AppDbContext' has pending changes. Add a new migration
before updating the database. This exception can be suppressed or logged by
passing event ID 'RelationalEventId.PendingModelChangesWarning' to the
'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.

Fix: create a new (possibly empty) migration after the package upgrade, before deploying. A useful CI gate is dotnet ef migrations has-pending-model-changes — a non-zero exit code indicates a missing migration and correctly breaks the pipeline.

Migrations bundle instead of Migrate() at container startup

Migrate() at app startup is technically possible, but the EF Core documentation lists documented drawbacks: the application needs DDL rights on the database, there is no SQL review before execution, and rollback is not built in. For container deployments, the migrations bundle as a one-shot Compose service is the cleaner approach:

dotnet ef migrations bundle --self-contained -r linux-x64 \
  --project src/Api --startup-project src/Api \
  --output ./docker/efbundle

In the Compose stack, the bundle runs as a short-lived service; API containers wait via condition: service_completed_successfully:

services:
  db-migrate:
    image: my-app:${VERSION}
    entrypoint: /app/efbundle
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      ConnectionStrings__Default: "${DB_CONNECTION}"
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    image: my-app:${VERSION}
    depends_on:
      db-migrate:
        condition: service_completed_successfully
    stop_grace_period: 35s

The migration lock introduced in EF Core 9 prevents parallel execution by multiple replicas. How depends_on conditions and healthchecks are correctly wired up is covered in Docker HEALTHCHECK and restart_policy.

The Dockerfile after the base image swap

The change to the Dockerfile is just two FROM lines. What changes underneath is the base distribution — and that has measurable consequences.

Two nights in Docker: Debian out, Ubuntu in — numbers from the VPS

.NET 8 used Debian 12 as the default base. .NET 10 uses Ubuntu 24.04 (Noble Numbat) — Debian images are no longer shipped for .NET 10. Measured on 24 September 2026 on an arm64 VPS (Docker Engine 29.8.0, no local cache) via docker images after docker pull:

Image Base OS Unpacked size Pull time Runtime
aspnet:8.0 Debian 12 249 MB 7.4 s 8.0.31
aspnet:10.0 Ubuntu 24.04 262 MB 7.7 s 10.0.12
aspnet:10.0-noble-chiseled Ubuntu 24.04 (no shell) 131 MB 3.6 s 10.0.12

The direct swap from aspnet:8.0 to aspnet:10.0 makes the runtime image 5% larger (249 → 262 MB) — the distro change from Debian to Ubuntu accounts for this. Only switching to aspnet:10.0-noble-chiseled halves the size to 131 MB with the identical runtime version (10.0.12). How the full multi-stage Dockerfile is structured is covered in Multi-Stage Dockerfile for .NET and Node.js.

aspnet:10.0-noble-chiseled: half the size, but no shell

Chiseled images run as non-root by default (UID 1654), contain no package manager and no shell. This significantly reduces the attack surface. The concrete trade-offs:

  • HEALTHCHECK CMD curl ... and CMD sh -c ... do not work; health checking must go through the reverse proxy (Traefik health check) or a binary included in the image.
  • No ICU libraries by default: culture-sensitive string operations fail. Fix: use aspnet:10.0-noble-chiseled-extra (includes ICU and tzdata) or set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.

For apps behind Traefik that do not need shell-based runtime checks, chiseled is the recommended choice; halving the image size and running non-root by default justify the constraints. Regardless of the image, graceful shutdown works correctly: the Generic Host catches SIGTERM and shuts down in an orderly fashion (HostOptions.ShutdownTimeout default: 30 seconds). Since Docker terminates with SIGKILL by default after 10 seconds (per Docker documentation), set stop_grace_period: 35s in the Compose configuration.

ASP.NET Core 10: three breaking changes that surface in production

Forwarded headers and the "Unknown proxy" log entry

Since ASP.NET Core 8.0.17, the Forwarded Headers middleware discards X-Forwarded-* headers from proxies not explicitly configured as trusted. Anyone who has not registered their Traefik or nginx container as a known proxy will find Unknown proxy: 10.0.0.100:54321 in the log and observe that HTTPS redirects or auth checks silently fail. In .NET 10, the old ASP.NET-Core-internal IPNetwork type and KnownNetworks are obsolete (ASPDEPR005); the replacement is System.Net.IPNetwork and KnownIPNetworks:

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
    options.KnownProxies.Add(IPAddress.Parse("10.0.0.100"));
    // Entire Traefik Docker subnet:
    options.KnownIPNetworks.Add(
        new System.Net.IPNetwork(IPAddress.Parse("172.16.0.0"), 12));
});
app.UseForwardedHeaders(); // before UseHsts()

Cookie auth now returns 401 instead of 302

[ApiController] endpoints and Minimal APIs with JSON responses now return 401/403 instead of a redirect to the login URL when authentication is missing. For a pure web API this is the correct behaviour; SPAs typically do not notice the change because they handle auth flows independently. To opt back into redirect behaviour on a specific endpoint:

app.MapGet("/dashboard", () => Results.Ok())
   .RequireAuthorization()
   .AllowCookieRedirect();

WithOpenApi is deprecated

The new web API template uses AddOpenApi()/MapOpenApi() and serves the document at /openapi/v1.json (OpenAPI 3.1 as of .NET 10). WithOpenApi() is deprecated as ASPDEPR002. For operation customisation:

app.MapGet("/products", GetProducts)
   .AddOpenApiOperationTransformer((op, ctx, ct) => {
       op.Summary = "All products";
       return Task.CompletedTask;
   });

Swagger UI continues to work via Swashbuckle.AspNetCore.SwaggerUi pointing to /openapi/v1.json.

CI with GitHub Actions

actions/setup-dotnet@v6 supports dotnet-version: '10.0.x' and global-json-file. For the transition period, a short-lived matrix that tests both versions in parallel is practical:

strategy:
  matrix:
    dotnet: ['8.0.x', '10.0.x']
steps:
  - uses: actions/setup-dotnet@v6
    with:
      dotnet-version: ${{ matrix.dotnet }}
  - run: dotnet restore
  - run: dotnet build --configuration Release --no-restore
  - run: dotnet test --no-restore

Do not activate MTP during the upgrade

.NET 10 supports Microsoft Testing Platform (MTP) in addition to VSTest, activated via global.json. That switch applies repo-wide — no test project can use VSTest after it. While the 8/10 transition matrix is still running, do not activate MTP: test projects still targeting net8.0 would fail. MTP is a separate migration step after the TFM upgrade is complete. How GitHub Actions, GHCR push, and the build workflow are structured is covered in GitHub Actions CI for .NET and GHCR.

Questions from real-world migrations

Do I have to upgrade to EF Core 10 at the same time? No. EF Core 9 runs on net10.0. EF Core 10 does not run on earlier .NET versions. The recommendation is still to do both together: EF Core 9 has the same end-of-life date (10 November 2026), and EF Core 10 adds named query filters, a simplified ExecuteUpdateAsync syntax, and LeftJoin/RightJoin as proper LINQ operators.

What happens if ASPNETCORE_ENVIRONMENT is not set before building the bundle? The bundle is built with Development defaults — wrong connection string, wrong feature flags. The environment variable must be set both when running dotnet ef migrations bundle and when executing the bundle in the deployment.

The chiseled image throws a globalisation exception on startup. Chiseled images do not include ICU libraries. Culture-sensitive string comparisons and certain calendar operations will fail. Fix: use aspnet:10.0-noble-chiseled-extra (includes ICU and tzdata) or set DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 if the app does not need culture-dependent comparisons.

EF Core tools with multi-targeting: why does an error appear? Starting with EF Core 10, the tools require the target framework to be specified explicitly when <TargetFrameworks> (plural) is set: dotnet ef migrations add ... --framework net10.0. Without the flag: The project targets multiple frameworks. Use the --framework option to specify which target framework to use.

Native AOT — is it worth including in this upgrade? Not for an EF-Core-heavy web API. EF Core classifies its own AOT support as "highly experimental"; classic MVC is not supported under AOT. Native AOT is a separate undertaking after the TFM upgrade is complete, requiring a dedicated template (dotnet new webapiaot) and an AOT-compatible data access layer.

Official sources and next steps

Direct entry points at Microsoft: Upgrade to a new .NET version · ASP.NET Core 9→10 migration guide · EF Core 10 breaking changes · Migrations bundles · Container images: Ubuntu as the new default.

If you need support with the upgrade, reviewing breaking changes, or adapting your CI/CD pipeline, see our .NET development services.

Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the Impressum).

Discuss your project