.NET 6 stopped receiving security patches on 12 November 2024. If you are still shipping a service on it, every unpatched CVE is now your problem rather than Microsoft’s. The retarget to .NET 8 is close to a half-day job — the traps are in what changes silently underneath it.
This is the mechanical walkthrough for a real ASP.NET Core service: move the target framework, pin the SDK, run the built-in analyzer, and then deal with the handful of breaking changes that will actually reach production. I will assume you own a service that builds and runs today on .NET 6 and needs to be on a supported runtime. The framework upgrade itself is not where the risk lives; the risk lives in the container port, the JSON serialiser, and the things the compiler will not warn you about.
The mechanical part is genuinely small
Two files carry most of the change. The first is the target framework moniker in the project file. The second is global.json, which pins the SDK so your build agents and your laptop agree on which compiler ran. If you do not have a global.json, add one — a service that builds against whatever SDK happens to be installed on the runner is a reproducibility problem waiting to be discovered during an incident.
Free · 4 minutes
Do you actually know what you are running — and what it is about to cost you?
Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.
<!-- MyService.csproj -->
<PropertyGroup>
<!-- was net6.0 -->
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
// global.json — pin the SDK, roll forward within the 8.0 band only
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
}
}
Change the moniker, pin the SDK, then run dotnet restore and dotnet build. Most of your NuGet packages will resolve a .NET 8 target framework automatically; the ones that do not are usually pinned to a 6.0-era major version and need bumping. Do that deliberately, one package at a time, reading each release note — a transitive dependency quietly jumping two majors is exactly how a serialisation or logging behaviour changes without anyone deciding it should.
Let the analyzer find the breaks
The .NET Upgrade Assistant is a global tool that will do the retarget for you and flag known incompatibilities. Even when you are comfortable editing the project file by hand, running it once is worth the few minutes because it surfaces breaks you would otherwise meet in production.
dotnet tool install -g upgrade-assistant
# run it against the project or solution
upgrade-assistant upgrade ./MyService.csproj
Treat its output as a checklist, not a verdict. It handles the moniker and obvious package updates well; it will not reason about your runtime behaviour. Worth knowing: Microsoft has since deprecated this tool in favour of a GitHub Copilot modernisation agent, so if it has disappeared from your tooling that is why — the manual TFM edit above still works regardless. The more durable safeguard is to run the build with warnings-as-errors in CI, so an analyzer-flagged obsolete API fails the pipeline rather than shipping. If your pre-commit and CI enforcement is already doing that job, this upgrade is where it earns its keep.
The breaking changes that actually bite
Four changes account for most of the production surprises on this particular jump.
The container port moved from 80 to 8080. This is the one that breaks health checks and load-balancer targets on the first deploy. The .NET 8 base images run as a non-root user by default, and a non-root process cannot bind to privileged port 80, so the default listening port is now 8080, controlled by the ASPNETCORE_HTTP_PORTS environment variable. If your Kubernetes readiness probe or your reverse proxy still points at 80, the pod comes up healthy from the runtime’s point of view and unreachable from everyone else’s.
System.Text.Json got stricter. The reflection-based serialiser resolves property metadata more eagerly in .NET 8, and several defaults shifted. If your API contract depends on exact JSON shape — and for a regulated service it usually does — assert on serialised output in tests rather than trusting that the wire format is unchanged.
Custom converters for ProblemDetails were removed from ASP.NET Core. If you registered a bespoke converter to shape error responses, it no longer takes effect and your error payloads change. Grep for ProblemDetails before you deploy.
The experimental concurrency-limiter middleware is gone, superseded by the rate-limiting middleware that shipped in .NET 7. If your service leaned on the older package, this is a rewrite, not a rename.
Bumping the container base image
The Dockerfile change is a straight tag bump from 6.0 to 8.0, but do it knowing the port and user changes above are baked into the new base image.
# build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish MyService.csproj -c Release -o /app
# runtime stage — was mcr.microsoft.com/dotnet/aspnet:6.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app .
# the image already runs as the non-root 'app' user and listens on 8080
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080
ENTRYPOINT ["dotnet", "MyService.dll"]
If you have a reason to keep the old behaviour during a phased rollout, you can set ASPNETCORE_HTTP_PORTS=80 and revert to root, but that is a decision to make consciously and unwind quickly, not a default to preserve out of habit. The non-root default is a security improvement you want.
Smoke-test the retargeted binary, then stop
Before trusting the upgrade, run the published binary and exercise the real paths: hit the health endpoint on 8080, round-trip a representative request through your serialiser, and confirm your error responses still match contract. This is precisely the change you want visible in your delivery metrics — a framework bump is a textbook case for watching change-failure rate and mean time to recovery so a bad retarget shows up as a number rather than a support ticket.
.NET 8 also unlocks assembly trimming and native AOT, which cut image size and cold-start time. Resist doing them in the same change. Trimming removes code the linker believes is unused, and reflection-heavy libraries — several serialisers, some DI patterns — break in ways that only appear at runtime. Ship the boring retarget first, prove it in production, then treat trimming and AOT as a separate, measured piece of work. Bundling them together is how a routine upgrade becomes a week of debugging.
One honest caveat on timing. .NET 8 is LTS, but its own support ends on 10 November 2026, and .NET 10 is now the current LTS. If you are doing the work anyway on a service that will outlive this year, retarget straight to net10.0 — the mechanics on this page are identical, and you buy yourself three more years instead of a few months. The same discipline applies whether the target is 8 or 10: this is the sort of unglamorous, deadline-driven modernisation that keeps a regulated estate supportable, the same instinct behind modernising a core without breaking client service. The upgrade you keep deferring is the one that eventually happens under an incident.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Looking at an acquisition, supplier, or major project?
The greatest risks are rarely visible in the executive summary. The Sixteen Pillars framework surfaces the technology risks that diligence usually misses.