Mert Özen Further With Every Line
DevOps Web Servers and Hosting — #02

Getting to Know Kestrel: Endpoints, ASPNETCORE_HTTP_PORTS, Limits and HTTP/2–HTTP/3

Mert Özen Nov 8, 2026 14 min 5 views
Getting to Know Kestrel: Endpoints, ASPNETCORE_HTTP_PORTS, Limits and HTTP/2–HTTP/3

Kestrel's listening address is decided by Listen in code, Kestrel:Endpoints, --urls/ASPNETCORE_URLS and ASPNETCORE_HTTP_PORTS, in that order, and every overridden setting shows up as a warning in the log. With nine runs we measure the precedence and look at PEM certificates and SNI, certificate renewal without a restart, default limits, HTTP/3 with libmsquic, connecting to Nginx over a Unix socket and the localhost trap in containers.

Version note: The examples in this post were run with .NET 10 (ASP.NET Core runtime 10.0.14, the mcr.microsoft.com/dotnet/aspnet:10.0 image), libmsquic 2.6.3, Nginx 1.30.6 and curl 8.23 on Docker 29.

Guess First

You're running Bookstore.Api in a container. The image itself comes with ASPNETCORE_HTTP_PORTS=8080. You add -e ASPNETCORE_URLS=http://+:5000 to docker run, put --urls http://+:6000 at the end of the command and also pass Kestrel__Endpoints__Api__Url=http://+:7000 as an environment variable.

Which port does the app listen on: 8080, 5000, 6000, 7000, or all of them? Do you see a warning in the log? And if you start the same app with ASPNETCORE_URLS=http://localhost:8080, can another container on the same Docker network reach it?

Five Places Kestrel Learns Its Port

There are five ways to tell Kestrel "listen on this address", and all of them can be present in the same app at once:

SourceExampleNote
Listen* in codekestrel.ListenAnyIP(9000)Inside ConfigureKestrel; the strongest
Kestrel:Endpoints in configurationKestrel__Endpoints__Api__Url=http://+:7000Named endpoint; certificate, protocol and SNI (Server Name Indication) settings live here
The urls key--urls, ASPNETCORE_URLS, UseUrlsSemicolon-separated list of URLs (Uniform Resource Locators)
HTTP_PORTS and HTTPS_PORTSASPNETCORE_HTTP_PORTS=8080Arrived with .NET 8; ports only, the weakest
None-http://localhost:5000

Instead of reading the precedence from the docs, let's measure it. I wrote a small script that starts the same publish output with a different setting each time and keeps only the binding-related log lines:

#!/bin/bash
# run the app once with the given env/args and print the binding lines
W="$(cd "$(dirname "$0")" && pwd)"
name=$1; shift
echo "### $name"
docker rm -f webhwork-prec >/dev/null 2>&1
docker run -d --name webhwork-prec --network webhwork-net -v "$W/out/app":/app:ro -w /app "$@" >/dev/null
for i in $(seq 1 20); do docker logs webhwork-prec 2>&1 | grep -q -E "Application started|Unhandled|fail" && break; sleep 0.5; done
docker logs webhwork-prec 2>&1 | grep -v -E "DataProtection|unencrypted|aka.ms|Hosting environment|Content root|Press Ctrl" | grep -v '^\s*at ' | head -12
docker rm -f webhwork-prec >/dev/null 2>&1

With nothing at all, only the image's own environment variable, and then with that variable emptied as well:

$ ./prec.sh "1 image default" mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 1 image default
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:8080

$ ./prec.sh "2 nothing" -e ASPNETCORE_HTTP_PORTS= mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 2 nothing
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000

The 8080 in the container is chosen by the image, not by your code. The ASPNETCORE_HTTP_PORTS=8080 variable that Microsoft has set in every ASP.NET Core image since .NET 8 does that. Empty the variable and Kestrel falls back to its own default, localhost:5000. We'll get to what listening on localhost means inside a container in a moment.

Now let's stack the sources:

$ ./prec.sh "3 URLS + HTTP_PORTS" -e ASPNETCORE_URLS=http://+:5000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 3 URLS + HTTP_PORTS
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
      Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://+:5000'.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:5000

$ ./prec.sh "4 --urls" -e ASPNETCORE_URLS=http://+:5000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll --urls http://+:6000
### 4 --urls
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
      Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://+:6000'.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:6000

$ ./prec.sh "5 Kestrel:Endpoints" -e ASPNETCORE_URLS=http://+:5000 -e Kestrel__Endpoints__Api__Url=http://+:7000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll --urls http://+:6000
### 5 Kestrel:Endpoints
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
      Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://+:6000'.
warn: Microsoft.AspNetCore.Server.Kestrel[0]
      Overriding address(es) 'http://+:6000'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:7000

The answer to the guess is in the fifth run: only 7000. Precedence works in two steps. First the urls key overrides HTTP_PORTS; the choice between --urls and ASPNETCORE_URLS is made by the provider order you saw in the "Configuration and the Options Pattern" post of the ASP.NET Core Web API (Application Programming Interface) series: the command line wins because it's added after environment variables. Then Kestrel's own endpoint definitions (Kestrel:Endpoints in configuration and Listen* in code) step in and drop everything that came from urls. Both overrides are written to the log at warn level; if you see these two lines in a production log, someone's setting is going to waste.

Listening in code also counts as Kestrel's own definition and works together with the endpoints in configuration. Bookstore.Api has a small switch for it:

if (int.TryParse(builder.Configuration["Demo:ListenPort"], out int listenPort))
{
    builder.WebHost.ConfigureKestrel(kestrel => kestrel.ListenAnyIP(listenPort));
}
$ ./prec.sh "6 Listen in code" -e Kestrel__Endpoints__Api__Url=http://+:7000 -e Demo__ListenPort=9000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 6 Listen in code
warn: Microsoft.AspNetCore.Server.Kestrel[0]
      Overriding address(es) 'http://*:8080'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:9000
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:7000

$ ./prec.sh "7 same port twice" -e Kestrel__Endpoints__Api__Url=http://+:9000 -e Demo__ListenPort=9000 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 7 same port twice
warn: Microsoft.AspNetCore.Server.Kestrel[0]
      Overriding address(es) 'http://*:8080'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.IO.IOException: Failed to bind to address http://[::]:9000: address already in use.
       ---> Microsoft.AspNetCore.Connections.AddressInUseException: Address already in use

In the sixth run you can also see HTTP_PORTS=8080 reaching Kestrel as http://*:8080: the port list is really shorthand for urls. The seventh run is the case where two sources want the same port. There's no overriding here; both try to bind and the app never starts. Because the error says "address already in use", people first go looking for another process, when the one holding the port is another endpoint of the same process.

0.0.0.0, [::], localhost and +

For Kestrel the host part of the URL answers "which network interface should I listen on", not a domain match. 0.0.0.0 listens on all IPv4 addresses, [::] on all of them via IPv6 and a dual-mode socket. localhost listens only on loopback (127.0.0.1 and [::1]). Anything unrecognised, including + and *, counts as a wildcard; Kestrel shows this in the log as http://[::]:5000. Writing http://api.bookstore.example:80 doesn't restrict Kestrel to that domain; if you want to separate by host, that's the job of host filtering or the proxy in front.

localhost is a special trap in a container, because the container's loopback belongs to that container alone:

$ docker run -d --name webhwork-api1 --network webhwork-net --ip 172.30.85.21 \
    -e ASPNETCORE_URLS=http://localhost:8080 ... mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
$ docker logs webhwork-api1 2>&1 | grep -A1 listening
      Now listening on: http://localhost:8080

$ curl -sS http://172.30.85.21:8080/api/books/1
curl: (7) Failed to connect to 172.30.85.21:8080 after 0 ms: Could not connect to server

The log says all is well, the container is up, and if your health check runs inside the container, it passes too. But nothing on the network can reach the app. In a container, http://+:8080 (or not touching anything and using the image's HTTP_PORTS) is the right choice. On a VM (virtual machine) it's the opposite: closing Kestrel behind Nginx onto loopback with http://127.0.0.1:5000 is the cheapest way to stop the proxy being bypassed.

Certificates: PEM, PFX and SNI

Kestrel can terminate HTTPS (HTTP Secure) itself. Providing the certificate through configuration is more flexible than calling UseHttps in code: the file path changes per environment, the code doesn't. For the experiment I generated a 30-day test CA (Certificate Authority) with OpenSSL in the work folder, plus two certificates for api.bookstore.test and admin.bookstore.test; the private keys never leave that folder. appsettings.Production.json:

{
  "Kestrel": {
    "Endpoints": {
      "Http": {
        "Url": "http://+:8080"
      },
      "Https": {
        "Url": "https://+:8443",
        "Protocols": "Http1AndHttp2",
        "Certificate": {
          "Path": "/certs/api.bookstore.test.crt",
          "KeyPath": "/certs/api.bookstore.test.key"
        },
        "Sni": {
          "admin.bookstore.test": {
            "Certificate": {
              "Path": "/certs/admin.bookstore.test.crt",
              "KeyPath": "/certs/admin.bookstore.test.key"
            }
          },
          "*": {
            "Certificate": {
              "Path": "/certs/api.bookstore.test.crt",
              "KeyPath": "/certs/api.bookstore.test.key"
            }
          }
        }
      }
    }
  }
}

The Path and KeyPath pair is for PEM (Privacy-Enhanced Mail) files; with PFX (PKCS #12) you give Path and Password, and on Windows you use Subject and Store to take it from the certificate store. The Sni section picks the certificate by the host name the client states during the TLS (Transport Layer Security) handshake. Two hosts on the same IP (Internet Protocol) address and port:

$ for h in api.bookstore.test admin.bookstore.test; do
    curl -sv --cacert /w/certs/ca.crt --resolve $h:8443:172.30.85.21 https://$h:8443/api/whoami -o /dev/null 2>&1 \
      | grep -E "ALPN|subject:|SSL connection|HTTP/2 200|using HTTP"; done
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
*   subject: CN=api.bookstore.test
* using HTTP/2
< HTTP/2 200
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
*   subject: CN=admin.bookstore.test
* using HTTP/2
< HTTP/2 200

Do you have to restart the app when the certificate is renewed? I tried it: while the container was running I generated a new 60-day certificate for api.bookstore.test, wrote it over the old files and looked at the certificate presented in the handshake before and after:

$ echo | openssl s_client -connect 172.30.85.21:8443 -servername api.bookstore.test 2>/dev/null | openssl x509 -noout -serial -enddate
serial=4F1C62F7875DFE93B63D7ACB853D244745B382FD
notAfter=Dec  8 03:32:35 2026 GMT
$ cp new.key api.bookstore.test.key; cp new.crt api.bookstore.test.crt
$ echo | openssl s_client -connect 172.30.85.21:8443 -servername api.bookstore.test 2>/dev/null | openssl x509 -noout -serial -enddate
serial=4F1C62F7875DFE93B63D7ACB853D244745B38300
notAfter=Jan  7 03:41:31 2027 GMT

The new certificate is served without a restart. Kestrel watches the certificate files given in configuration; at Debug log level you can see the line Created file watcher for '/certs/api.bookstore.test.crt', while at Information level it writes nothing about the reload. It's enough for your renewal script to overwrite the file, but do the "is the new certificate really being served" check from outside as above. Where and how to obtain a certificate, the renewal schedule and chain details are the topic of the upcoming SSL (Secure Sockets Layer)/TLS Certificates and HTTPS series.

The development certificate has no place in production, and in production it isn't there anyway. Turn on HTTPS_PORTS without providing a certificate and the app doesn't start at all:

$ ./prec.sh "8 HTTPS_PORTS no cert" -e ASPNETCORE_HTTPS_PORTS=8443 mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 8 HTTPS_PORTS no cert
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.InvalidOperationException: Unable to configure HTTPS endpoint. No server certificate was specified, and the default developer certificate could not be found or is out of date.

If you want HTTPS through the port list, you put the certificate under Kestrel:Certificates:Default:

$ ./prec.sh "9 HTTPS_PORTS with default cert" -e ASPNETCORE_HTTPS_PORTS=8443 \
    -e Kestrel__Certificates__Default__Path=/certs/api.bookstore.test.crt \
    -e Kestrel__Certificates__Default__KeyPath=/certs/api.bookstore.test.key \
    -v "$PWD/certs":/certs:ro mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
### 9 HTTPS_PORTS with default cert
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://[::]:8443

Watch out for a naming confusion: ASPNETCORE_HTTPS_PORTS (plural) is the ports Kestrel listens on, while ASPNETCORE_HTTPS_PORT (singular) is the port the HTTPS redirection middleware redirects the client to. One doesn't replace the other.

Limits and Timeouts

Instead of writing Kestrel's default limits from memory, I asked the app:

app.MapGet("/api/server/limits", (IOptions<KestrelServerOptions> options) =>
{
    KestrelServerLimits limits = options.Value.Limits;
    return new
    {
        limits.MaxRequestBodySize,
        limits.MaxRequestHeadersTotalSize,
        limits.MaxRequestHeaderCount,
        limits.MaxRequestLineSize,
        limits.MaxConcurrentConnections,
        limits.MaxConcurrentUpgradedConnections,
        limits.KeepAliveTimeout,
        limits.RequestHeadersTimeout,
        MinRequestBodyDataRate = limits.MinRequestBodyDataRate?.BytesPerSecond,
        limits.Http2.MaxStreamsPerConnection,
        options.Value.AddServerHeader,
    };
});
$ curl -s http://172.30.85.21:8080/api/server/limits | jq .
{
  "maxRequestBodySize": 30000000,
  "maxRequestHeadersTotalSize": 32768,
  "maxRequestHeaderCount": 100,
  "maxRequestLineSize": 8192,
  "maxConcurrentConnections": null,
  "maxConcurrentUpgradedConnections": null,
  "keepAliveTimeout": "00:02:10",
  "requestHeadersTimeout": "00:00:30",
  "minRequestBodyDataRate": 240,
  "maxStreamsPerConnection": 100,
  "addServerHeader": true
}

30,000,000 bytes (about 28.6 MB) for the request body, 32 KB for all headers combined, 8 KB for the request line, 30 seconds for all the headers to arrive, 2 minutes 10 seconds for an idle keepalive connection. There's no upper bound on connections. MinRequestBodyDataRate being 240 bytes/second means a client sending the body more slowly than that is cut off (after a short initial grace period); it's part of Kestrel's own protection against slowloris-style attacks.

You can change these values in the Kestrel:Limits section or with ConfigureKestrel. If a single endpoint such as file upload must accept a larger body, give that endpoint its own limit with [RequestSizeLimit] or IHttpMaxRequestBodySizeFeature instead of raising the global limit; details are in the "File Upload and Download" post of the ASP.NET Core Web API series. If Nginx is in front, its client_max_body_size (1 MB by default) kicks in much earlier; aligning the two limits is covered in the fifth post.

HTTP/2 and HTTP/3

The SNI output above contains the line ALPN: server accepted h2: ALPN (Application-Layer Protocol Negotiation) is the extension through which client and server decide which HTTP version to speak during the TLS handshake. Kestrel's default is Http1AndHttp2; on a TLS endpoint HTTP/2 is chosen if the client supports it. On an endpoint without TLS there's no ALPN, so the same setting effectively means HTTP/1.1:

$ curl -s --http1.1 --cacert /w/certs/ca.crt --resolve api.bookstore.test:8443:172.30.85.21 https://api.bookstore.test:8443/api/whoami | jq -c "{protocol,scheme,host}"
{"protocol":"HTTP/1.1","scheme":"https","host":"api.bookstore.test:8443"}
$ curl -s --cacert /w/certs/ca.crt --resolve api.bookstore.test:8443:172.30.85.21 https://api.bookstore.test:8443/api/whoami | jq -c "{protocol,scheme,host}"
{"protocol":"HTTP/2","scheme":"https","host":"api.bookstore.test:8443"}
$ curl -sS --http2-prior-knowledge http://172.30.85.21:8080/api/whoami
curl: (16) Remote peer returned unexpected data while we expected SETTINGS frame.  Perhaps, peer does not support HTTP/2 properly.

If you need HTTP/2 without encryption (h2c), for example to run gRPC without TLS on an internal network, you have to define the endpoint as Http2 separately.

HTTP/3 runs over UDP (User Datagram Protocol) instead of TCP (Transmission Control Protocol), using QUIC (a name that started out as Quick UDP Internet Connections). According to Microsoft Learn, ASP.NET Core's HTTP/3 support depends on MsQuic, and on Linux it needs the libmsquic package, which lives in Microsoft's Linux package repository (packages.microsoft.com). The mcr.microsoft.com/dotnet/aspnet:10.0 image I tried (Ubuntu 24.04) doesn't contain libmsquic. A small image that adds the package:

FROM mcr.microsoft.com/dotnet/aspnet:10.0
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
 && curl -sSL -o /tmp/ms.deb https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb \
 && dpkg -i /tmp/ms.deb && apt-get update && apt-get install -y --no-install-recommends libmsquic \
 && rm -rf /var/lib/apt/lists/* /tmp/ms.deb

When you turn on HTTP/3 on the endpoint with "Protocols": "Http1AndHttp2AndHttp3", Kestrel adds the alt-svc header to HTTP/1.1 and HTTP/2 responses; the browser makes the first request over TCP and, on seeing this header, moves the following ones to QUIC:

$ curl -s -o /dev/null -D - --cacert /w/certs/ca.crt --resolve api.bookstore.test:8443:172.30.85.21 https://api.bookstore.test:8443/api/books
HTTP/2 200
content-type: application/json; charset=utf-8
date: Sun, 08 Nov 2026 03:37:37 GMT
server: Kestrel
alt-svc: h3=":8443"; ma=86400

The curl in the container isn't built with HTTP/3 support, so I sent the real request with .NET's HttpClient. A small console app that trusts the test CA and asks for HttpVersion.Version30:

using System.Net;
using System.Net.Quic;
using System.Security.Cryptography.X509Certificates;

// usage: H3Client <url> <ca.crt>
string url = args[0];
X509Certificate2 ca = X509CertificateLoader.LoadCertificateFromFile(args[1]);
Console.WriteLine($"QuicConnection.IsSupported: {QuicConnection.IsSupported}");

SocketsHttpHandler handler = new()
{
    SslOptions =
    {
        RemoteCertificateValidationCallback = (_, cert, _, _) =>
        {
            using X509Chain chain = new();
            chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
            chain.ChainPolicy.CustomTrustStore.Add(ca);
            chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
            return cert is not null && chain.Build(new X509Certificate2(cert));
        },
    },
};

using HttpClient client = new(handler);
using HttpRequestMessage request = new(HttpMethod.Get, url)
{
    Version = HttpVersion.Version30,
    VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using HttpResponseMessage response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} HTTP/{response.Version}");
Console.WriteLine(await response.Content.ReadAsStringAsync());
$ dotnet H3Client.dll https://api.bookstore.test:8443/api/whoami /certs/ca.crt
QuicConnection.IsSupported: True
200 HTTP/3.0
{"instance":"api1","remoteIp":"172.30.85.51","remotePort":47337,"connectionId":"0HNPT96B6GG81","protocol":"HTTP/3","scheme":"https","host":"api.bookstore.test:8443","pathBase":"","path":"/api/whoami","xForwardedFor":"","xOriginalFor":"","xForwardedProto":"","link":"https://api.bookstore.test:8443/api/books/1"}

Running the same config on the plain image without libmsquic, Kestrel's log has not a single line about HTTP/3, even at Debug level; only Now listening on: https://[::]:8443. The alt-svc header disappears from responses and a client asking for HTTP/3 finds nobody on the UDP port:

QuicConnection.IsSupported: True
Unhandled exception. System.Net.Http.HttpRequestException: No route to host (api.bookstore.test:8443)
 ---> System.Net.Sockets.SocketException (0x00000071): No route to host

As the docs say, if the platform isn't ready Kestrel quietly turns HTTP/3 off and carries on with HTTP/1.1 and HTTP/2. Before saying "I enabled HTTP/3", look at the alt-svc header and at a real HTTP/3 request. Don't forget to open UDP 443 in the firewall next to TCP 443.

Connecting to Nginx over a Unix Socket

If Nginx and Kestrel are on the same machine, there's no need for TCP between them. Microsoft Learn recommends a Unix socket for better performance with Nginx. On the Kestrel side only the URL changes:

$ docker run -d --name webhwork-api1 -e ASPNETCORE_URLS=http://unix:/run/bookstore/kestrel.sock \
    -v webhwork-sock:/run/bookstore ... mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
$ docker logs webhwork-api1 2>&1 | grep -A1 listening
      Now listening on: http://unix:/run/bookstore/kestrel.sock
$ docker exec webhwork-api1 ls -l /run/bookstore/
total 0
srwxr-xr-x 1 root root 0 Nov  8 03:38 kestrel.sock

On the Nginx side you give proxy_pass the path of the socket:

location /api/ {
    proxy_pass http://unix:/run/bookstore/kestrel.sock;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

The first request:

HTTP/1.1 502 Bad Gateway

2026/11/08 03:38:26 [crit] 20#20: *1 connect() to unix:/run/bookstore/kestrel.sock failed (13: Permission denied) while connecting to upstream, client: 172.30.85.50, server: bookstore.local, request: "GET /api/whoami HTTP/1.1", upstream: "http://unix:/run/bookstore/kestrel.sock:/api/whoami", host: "bookstore.local"

Connecting to a socket requires write permission; root created the socket and left it with rwxr-xr-x, while Nginx's worker processes run as the nginx user. The fix in the docs is to grant write permission on the socket with chmod; cleaner is to run Kestrel and Nginx in a shared group and make the socket writable by that group. Once permission is granted the request goes through, and one more detail shows up:

$ curl -s -H "Host: bookstore.local" http://172.30.85.10/api/whoami | jq -c "{remoteIp,remotePort,host,xForwardedFor}"
{"remoteIp":null,"remotePort":0,"host":"bookstore.local","xForwardedFor":"172.30.85.50"}

There's no IP address on a Unix socket; RemoteIpAddress is null. You depend entirely on X-Forwarded-For for the client IP, and the forwarded headers middleware's rules for trusting the proxy work differently in this case. That's the next post's topic.

In Production

Define the binding in one place. If the team uses Kestrel:Endpoints in appsettings.json, ASPNETCORE_URLS in the systemd unit and ASPNETCORE_HTTP_PORTS in the Dockerfile, only the log tells you which one is in effect. Add searching the log for the word Overriding to your post-deploy checklist.

The app logs the addresses it listens on at startup, but if you want to ask later there's IServerAddressesFeature; the /api/server endpoint in Bookstore.Api returns it. Running the health check from outside, over the real network path, catches the localhost trap in the first minute.

Change limits by measuring. MaxConcurrentConnections being null means unlimited; if you expose Kestrel directly to the internet, the number of connections is bounded by the operating system's file descriptor limit. Behind a proxy this limit is set in Nginx. If you make KeepAliveTimeout shorter than the proxy's upstream keepalive time, the proxy tries to use a connection that has already been closed and you see an occasional 502.

Common Pitfalls

Defining the same port from two sources. When one asks for 9000 in code and the other in configuration, as in the seventh run, it's a clash rather than an override and the app won't start. The error looks like it points at another process.

Binding to localhost in a container. The app looks healthy from inside and is unreachable from outside. The setting that's right behind a proxy on a VM is wrong in a container.

Enabling HTTP/3 and assuming it works. If libmsquic is missing or the UDP port is closed, Kestrel doesn't tell you. In this experiment I saw one more thing: with an Sni section on the endpoint, the HTTP/3 request failed with Application layer protocol negotiation error was encountered; giving the same certificate without Sni, directly as Certificate, worked. I couldn't find a note about this combination on Microsoft Learn; if you plan to use SNI and HTTP/3 together, try it in your own environment.

Copying a certificate generated with dotnet dev-certs to the server. The development certificate is only for localhost, short-lived and trusted on your own machine; on the server clients don't trust it and nobody tracks its renewal.

A Small Experiment

★☆☆ Run prec.sh after adding "urls": "http://+:5500" to appsettings.json while also passing ASPNETCORE_URLS. Which one wins? Explain the result with the configuration provider order.

★★☆ Set Kestrel:Limits:MaxRequestBodySize to 1048576 and send a 2 MB file to /api/uploads with curl --data-binary @file. Which status code comes back and what does Kestrel write to the log? Then raise the limit to 50 MB for this endpoint only with IHttpMaxRequestBodySizeFeature.

★★★ Move the HTTP/3 endpoint into code with ListenAnyIP(8443, o => { o.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; o.UseHttps(...); }) and pick the certificate per host with ServerCertificateSelector. Does the HTTP/3 request work for both hosts? Compare the result with the Sni attempt in configuration.

Kestrel now listens on the right address, but the moment we put Nginx in front the app sees the client wrongly: the IP is Nginx's, the scheme is http, and on a Unix socket there's no IP at all. In the next post we look at the forwarded headers middleware, KnownProxies and .NET 10's KnownIPNetworks, the HTTPS redirection loop and whom to trust with headers.