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.

LayerMain typesResponsibility
HostWebApplicationBuilder, WebApplicationConfiguration, logging, dependency injection, startup and shutdown
ServerKestrel, IIS HTTP Server, HTTP.sysSockets, TLS, HTTP parsing, connection limits
Request abstractionHttpContext, request featuresA server-agnostic view of the request and response
MiddlewareRequestDelegate, IApplicationBuilderOrdered cross-cutting concerns such as errors, HTTPS and auth
RoutingUseRouting, route templatesMatching the URL and HTTP method to one endpoint
EndpointsMinimal APIs, controllers, Razor Pages, Blazor, hubs, gRPCYour 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:

  1. 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.
  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.
  3. Create the context. The server builds an HttpContext. The hosting layer creates a dependency injection scope (HttpContext.RequestServices) and starts an Activity and request metrics for observability.
  4. Run early middleware. Exception handling, HSTS, HTTPS redirection and static files run in registration order, and any of them can short-circuit.
  5. 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.
  6. Apply policies. Authentication, authorization, CORS, rate limiting and output caching read the selected endpoint's metadata, such as [Authorize], and apply the matching rules.
  7. Execute. The endpoint binds parameters, runs filters, calls your handler and serializes the result, usually with System.Text.Json.
  8. Unwind. The response flows back through the middleware in reverse order. Once the body is flushed, HttpResponse.HasStarted is true and headers are frozen. Kestrel keeps the connection alive and disposes the request scope.

This Program.cs makes steps 4 to 8 visible:

C#
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:

Bash
dotnet new web -n Hello.Pipeline
cd Hello.Pipeline
dotnet run

The template produces a four-line Program.cs:

C#
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 ValidateScopes and ValidateOnBuild enabled 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:

C#
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 methodWhat you getTypical use
CreateBuilderAll defaults, including HTTPS and HTTP/3 in Kestrel and IIS integrationMost web apps and APIs
CreateSlimBuilderAppsettings, user secrets and console logging; no HTTPS, QUIC, IIS integration or regex route constraintsNative AOT services behind a TLS-terminating proxy
CreateEmptyBuilderNo defaults; you add the server, configuration and loggingThe 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:

JSON
{
  "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:

C#
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.

C#
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 valueAllowed versionsNotes
Http1HTTP/1.1With or without TLS
Http2HTTP/2Without TLS only for prior-knowledge clients
Http1AndHttp2HTTP/1.1, HTTP/2The default
Http1AndHttp2AndHttp3All threeThe 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:

C#
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:

XML
<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() and RequireRateLimiting() attach metadata that middleware reads after routing.
  • Short-circuit cheap routes. ShortCircuit() runs an endpoint right after routing (.NET 8 and later), MapShortCircuit does the same for URL prefixes, and .NET 11 adds a [ShortCircuit] attribute.
C#
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:

  1. Command-line arguments.
  2. Environment variables without the ASPNETCORE_ or DOTNET_ prefix.
  3. User secrets, in the Development environment only.
  4. appsettings.{Environment}.json.
  5. appsettings.json.

Hierarchical keys use a colon in JSON paths and a double underscore in environment variables, so Catalog:PageSize becomes Catalog__PageSize:

Bash
export ASPNETCORE_ENVIRONMENT=Staging
export ASPNETCORE_HTTP_PORTS=8080
export Catalog__PageSize=100
dotnet Catalog.Api.dll

Bind 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:

C#
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 nameWhat you getChoose it for
webAn empty app with one MapGetLearning and small services
webapiMinimal APIs with OpenAPI; --use-controllers switches to controllersJSON APIs
webapiaotMinimal APIs, CreateSlimBuilder, JSON source generation, PublishAotFast startup, small images
mvc / webappControllers with views, or Razor PagesServer-rendered apps
blazorBlazor Web App; --interactivity takes None, Server, WebAssembly or AutoInteractive UI in C#
grpcA gRPC service with a sample .proto fileService-to-service RPC
workerA Generic Host background serviceQueue 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 KnownProxies or KnownIPNetworks instead of trusting every sender.
  • Keep Program.cs a composition root. Move registrations into extension methods such as AddCatalogModule().
  • Validate options on start, so configuration mistakes fail the deployment rather than a customer request.
  • Size Kestrel limits to your API. Lower MaxRequestBodySize globally and raise it only on upload endpoints.
  • Stay asynchronous. Leave AllowSynchronousIO off, because blocking I/O starves the thread pool under load.
  • Align shutdown timeouts. HostOptions.ShutdownTimeout defaults to 30 seconds; keep it below your orchestrator's grace period.

Common Pitfalls#

  • Redirect loops behind a proxy. UseHttpsRedirection sees HTTP from a TLS-terminating proxy. Configure forwarded headers instead of removing the redirect.
  • Binding to localhost in a container. Loopback is unreachable from outside the container. Use ASPNETCORE_HTTP_PORTS or http://+:8080.
  • Case-sensitive file names on Linux. An environment named staging looks for appsettings.staging.json, which won't match appsettings.Staging.json.
  • Changing headers after the response starts. Check HttpResponse.HasStarted, or add late headers with Response.OnStarting.
  • Middleware in the wrong order. UseCors after UseAuthorization, or UsePathBase after 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#

OptionStrengthsTrade-offsBest for
Kestrel at the edgeFastest path, full protocol supportOne process per IP and port; you own TLSContainers behind a cloud load balancer
Kestrel behind a proxyTLS offload, host routing, load balancingExtra hop; forwarded headers requiredMost production deployments
IIS in-processHighest IIS throughput, Windows authenticationOne app per pool; Windows onlyWindows Server shops standardized on IIS
IIS out-of-processProcess isolation, automatic restartsLoopback hop costs throughputIIS hosts that need isolation
HTTP.sysPort sharing, kernel-mode Windows authenticationWindows only; Kestrel is faster and more extensibleIntranet 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.
  • WebApplication wires 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.

Further Reading#