ASP.NET Core architecture comes down to three cooperating layers: a host that owns configuration, logging and dependency injection, a server such as Kestrel that speaks HTTP, and a pipeline of middleware and endpoints that turns each request into a response. Knowing where each layer starts and stops lets you debug proxy setups, choose a hosting model and reason about middleware order. This guide traces one request end to end and covers WebApplication, Kestrel, HTTP/3, IIS, routing and configuration from .NET 8 through the upcoming .NET 11.
What Is ASP.NET Core Architecture?#
ASP.NET Core is a modular, cross-platform framework for HTTP APIs, server-rendered sites and interactive web UIs. Unlike classic ASP.NET, it doesn't depend on IIS or System.Web. The web server runs inside your process, and every feature, from static files to authentication, is a component that you opt into in code.
| Layer | Main types | Responsibility |
|---|---|---|
| Host | WebApplicationBuilder, WebApplication | Configuration, logging, dependency injection, startup and shutdown |
| Server | Kestrel, IIS HTTP Server, HTTP.sys | Sockets, TLS, HTTP parsing, connection limits |
| Request abstraction | HttpContext, request features | A server-agnostic view of the request and response |
| Middleware | RequestDelegate, IApplicationBuilder | Ordered cross-cutting concerns such as errors, HTTPS and auth |
| Routing | UseRouting, route templates | Matching the URL and HTTP method to one endpoint |
| Endpoints | Minimal APIs, controllers, Razor Pages, Blazor, hubs, gRPC | Your application logic |
The seam between the layers is the request feature model. A server exposes each request as feature interfaces such as IHttpRequestFeature and ITlsConnectionFeature, and ASP.NET Core wraps them in an HttpContext. Because your code only sees HttpContext, the same app runs on Kestrel in a Linux container, inside the IIS worker process, or on the in-memory TestServer that integration tests use. Everything above the server is ordinary .NET, built on the same dependency injection container and Generic Host that worker services use.
How ASP.NET Core Works: From Socket to Response#
Follow a single GET /orders/42 request through the system:
- Accept. Kestrel accepts a TCP connection, or a QUIC connection for HTTP/3. For HTTPS, the TLS handshake runs first and Application-Layer Protocol Negotiation (ALPN) picks HTTP/1.1 or HTTP/2.
- Parse and protect. Kestrel parses the headers and enforces its limits. The defaults allow 30 seconds to receive headers, bodies up to 30,000,000 bytes (about 28.6 MB), 130 seconds of keep-alive idle time, and at least 240 bytes per second after a 5-second grace period.
- Create the context. The server builds an
HttpContext. The hosting layer creates a dependency injection scope (HttpContext.RequestServices) and starts anActivityand request metrics for observability. - Run early middleware. Exception handling, HSTS, HTTPS redirection and static files run in registration order, and any of them can short-circuit.
- Route. The routing middleware matches the path and HTTP method against all endpoints and stores the winner and its route values (
id = 42) on the context. - Apply policies. Authentication, authorization, CORS, rate limiting and output caching read the selected endpoint's metadata, such as
[Authorize], and apply the matching rules. - Execute. The endpoint binds parameters, runs filters, calls your handler and serializes the result, usually with
System.Text.Json. - Unwind. The response flows back through the middleware in reverse order. Once the body is flushed,
HttpResponse.HasStartedistrueand headers are frozen. Kestrel keeps the connection alive and disposes the request scope.
This Program.cs makes steps 4 to 8 visible:
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Outermost middleware: first on the way in, last on the way out.
app.Use(async (context, next) =>
{
var start = Stopwatch.GetTimestamp();
app.Logger.LogInformation("Before routing, endpoint: {Endpoint}",
context.GetEndpoint()?.DisplayName ?? "(none)");
await next(context);
app.Logger.LogInformation("{Method} {Path} -> {Status} in {Elapsed:F1} ms",
context.Request.Method, context.Request.Path, context.Response.StatusCode,
Stopwatch.GetElapsedTime(start).TotalMilliseconds);
});
app.UseRouting();
// Runs after matching, so the endpoint and its metadata are available.
app.Use(async (context, next) =>
{
app.Logger.LogInformation("After routing, endpoint: {Endpoint}",
context.GetEndpoint()?.DisplayName ?? "(no match)");
await next(context);
});
app.MapGet("/orders/{id:int}", (int id) =>
TypedResults.Ok(new { Id = id, Status = "Shipped" }));
app.Run();Request /orders/42 and read the log. The first middleware reports (none) because nothing has been matched yet, and the second reports the selected endpoint. The timing line prints last, because the outer middleware resumes only after everything downstream has finished. That nesting is the whole ASP.NET Core middleware pipeline in miniature.
Getting Started: Your First ASP.NET Core App#
Create an empty project with the web template and run it:
dotnet new web -n Hello.Pipeline
cd Hello.Pipeline
dotnet runThe template produces a four-line Program.cs:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();CreateBuilder creates a host builder with sensible defaults. Build seals the service collection and returns a WebApplication, which is the host, the middleware builder and the route builder at once. MapGet registers an endpoint, and Run starts the server and blocks until the process receives Ctrl+C or SIGTERM. Locally, Properties/launchSettings.json assigns a random HTTP port between 5000 and 5300 and an HTTPS port between 7000 and 7300. With no endpoint configuration at all, Kestrel binds to http://localhost:5000.
WebApplicationBuilder and WebApplication in Depth#
WebApplication.CreateBuilder(args) configures four things for you:
- Configuration from
appsettings.json,appsettings.{Environment}.json, user secrets (Development only), environment variables and the command line. - Logging to the console, debug and EventSource providers, plus EventLog on Windows.
- Kestrel with IIS integration, so the same build can also run behind IIS.
- Dependency injection with
ValidateScopesandValidateOnBuildenabled in Development, so a scoped service captured by a singleton fails fast on your machine.
Each concern is a builder property: Configuration, Services, Logging, Metrics and Environment. The Host and WebHost adapters accept extension methods written for the older IHostBuilder and IWebHostBuilder APIs. A production Program.cs follows a fixed shape of services, then pipeline, then endpoints:
var builder = WebApplication.CreateBuilder(args);
// 1. Register services. The container is sealed when Build() runs.
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddOutputCache();
var app = builder.Build();
// 2. Compose the middleware pipeline. Order matters.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler(); // Writes a problem details response
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseOutputCache();
// 3. Map endpoints.
app.MapHealthChecks("/healthz");
app.MapGet("/time", () => TypedResults.Ok(DateTimeOffset.UtcNow))
.CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(10)));
app.Run();Middleware That WebApplication Adds for You#
WebApplication adds UseDeveloperExceptionPage first in Development, UseRouting if you mapped endpoints without calling it, UseAuthentication and UseAuthorization when their services are registered, and UseEndpoints at the end. Your own middleware runs between the automatic UseRouting and UseEndpoints. Middleware that must see the request before matching, such as UsePathBase, therefore needs an explicit app.UseRouting() call after it. Similarly, once you call UseCors, call UseAuthentication and UseAuthorization explicitly to pin the order.
CreateBuilder vs CreateSlimBuilder vs CreateEmptyBuilder#
| Factory method | What you get | Typical use |
|---|---|---|
CreateBuilder | All defaults, including HTTPS and HTTP/3 in Kestrel and IIS integration | Most web apps and APIs |
CreateSlimBuilder | Appsettings, user secrets and console logging; no HTTPS, QUIC, IIS integration or regex route constraints | Native AOT services behind a TLS-terminating proxy |
CreateEmptyBuilder | No defaults; you add the server, configuration and logging | The smallest binaries and specialized hosts |
The webapiaot template uses the slim builder. Restore HTTPS with builder.WebHost.UseKestrelHttpsConfiguration() or HTTP/3 with builder.WebHost.UseQuic(). With the empty builder, start by calling builder.WebHost.UseKestrelCore().
Kestrel: The Default ASP.NET Core Web Server#
Kestrel is the cross-platform, in-process server that every template uses unless the app runs in-process under IIS. It is built on System.IO.Pipelines with pooled buffers and supports HTTP/1.1, HTTP/2, HTTP/3 and WebSockets. Since .NET 10, its memory pool returns idle memory to the operating system and reports usage through the Microsoft.AspNetCore.MemoryPool meter.
Configuring Kestrel Endpoints and Ports#
ASPNETCORE_HTTP_PORTS and ASPNETCORE_HTTPS_PORTS list ports on all interfaces and have the lowest priority. Since .NET 8, the official container images set ASPNETCORE_HTTP_PORTS=8080. ASPNETCORE_URLS and --urls override the port lists, and endpoints in the Kestrel configuration section or in code override both:
{
"Kestrel": {
"Endpoints": {
"Internal": {
"Url": "http://0.0.0.0:8080"
},
"Public": {
"Url": "https://0.0.0.0:8443",
"Protocols": "Http1AndHttp2",
"Certificate": {
"Path": "/certs/api.pfx"
}
}
}
},
"AllowedHosts": "api.contoso.com"
}Load the certificate password from a secret store, for example through the Kestrel__Endpoints__Public__Certificate__Password environment variable. AllowedHosts enables host filtering, which matters because Kestrel ignores host names when it binds. Set server limits in code:
builder.WebHost.ConfigureKestrel(options =>
{
options.AddServerHeader = false; // Don't advertise "Kestrel"
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB instead of ~28.6 MB
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
options.Limits.MaxConcurrentConnections = 10_000; // Unlimited by default
});HTTP/2 and HTTP/3 in Kestrel#
HTTP/2 is on by default, because endpoints default to HttpProtocols.Http1AndHttp2. Browsers use it only over TLS, where ALPN negotiates it. Cleartext HTTP/2 works only for prior knowledge clients, which is how many in-cluster gRPC calls run.
HTTP/3 runs over QUIC. QUIC removes TCP head-of-line blocking, merges the transport and TLS 1.3 handshakes, and keeps connections alive across network changes. HTTP/3 has been fully supported since .NET 7 but is opt-in and requires the MsQuic library. Without MsQuic, Kestrel falls back to HTTP/1.1 and HTTP/2. Clients discover HTTP/3 through the alt-svc header that Kestrel adds, so the first request on a connection uses an older version. In .NET 11, Kestrel starts processing HTTP/3 requests before the SETTINGS frame arrives, which cuts first-request latency.
using Microsoft.AspNetCore.Server.Kestrel.Core;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5001, listenOptions =>
{
// Offer all three versions: not every network path allows UDP.
listenOptions.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
listenOptions.UseHttps(); // HTTP/3 always requires TLS
});
});
var app = builder.Build();
app.MapGet("/", (HttpContext context) => $"Served over {context.Request.Protocol}");
app.Run();HttpProtocols value | Allowed versions | Notes |
|---|---|---|
Http1 | HTTP/1.1 | With or without TLS |
Http2 | HTTP/2 | Without TLS only for prior-knowledge clients |
Http1AndHttp2 | HTTP/1.1, HTTP/2 | The default |
Http1AndHttp2AndHttp3 | All three | The recommended way to enable HTTP/3 |
ASP.NET Core Hosting Models: Edge, Reverse Proxy or IIS#
Kestrel is supported both at the edge and behind a reverse proxy such as Nginx, IIS, YARP, a cloud load balancer or a Kubernetes ingress. At the edge, one Kestrel process owns its IP address and port. A proxy helps when several apps share port 443, or when you want centralized TLS, load balancing and defense in depth.
Forwarded Headers Behind a Proxy#
When a proxy terminates TLS, the app sees plain HTTP from the proxy's address. That breaks HTTPS redirection, link generation and IP-based rate limits. The forwarded headers middleware restores the scheme, client IP, host and path base from the X-Forwarded-* headers, but only for proxies you trust:
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// .NET 10: KnownIPNetworks (System.Net.IPNetwork) replaces the obsolete KnownNetworks.
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.20.0.0/16"));
});
var app = builder.Build();
app.UseForwardedHeaders(); // Before HSTS, HTTPS redirection and authentication
app.UseHsts();
app.UseHttpsRedirection();Since the 8.0.17 and 9.0.6 servicing updates, the middleware ignores headers from proxies that aren't in KnownProxies or the known networks, which typically shows up as an HTTPS redirect loop. In .NET 10, KnownNetworks is obsolete (warning ASPDEPR005) in favor of KnownIPNetworks. Setting ASPNETCORE_FORWARDEDHEADERS_ENABLED=true also enables the middleware, but it clears the trust lists, so reserve it for apps that are reachable only through the proxy.
Hosting on IIS: In-Process vs Out-of-Process#
The ASP.NET Core Module (ANCM), installed with the .NET Hosting Bundle, connects IIS to your app. The default in-process model runs the app inside w3wp.exe on IIS HTTP Server (IISHttpServer) instead of Kestrel. Skipping the loopback hop delivers much higher throughput, but each app needs its own application pool with matching bitness. The out-of-process model runs Kestrel in a separate process that ANCM starts, proxies to and restarts on failure, trading throughput for isolation:
<PropertyGroup>
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
</PropertyGroup>HTTP.sys is a third, Windows-only server. It offers port sharing, kernel-mode Windows authentication and response caching, and .NET 10 adds RequestQueueSecurityDescriptor to secure its request queue.
Endpoint Routing: How Requests Find Their Handler#
Endpoint routing separates matching from execution. Minimal APIs, MVC, Razor Pages, Blazor, SignalR, gRPC and health checks all add endpoints, each one a RequestDelegate plus metadata, to a single routing table. Matching evaluates every template at once, removes candidates that fail constraints or the HTTP method check, and then picks by order and precedence, so the literal /orders/latest beats /orders/{id}.
- Constraints disambiguate; they don't validate. A failed
{id:int}constraint returns 404, not 400. - Metadata drives policy.
RequireAuthorization(),RequireCors()andRequireRateLimiting()attach metadata that middleware reads after routing. - Short-circuit cheap routes.
ShortCircuit()runs an endpoint right after routing (.NET 8 and later),MapShortCircuitdoes the same for URL prefixes, and .NET 11 adds a[ShortCircuit]attribute.
var app = WebApplication.Create(args);
// Bots probe these paths constantly: answer 404 before other middleware runs.
app.MapShortCircuit(404, "robots.txt", "favicon.ico");
app.MapGet("/healthz", () => "Healthy").ShortCircuit();
var orders = app.MapGroup("/api/orders");
orders.MapGet("/latest", () => TypedResults.Ok("most recent order"));
orders.MapGet("/{id:int}", (int id) => TypedResults.Ok(id));
app.Run();Short-circuited endpoints can't carry authorization or CORS metadata. The combination throws an InvalidOperationException, because the middleware that would enforce those policies never runs.
Environments and Configuration in ASP.NET Core#
ASP.NET Core reads the environment name from DOTNET_ENVIRONMENT and ASPNETCORE_ENVIRONMENT. With WebApplication, the DOTNET_ value wins if both are set, and when neither is set the environment is Production. Any string works as a name, and app.Environment.IsEnvironment("QA") checks custom ones. The environment is fixed for the lifetime of the process.
Configuration is layered. From highest to lowest priority, the defaults are:
- Command-line arguments.
- Environment variables without the
ASPNETCORE_orDOTNET_prefix. - User secrets, in the Development environment only.
appsettings.{Environment}.json.appsettings.json.
Hierarchical keys use a colon in JSON paths and a double underscore in environment variables, so Catalog:PageSize becomes Catalog__PageSize:
export ASPNETCORE_ENVIRONMENT=Staging
export ASPNETCORE_HTTP_PORTS=8080
export Catalog__PageSize=100
dotnet Catalog.Api.dllBind settings to strongly typed options and validate them at startup, so a bad deployment fails immediately rather than on the first request that needs the setting:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<CatalogOptions>()
.BindConfiguration(CatalogOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
var app = builder.Build();
app.MapGet("/catalog/settings", (IOptions<CatalogOptions> options) =>
TypedResults.Ok(new { options.Value.PageSize }));
app.Run();
public sealed class CatalogOptions
{
public const string SectionName = "Catalog";
[Range(1, 500)]
public int PageSize { get; set; } = 50;
[Required, Url]
public string ImageBaseUrl { get; set; } = string.Empty;
}Keep secrets out of appsettings.json. Use user secrets on developer machines and a managed store such as Azure Key Vault in production.
ASP.NET Core Project Templates#
Every template produces the same WebApplication host and differs only in the services and endpoints it registers:
| Short name | What you get | Choose it for |
|---|---|---|
web | An empty app with one MapGet | Learning and small services |
webapi | Minimal APIs with OpenAPI; --use-controllers switches to controllers | JSON APIs |
webapiaot | Minimal APIs, CreateSlimBuilder, JSON source generation, PublishAot | Fast startup, small images |
mvc / webapp | Controllers with views, or Razor Pages | Server-rendered apps |
blazor | Blazor Web App; --interactivity takes None, Server, WebAssembly or Auto | Interactive UI in C# |
grpc | A gRPC service with a sample .proto file | Service-to-service RPC |
worker | A Generic Host background service | Queue consumers and scheduled jobs |
.NET 10 added a --localhost-tld option to the web and blazor templates, so apps can run at addresses such as https://myapp.dev.localhost:7099. The .NET 11 previews add a webworker template and bundle the mcpserver template for Model Context Protocol servers with the SDK.
Best Practices#
- Terminate TLS at an edge you control, and enable HTTP/2 and HTTP/3 there.
- Configure forwarded headers explicitly with
KnownProxiesorKnownIPNetworksinstead of trusting every sender. - Keep
Program.csa composition root. Move registrations into extension methods such asAddCatalogModule(). - Validate options on start, so configuration mistakes fail the deployment rather than a customer request.
- Size Kestrel limits to your API. Lower
MaxRequestBodySizeglobally and raise it only on upload endpoints. - Stay asynchronous. Leave
AllowSynchronousIOoff, because blocking I/O starves the thread pool under load. - Align shutdown timeouts.
HostOptions.ShutdownTimeoutdefaults to 30 seconds; keep it below your orchestrator's grace period.
Common Pitfalls#
- Redirect loops behind a proxy.
UseHttpsRedirectionsees HTTP from a TLS-terminating proxy. Configure forwarded headers instead of removing the redirect. - Binding to
localhostin a container. Loopback is unreachable from outside the container. UseASPNETCORE_HTTP_PORTSorhttp://+:8080. - Case-sensitive file names on Linux. An environment named
staginglooks forappsettings.staging.json, which won't matchappsettings.Staging.json. - Changing headers after the response starts. Check
HttpResponse.HasStarted, or add late headers withResponse.OnStarting. - Middleware in the wrong order.
UseCorsafterUseAuthorization, orUsePathBaseafter routing, silently changes behavior. - Expecting HTTP/3 everywhere. Blocked UDP, a missing MsQuic library or an untrusted certificate all cause a silent fallback to HTTP/2.
Kestrel vs IIS vs HTTP.sys: Choosing a Hosting Option#
| Option | Strengths | Trade-offs | Best for |
|---|---|---|---|
| Kestrel at the edge | Fastest path, full protocol support | One process per IP and port; you own TLS | Containers behind a cloud load balancer |
| Kestrel behind a proxy | TLS offload, host routing, load balancing | Extra hop; forwarded headers required | Most production deployments |
| IIS in-process | Highest IIS throughput, Windows authentication | One app per pool; Windows only | Windows Server shops standardized on IIS |
| IIS out-of-process | Process isolation, automatic restarts | Loopback hop costs throughput | IIS hosts that need isolation |
| HTTP.sys | Port sharing, kernel-mode Windows authentication | Windows only; Kestrel is faster and more extensible | Intranet services without IIS |
Version choice matters as much as hosting. .NET 10 is the current LTS release, supported until November 2028, while .NET 8 and .NET 9 both reach end of support on November 10, 2026. .NET 11 reached Release Candidate 1 with a go-live license on September 8, 2026 and is due in November 2026. Its ASP.NET Core changes include built-in OpenTelemetry attributes on the HTTP server activity and Zstandard response compression.
Frequently Asked Questions#
Do I still need IIS or Nginx in front of Kestrel?#
No. Kestrel is supported as an internet-facing edge server. A reverse proxy is still worthwhile when several apps share port 443, when you want centralized TLS and load balancing, or when policy requires an extra security layer. Most cloud platforms provide that proxy already.
What is the difference between UseRouting and UseEndpoints?#
UseRouting selects the endpoint and route values and stores them on the HttpContext. UseEndpoints executes the selected endpoint. Middleware between the two can read the endpoint's metadata, which is how authorization, CORS and rate limiting apply per-endpoint policies.
Why does my app redirect forever behind a load balancer?#
The load balancer terminates TLS and forwards plain HTTP, so UseHttpsRedirection keeps redirecting. Enable the forwarded headers middleware for X-Forwarded-Proto, register it early, and add the proxy's addresses to KnownProxies or KnownIPNetworks.
When should I use CreateSlimBuilder instead of CreateBuilder?#
Use CreateSlimBuilder for Native AOT or heavily trimmed services, usually containers that receive plain HTTP from a TLS-terminating proxy. It starts faster and produces smaller binaries because it omits HTTPS, HTTP/3 and IIS integration. For everything else, CreateBuilder is the safer default.
Summary#
- ASP.NET Core stacks a host, a server and a pipeline of middleware and endpoints, connected through
HttpContext. WebApplicationwires up configuration, logging, dependency injection and Kestrel, and adds routing and auth middleware automatically.- Kestrel speaks HTTP/1.1 and HTTP/2 by default. HTTP/3 is opt-in and depends on MsQuic.
- Behind a proxy, configure forwarded headers with explicit trust. On IIS, prefer in-process hosting unless you need isolation.
- Routing matches early and executes late, so policy middleware can act on endpoint metadata in between.