.NET 10 LTS Production Patterns — A Migration Story

0 comments 846 views

Software Engineering dotnet

22 min read 4395 words

TL;DR

On May 7, 2026, I migrated the bilalkose.com.tr web application and its 5 dependent packages from .NET 8 to .NET 10 LTS. The result: 6 csproj target framework bumps, 26 Microsoft package updates, major version pushes of 9 custom packages, AspNetCoreRateLimit removed in favor of native AddRateLimiter, AutoMapper 16 constructor adaptation, and SqlClient TLS default changes — a total of 0 errors + 18 warnings on build, 10/10 smoke tests green. This article covers the production gotchas that AI-generated guides skip over, the six fracture points I hit along the real migration path, and how I solved them.

Author's note: Every code snippet in this article is taken from the live bilalkose.com.tr codebase. The migration date is 2026-05-07; file paths and error messages come from the real production environment.

Table of Contents

  1. Why .NET 10 LTS — What's Changing Now?
  2. Migration Setup: 6 csproj + 26 Packages + Core Feed
  3. Rate Limiter Migration: AspNetCoreRateLimit → AddRateLimiter
  4. AutoMapper 16 Constructor Breaking Change
  5. SqlClient New Defaults (TLS surprise)
  6. Serilog 4.3 + ASP.NET Core 10 Transitive Pitfalls
  7. Docker and Kubernetes Production Deploy
  8. Before Production: 10 Smoke Tests
  9. Observability: Prometheus + OpenTelemetry
  10. 7 Takeaways from This Migration
  11. Frequently Asked Questions — 10 Q&A

Why .NET 10 LTS — What's Changing Now?

.NET 10 reached general availability in November 2025 and falls under Long-Term Support. That means it will receive official security patches through November 2028. The practical meaning for enterprise systems: the next "three calm years."

Microsoft's LTS cadence repeats every two years: .NET 6 (November 2021), .NET 8 (November 2023), .NET 10 (November 2025). Odd-numbered versions in between (.NET 7, .NET 9) are "Standard Term Support" — 18 months of support. The LTS focus is clear: enterprise engineering teams make decisions on the even-numbered releases.

Two facts force the timing. First: on November 10, 2026, .NET 8 and .NET 9 reach a combined EOL (End-of-Life). From November 2026 onward, those versions stop receiving official CVE patches. In the Turkish market, sector surveys conducted in early 2026 estimate that roughly 60% of .NET teams are still on .NET 8; 25% on .NET 9; and 15% on something newer — meaning a very large migration wave is coming in Q3-Q4 2026. Teams that start now buy themselves maneuvering room before the crunch. Second: CVE-2026-40372 (a DataProtection padding-oracle vulnerability) is only patched in .NET 10.0.7+. If you're on .NET 9, there is no alternative.

Concrete benefits of being an early adopter:

  • Performance: ASP.NET Core 10 shows roughly 10-20% throughput gains over .NET 8 in horizontal load tests (Microsoft official). This is especially visible in the JSON serializer and middleware allocation profile. I'm planning my own benchmark run on this setup.
  • Native rate limiter: Microsoft.AspNetCore.RateLimiting is now in the framework. Third-party package dependency drops (details below).
  • C# 14: Class-level primary constructors, field-targeted attribute syntax, partial properties, lambda parameter modifiers (ref/out) — less boilerplate, more readable domain models. Particularly valuable for EFCore entities.
  • Native AOT maturity: With ASP.NET Core 10, native AOT is now production-ready for standard web APIs. Cold start drops from 3-4 seconds to 50-100ms (critical for Kubernetes auto-scaling scenarios).
  • Smaller image: The mcr.microsoft.com/dotnet/aspnet:10.0 Docker layer is roughly 3% smaller than 8.0.
  • OpenTelemetry maturation: Better integration with diagnostic and tracing APIs than before — distributed tracing setup is cut roughly in half.

This article is not an overview of those new features. Microsoft Learn's release summary already covers that ground. The focus here is the six fracture points I hit along the real migration path of a mid-sized production app, and how I resolved each.

Migration Setup: 6 csproj + 26 Packages + Core Feed

Let's get familiar with the system I migrated, because most decisions vary with setup size.

Codebase:

  • 1 web application (ASP.NET Core, Razor + MVC + some minimal API endpoints)
  • 4 Application/Domain/Persistence/Infrastructure layer csprojs (similar to Clean Architecture)
  • 1 test project (MSTest)
  • 9 custom packages (Core.Application.Base, Core.CrossCuttingConcerns.Base, Core.Mailing.Base, Core.Test.Base, etc. — on my own feed at nuget.bilalkose.com.tr)

Target:

  • Target framework net8.0net10.0
  • 26 Microsoft.* packages (AspNetCore, EFCore, Extensions.*, System.Drawing.Common, System.Text.Json) 8.x → 10.0.x
  • All 9 custom packages 1.x → 2.0.0 (major bump — contains breaking changes, AutoMapper 16 transitive required)
  • Third-party packages: AspNetCoreRateLimit removal, Serilog bumped to 4.3, Microsoft.NET.Test.Sdk 18.0.0, MSTest 3.10

Roadmap:

  1. Add global.json: "sdk.version": "10.0.100", "rollForward": "latestFeature", "allowPrerelease": true — my dev machine has the preview SDK, prod Docker will pull GA.
  2. Dockerfile base image bump: sdk:8.0sdk:10.0, aspnet:8.0aspnet:10.0.
  3. Bump target framework in 6 csprojs.
  4. Update <Version> for the 26 Microsoft packages.
  5. All 9 custom packages move to net10.0 in their own csprojs + transitive bump (especially AutoMapper 13 → 16, IdentityModel.Tokens 7.5.2 → 8.0.0, MimeKit/MailKit 4.4 → 4.14, BouncyCastle drop).
  6. dotnet pack + dotnet nuget push × 9 packages → my own feed.
  7. Custom package references in the web application csprojs go from 1.x → 2.0.0.
  8. AspNetCoreRateLimit removal + AddRateLimiter migration.
  9. Build + smoke test.

Total time: about 6-8 hours of focused work + 1-2 hours of smoke testing and fixes. For a typical Turkish SaaS company, this is 1-2 days for 1-2 team members.

Major bump table for 9 custom packages (self-hosted feed nuget.bilalkose.com.tr):

#Package1.x2.0.0Breaking change
1Core.Application.Base1.5.x2.0.0net10.0 + AutoMapper 16 transitive
2Core.CrossCuttingConcerns.Base1.x2.0.0net10.0 + Serilog 4.3
3Core.Mailing.Base1.x2.0.0MimeKit/MailKit 4.4 → 4.14, BouncyCastle drop
4Core.Persistence.Base1.x2.0.0EFCore 8 → 10, SqlClient 5.x defaults
5Core.Security.Base1.x2.0.0IdentityModel.Tokens 7.5.2 → 8.0.0
6Core.Test.Base1.x2.0.0MapperConfiguration ctor adapt + MSTest 3.10
7Core.ElasticSearch.Base1.x2.0.0NEST → Elastic.Clients.Elasticsearch upgrade path
8Core.Caching.Base1.x2.0.0Microsoft.Extensions.Caching 8 → 10
9Core.HealthChecks.Base1.x2.0.0AspNetCore.HealthChecks 8.x → 10.x

For each package: dotnet pack -c Release + dotnet nuget push --source bilal-nuget --api-key dummy (the self-hosted feed accepts anonymous push). The same version cannot be overwritten (409 Conflict) — I have to bump the version on every retry.

Rate Limiter Migration: AspNetCoreRateLimit → AddRateLimiter

.NET 10's native rate limiter lives under the Microsoft.AspNetCore.RateLimiting namespace. The AspNetCoreRateLimit (5.0.0) dependency still compiles on .NET 10 — so technically removal wasn't required. But two medium-term reasons forced the call:

  1. The vendor has not committed to new CVE patches for this package; it isn't tested on .NET 10, it just "works."
  2. The native API has lower allocation (zero-allocation hot path) and is managed in code instead of piling 124 lines of config into appsettings.json.

Migration code — actual production implementation:

Two design decisions are worth noting. First: I chose the GlobalLimiter pattern over [EnableRateLimiting("policy-name")] attributes. Reason: doing URL-based routing in a single place is more readable across deploys than adding attributes to 30+ controllers. Second: SlidingWindow instead of FixedWindow — it absorbs burst traffic more smoothly (it avoids the 2× spike problem at window boundaries).

services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
    {
        string ip = ResolveClientIp(ctx);
        if (IsLocalhost(ip))
            return RateLimitPartition.GetNoLimiter("local:" + ip);

        // GET path: single global sliding window
        if (ctx.Request.Method != HttpMethods.Post)
        {
            return RateLimitPartition.GetSlidingWindowLimiter(
                "global-get:" + ip,
                _ => new SlidingWindowRateLimiterOptions
                {
                    PermitLimit = 200,
                    Window = TimeSpan.FromMinutes(1),
                    SegmentsPerWindow = 4,
                    QueueLimit = 0
                });
        }

        // POST path: URL pattern → policy mapping
        string path = ctx.Request.Path.Value?.ToLowerInvariant() ?? "";
        (string policy, int limit, TimeSpan window, int segments) = path switch
        {
            var p when p.StartsWith("/api/mailgonder")
                    || p.StartsWith("/api/donation/create")
                => ("mail", 12, TimeSpan.FromMinutes(1), 4),

            var p when p.StartsWith("/api/donation/verify")
                => ("donation-verify", 20, TimeSpan.FromMinutes(1), 4),

            var p when p.Contains("/contact")
                => ("contact", 20, TimeSpan.FromHours(1), 6),

            var p when p.Contains("/blog/details")
                    || p.Contains("/projects/addrating")
                => ("comment", 20, TimeSpan.FromHours(1), 6),

            var p when p.StartsWith("/api/")
                => ("api-default", 12, TimeSpan.FromMinutes(1), 4),

            _ => ("global-post", 60, TimeSpan.FromMinutes(1), 4)
        };

        return RateLimitPartition.GetSlidingWindowLimiter(
            $"{policy}:{ip}",
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = limit,
                Window = window,
                SegmentsPerWindow = segments,
                QueueLimit = 0
            });
    });

    options.OnRejected = async (context, token) =>
    {
        var http = context.HttpContext;
        http.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        http.Response.ContentType = "text/html; charset=utf-8";

        int retryAfter = 60;
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan ra))
            retryAfter = (int)Math.Ceiling(ra.TotalSeconds);
        http.Response.Headers["Retry-After"] = retryAfter.ToString(CultureInfo.InvariantCulture);

        // rateLimit429Html template is read from file (3 languages + emoji support)
        await http.Response.WriteAsync(string.Format(rateLimit429Html,
            "—", "—", retryAfter), token);
    };
});

app.UseRateLimiter();

The ResolveClientIp helper follows the chain CF-Connecting-IP > X-Real-IP > RemoteIpAddress — mandatory when you're behind Cloudflare, otherwise all traffic falls into a single IP partition and the limiter becomes useless. IsLocalhost bypasses ::1 and 127.0.0.1 (for the dev test flow).

URL pattern → policy map:

PolicyLimitWindowURL pattern
mail121 minute/api/mailgonder*, /api/donation/create*
donation-verify201 minute/api/donation/verify*
contact201 hour/contact anywhere
comment201 hour/blog/details*, /projects/addrating*
api-default121 minuteall other /api/*
global-post601 minutefinal POST-side fallback
global-get2001 minuteall GET requests

429 HTML template: Early in the pipeline I read an HTML template via RateLimitQuotaExceededHtml.GetPhysicalPath(env.ContentRootPath) (3 languages + emoji + retry timer). If the file is missing, a FallbackTemplate kicks in. This pattern provides a big caching benefit — the template isn't re-rendered on every 429; only 3 placeholders get filled in via string.Format.

Thanks to the Retry-After: 60 header, well-behaved clients (especially SDKs and mobile apps) compute their backoff automatically.

Outcome: 124 lines of IpRateLimiting and IpRateLimitPolicies blocks were deleted from appsettings.json. Seven patterns now live in a single SecurityServiceCollectionExtensions.cs file — deploy diffs are clear, code review is fast.

Migration time: ~1 hour. Cleaning up using AspNetCoreRateLimit; in four files, removing package refs in two csprojs, writing the seven-pattern mapping, OnRejected + HTML template integration, and smoke testing.

The small victory of removal: I deleted the IpRateLimiting and IpRateLimitPolicies blocks from appsettings.json — 124 lines gone, no leftover whitespace. Less JSON config is always a good signal: deploy diffs are more readable, secrets can't leak into a config file, and dev/prod behavior differences become apparent through code review. This small "diff reduction" moment was the clearest sign of the ergonomic side of moving to .NET 10 — less config, more code.

Smoke test example:

# /api/mailgonder quick rate limit check (mail policy: 12/1m)
for i in {1..15}; do
  curl -s -o /dev/null -w "%{http_code} " https://yourapp.com/api/mailgonder
done
# Expected: 12 × 200/202, 3 × 429
# Retry-After header set on the final 429 (see with curl -I)

AutoMapper 16 Constructor Breaking Change

The transitive requirement to move from AutoMapper 13 → 16.1.1 was triggered by the custom package bump (while cleaning BouncyCastle CVEs out of Core.Mailing.Base, transitive AutoMapper came along for the ride). During migration, the compiler error:

error CS7036: There is no argument given that corresponds to the required parameter 'loggerFactory'
  of 'MapperConfiguration.MapperConfiguration(Action<IMapperConfigurationExpression>, ILoggerFactory)'

Starting in AutoMapper 14, the MapperConfiguration constructor made the ILoggerFactory parameter required. In earlier versions it was optional or didn't exist at all.

Where it hits: Test setup, because on the production DI side services.AddAutoMapper(typeof(SomeProfile)) already injects ILoggerFactory from the container. The problem typically surfaces in test base classes or in integration test factories.

Fix:

// BEFORE (AutoMapper 13):
var config = new MapperConfiguration(cfg =>
{
    cfg.AddProfile<SomeProfile>();
});
var mapper = config.CreateMapper();

// AFTER (AutoMapper 16):
var config = new MapperConfiguration(
    cfg => cfg.AddProfile<SomeProfile>(),
    NullLoggerFactory.Instance);  // <-- required parameter
var mapper = config.CreateMapper();

Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance produces no logs in the test context; in production the real ILoggerFactory comes from the DI container anyway. This fix landed in a single file (BaseMockRepository.cs) inside my Core.Test.Base package and did not propagate to the main web-app code.

No other breaking changes — the Profile API, ProjectTo, and dynamic mapping are all backward compatible with 13.

Passage — the first compile shock: When I bumped the Core packages to 2.0.0 and ran dotnet build, the compiler threw CS7036 across 7 files. My first reflex was "what a widespread breaking change!" — then I looked at the traces: all of them pointed to the same MapperConfiguration ctor, but 5 were in production DI and 2 in the test base. On the production DI side, services.AddAutoMapper(...) already injects ILoggerFactory from the container — the compiler error was due to using directive ordering or the incremental compilation cache. After dotnet clean + dotnet restore --force, prod errors dropped from 7 to 2, leaving only the test base class fix. Total fix time: 10 minutes, but a classic "wait, what?" migration trap.

SqlClient New Defaults (TLS surprise)

This was the most frustrating part of the migration — because the error message is misleading.

The EFCore 8 → 10 jump pulls Microsoft.Data.SqlClient 5.x transitively. Starting in SqlClient 5.0, the connection string defaults changed:

  • Encrypt default: FalseMandatory (TLS required)
  • TrustServerCertificate default: False

So a previously working simple connection string (Server=...;Database=...;User Id=...;Password=...;) now throws:

A connection was successfully established with the server,
but then an error occurred during the login process.
(provider: SSL Provider, error: 0 — The handshake failed
due to an unexpected packet format.)

At first glance, this looks like a SQL Server, network, or credential issue — but the real cause is the TLS handshake. If SQL Server uses a self-signed cert, or forced encryption is on in prod, the old connection string can't complete the handshake.

Solution — production-grade connection string:

Server=...;Database=...;User Id=...;Password=...;
Encrypt=True;
TrustServerCertificate=True;
Connect Timeout=60;
MultipleActiveResultSets=True;

Three main parameters:

  • Encrypt=True (same as Mandatory, TLS on) — the connection stays encrypted
  • TrustServerCertificate=True — accept self-signed certs; required if you don't have a corporate Public CA-signed certificate (common for cluster-internal SQL Server)
  • Connect Timeout=60 (seconds) — Kubernetes cold start + TLS handshake = ~15-30 seconds total; the default 15 seconds may not be enough

My production config strategy — two layers:

I keep the basic parameters in appsettings.json (TrustServerCertificate=True;MultipleActiveResultSets=True;) but leave the actual password and sensitive side as a REPLACE_ME placeholder. At pod start, the ConnectionStrings__BaseDb patch is pulled from HashiCorp Vault's secret/bilal-website path (AddVaultSecrets("bilal-website") extension method). The full string in Vault includes Encrypt=True;Connect Timeout=60 — so in production the configuration comes entirely from Vault, while the repo placeholder is there only for fingerprinting.

Vault secret patch flow:

kubectl exec -n vault vault-0 -- vault kv patch bilal-website   ConnectionStrings__BaseDb="Server=...;...;Encrypt=True;TrustServerCertificate=True;Connect Timeout=60;..."

When the pod restarts (kubectl rollout restart deployment/myapp), the new string takes effect. If the Vault sidecar reloader is running in the cluster, a restart isn't even necessary — the secret change propagates automatically.

The story of how this problem cost me 30 minutes: On the morning of May 7, 2026, when I started locally with dotnet run, the first error was "Login failed." I first checked the SQL Server configuration (server logs clean), then the K8s network policy (iptables clean). But the first line of the error message — SSL Provider, error: 0 — The handshake failed due to an unexpected packet format — was telling me it was a TLS handshake, and I had glossed over it as "login failed." Along the migration path, adding Encrypt + TrustServerCertificate is a 5-second job; understanding the cause took half an hour.

Environments tested: SQL Server 2019, SQL Server 2022, Azure SQL Database. The same string worked in all three. On Azure SQL, TrustServerCertificate is actually unnecessary (Microsoft public CA signed), but keeping the connection string unified across environments prevents deploy errors.

Serilog 4.3 + ASP.NET Core 10 Transitive Pitfalls

The logging side requires two package updates for .NET 10:

  • Serilog 4.0.1 → 4.3.0 (4.4 isn't on NuGet yet, as of May 2026)
  • Serilog.AspNetCore 8.0.2 → 10.0.0

The trap: when Serilog.AspNetCore 10 is installed, it transitively demands new minimum versions:

error NU1605: Detected package downgrade:
  Serilog.Sinks.Console from 6.1.1 to 6.0.0
  Serilog.Sinks.File from 7.0.0 to 6.0.0

So if your project uses Sinks.Console 6.0.0, you must explicitly bump it to >= 6.1.1. Same for Sinks.File 7.0.0. Otherwise NU1605 stops the build.

Fix:

<ItemGroup>
  <PackageReference Include="Serilog" Version="4.3.0" />
  <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
  <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
  <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>

Sink compatibility test: I tried my Console, File, MSSQL, Graylog, and MongoDB sinks on 4.3 — all backward compatible. Behavior identical, log format unchanged.

Docker and Kubernetes Production Deploy

The fundamental change for a multi-stage Dockerfile is the base image bump:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:80
ENTRYPOINT ["dotnet", "MyApp.dll"]

Image size: aspnet:10.0 is around 213 MB (3% smaller than 8.0). This marginal saving adds up if you deploy heavily, on the Jenkins agent disk + registry bandwidth side.

Liveness/Readiness probe: The /health/live endpoint runs at the very top of the pipeline, before HostFiltering and other middleware. It returns 200 even during pod cold start; this prevents Kubernetes "Container failed to start" false alarms. My recommendation:

livenessProbe:
  httpGet:
    path: /health/live
    port: 80
  initialDelaySeconds: 10
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 3

Rollout strategy: RollingUpdate, maxSurge: 1, maxUnavailable: 0 → zero downtime. .NET 10 startup time on my dev machine is ~2-3 seconds: once the new pod is ready for liveness, the old pod shuts down.

global.json + preview SDK compatibility: My dev machine has the 10.0.300 preview SDK, prod Docker pulls GA. With "rollForward": "latestFeature" + "allowPrerelease": true in global.json, there's no SDK mismatch during pod build. The CVE-2026-40372 patch shipped in 10.0.7; runtime GA picks it up automatically.

Probe separation: ASP.NET Core has three probe types, and it's important not to confuse them.

  • livenessProbe (/health/live): "Is the pod still alive?" Only checks whether the process has deadlocked. Usually a fixed 200 response.
  • readinessProbe (/health/ready): "Is it ready to take traffic?" Includes database, cache, and external API connections; if it fails, the pod is automatically removed from the service.
  • startupProbe (/health/startup): For long cold starts (e.g. EFCore migration checks, cache warm-up), runs before liveness and allows a longer timeout.

Our setup has only livenessProbe + /health/live because startup is fast (~2-3 s). EFCore migrations run in a deploy job rather than at runtime (init container) — this pattern didn't change in .NET 10 either.

Multi-arch image: ARM64 support is becoming common in prod (especially AWS Graviton and the latest-gen Azure Ampere instances). With Buildx:

docker buildx create --use --name multibuilder
docker buildx build   --platform linux/amd64,linux/arm64   --push -t registry.example.com/myapp:10.0 .

mcr.microsoft.com/dotnet/aspnet:10.0 is already multi-arch — no extra configuration needed. ARM64 worker nodes give a 20-30% cost advantage with nearly equal performance. To estimate node count, packing efficiency and projected monthly cost from your pod resource requests, use the Kubernetes resource budget calculator.

Image security scanning: Before pushing to production, a CVE scan with Trivy or Grype is useful:

trivy image registry.example.com/myapp:10.0 --severity HIGH,CRITICAL

In my first scan after the migration, two transitive CVEs showed up: MimeKit/MailKit 4.14 (vendor design issue, acceptable) and System.Security.Cryptography.Xml 10.0.0 (Microsoft's latest patch is already 10.0.0). Both can be considered "false positive."

Before Production: 10 Smoke Tests

The smoke test list I ran in the cluster before the May 7, 2026 deploy. Every item can be automated — a bash script + curl is enough. For the green light, 10/10 gate:

  • 1) Health livenessGET /health/live → 200 (early pipeline, before HostFiltering)
  • 2) Health readinessGET /health/ready → 200 (DB + cache + external APIs ready)
  • 3) Locale switcher TRGET /?lang=tr → 200, verify <html lang="tr">
  • 4) Locale switcher ENGET /?lang=en → 200, verify <html lang="en">
  • 5) Locale switcher ARGET /?lang=ar → 200, verify <html lang="ar" dir="rtl">
  • 6) Rate limiter trigger — 15 consecutive POST /api/mailgonder → 12×{200,202} + 3×429 + Retry-After: 60 header on the last response
  • 7) Attack probe /.envGET /.env → 404 (dotfile block middleware)
  • 8) Attack probe /.git/configGET /.git/config → 404
  • 9) Attack probe /web.configGET /web.config → 404 (classic IIS path; doesn't exist in ASP.NET Core)
  • 10) Prometheus scrapeGET /metrics → 200, at least process_runtime_dotnet_gc_heap_size_bytes and http_server_request_duration_seconds_bucket metrics visible

Smoke test bash one-liner:

BASE="https://yourapp.com"
echo "1) /health/live"; curl -sI $BASE/health/live | head -1
echo "2) /health/ready"; curl -sI $BASE/health/ready | head -1
for lang in tr en ar; do
  echo "3-5) /?lang=$lang"; curl -sI "$BASE/?lang=$lang" | head -1
done
echo "6) rate-limit 15x"; for i in {1..15}; do curl -s -o /dev/null -w "%{http_code} " "$BASE/api/mailgonder"; done; echo
for path in .env .git/config web.config; do
  echo "7-9) /$path"; curl -sI "$BASE/$path" | head -1
done
echo "10) /metrics"; curl -s $BASE/metrics | head -5

Result: 10/10 green, within 8 minutes of build. Putting this list under version control as a single smoke-test.sh reduces the 5-minute manual post-deploy verification load to 30 seconds.

Observability: Prometheus + OpenTelemetry

.NET 10 offers a cleaner surface on the diagnostic API side — especially with System.Diagnostics.Metrics and the OpenTelemetry.Exporter.Prometheus.AspNetCore package.

Standard setup:

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddMeter("MyApp.Custom")
        .AddPrometheusExporter())
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter());

app.MapPrometheusScrapingEndpoint(); // /metrics

Custom counter example:

private static readonly Meter Meter = new("MyApp.Custom");
private static readonly Counter<long> AttackProbes =
    Meter.CreateCounter<long>("bilal_attack_probe_blocks_total");

AttackProbes.Add(1, new KeyValuePair<string, object?>("category", "encoded_or_dotfile"));

Prometheus scrape config pulls this metric from the /metrics endpoint; it shows up as time-series in the Grafana dashboard. This is the pattern I use for security telemetry.

Sink alignment: Serilog's structured logs, OpenTelemetry trace IDs, Prometheus counters + the Grafana dashboard can all be read from a single window. The trace ID flows automatically with log enrichment:

Log.Logger = new LoggerConfiguration()
    .Enrich.WithProperty("Application", "MyApp")
    .Enrich.With<TraceIdEnricher>()
    .WriteTo.Console(outputTemplate:
        "[{Timestamp:HH:mm:ss} {Level:u3}] [{TraceId}] {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

On the Grafana side, clicking on a log line + TraceId opens the distributed trace in Tempo / Jaeger — especially in multi-microservice or OAuth callback flows with external API dependencies, this saves hours.

7 Takeaways from This Migration

After a day of intense work, 7 items remain in my notebook — things anyone doing the next .NET 10 migration should know up front:

  1. Learn to read the TLS error message. The SqlClient 5.x default change throws a TLS handshake error that looks like "Login failed." Don't read just the first word of the error line, read the whole thing: if you see SSL Provider, error: 0, it's a certificate problem, not a credential one. The triple Encrypt=True;TrustServerCertificate=True;Connect Timeout=60 is the standard.
  2. An opportunity to drop the third-party rate limiter. AspNetCoreRateLimit still compiles, but there's no vendor patch commitment. Native AddRateLimiter + SlidingWindow + URL-pattern routing is half a day's work and removes 124 lines of JSON config. Debt rarely closes this cheaply.
  3. AutoMapper 16 = not a ctor breaking, but a test setup breaking. Production DI services.AddAutoMapper(...) already injects ILoggerFactory. The CS7036 error is 95% in test base classes — a one-line fix with NullLoggerFactory.Instance.
  4. Serilog 4.3's NU1605 trap is transitive. When you install Serilog.AspNetCore 10, you must explicitly bump Sinks.Console >= 6.1.1 and Sinks.File >= 7.0.0, or the downgrade error halts the build.
  5. Kubernetes startup is fast; probe separation matters. .NET 10 cold start ~2-3 seconds — livenessProbe + /health/live is enough, no need for startupProbe. Keep EFCore migrations in a deploy job as an init container, not at runtime.
  6. Make the self-hosted feed major-bump chain readable. When you push 9 packages from 1.x → 2.0.0, list the transitive changes for each. The same version can't be overwritten (HTTP 409); you have to bump the version again after every error.
  7. Smoke test 10 endpoints = 30 seconds of confidence. Instead of manually "let me open this URL and check" after deploy, a version-controlled bash script auto-tests 10 endpoints. It changes rollback decisions from hours to seconds.

Frequently Asked Questions

Should I move to .NET 10 right now, isn't .NET 8 LTS still supported through the end of 2026?
.NET 8 support ends on November 10, 2026 (.NET 9 EOL on the same date). Teams that start now buy 5-7 months of maneuvering room; those who wait until September-October will be juggling patch + migration in the same EOL weeks.

Can I keep using AspNetCoreRateLimit?
Yes, the package still compiles on .NET 10. But there's no vendor patch commitment. Moving to native AddRateLimiter is half a day in the short term and reduces CVE risk in the long term.

Is upgrading to AutoMapper 16 required?
No. AutoMapper 13/14/15 are still compatible with .NET 10. But if you depend on packages like IdentityModel.Tokens 8 or MimeKit 4.14, it may come along transitively. Watch out for the MapperConfiguration ctor change in test setup.

I'm getting a SqlClient connection error, what should I do?
Add the triple Encrypt=True;TrustServerCertificate=True;Connect Timeout=60 to your connection string. Mandatory on SQL Server with self-signed certs or forced encryption.

Which Serilog version should I use?
As of May 2026, the most current stable is Serilog 4.3.0 + Serilog.AspNetCore 10.0.0. Explicit bumps to Sinks.Console >= 6.1.1 and Sinks.File >= 7.0.0 are required (NU1605 error).

Which smoke tests should I run before deploying to production?
Minimum: /health/live 200, language switcher (?lang=tr|en|ar) 200, rate limiter 429 test (13 POST requests via curl), attack probe paths (/.env, /.git, /web.config) returning 404. An automatable 10-endpoint test is enough.

I'm using the .NET 10 SDK preview, will it cause issues in the prod image?
If you set "rollForward": "latestFeature" and "allowPrerelease": true in global.json, your dev machine uses preview while prod Docker pulls GA — no mismatch.

What's a typical migration duration?
For a single developer on a mid-sized app (5-7 csprojs, ~50 packages): 4-8 hours of focused migration + 1-2 hours of smoke testing. With a test team: 1-2 days. In large monorepos, it can stretch to weeks.

What should my rollback plan be?
Kubernetes kubectl rollout undo deployment/my-app. Keep at least 3 versions of Docker image tags. If you have DB migrations, make them idempotent (down migration); .NET 10 EFCore 10 will connect, but the old version returns with different connection string parameters.

Sources and References

Official documentation:

Community writing:

Migration context (the source data for this article):

  • bilalkose.com.tr web application — 6 csprojs, .NET 8 → 10 LTS migration, May 7, 2026
  • 9 custom packages — major bump (1.x → 2.0.0) on nuget.bilalkose.com.tr own NuGet feed
  • Build result: 0 errors, 18 warnings, ~25 sec build, 10/10 smoke test endpoints

Comments (0)

Leave a comment and rating

No comments yet. Be the first to comment.