Nginx as a Reverse Proxy for ASP.NET Core: proxy_pass, Headers, WebSockets/SignalR and Timeouts
Since Nginx 1.29.7 upstream keepalive and HTTP/1.1 are the default, but only if you use an upstream block: proxy_pass to a direct address opens a new connection per request. We take Microsoft's reference config apart line by line and measure SignalR quietly falling back to long polling without the WebSocket upgrade map, buffering with SSE, aligning Nginx's and Kestrel's body and header limits, and telling 502 from 504 in the logs.
Version note: The examples in this post were run with Nginx 1.30.6 and 1.28.3 (official images), .NET 10 (ASP.NET Core runtime 10.0.14) and curl 8.23 on Docker 29.
Guess First
Bookstore's API (Application Programming Interface), Bookstore.Api, sits behind Nginx and a client sends 100 requests in a row. The /api/whoami endpoint on Kestrel returns HttpContext.Connection.Id for every request; the number of distinct IDs tells you how many separate TCP (Transmission Control Protocol) connections Nginx opened to Kestrel. There are four setups: Nginx 1.28 and 1.30, and in each proxy_pass goes either to an upstream block or straight to http://172.30.85.21:8080.
Guess: how many connections does Kestrel see in each of the four setups? Nginx 1.29.7's release notes say "upstream keepalive is now enabled by default"; does that sentence apply to all four?
Microsoft's Reference Config Line by Line
The config from the "Host ASP.NET Core on Linux with Nginx" page on Microsoft Learn is this post's starting point:
map $http_connection $connection_upgrade {
"~*Upgrade" $http_connection;
default keep-alive;
}
server {
listen 80;
server_name bookstore.local;
location / {
proxy_pass http://172.30.85.21:8080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Line by line:
map: if the client'sConnectionheader contains "Upgrade", pass it through as is; otherwise writekeep-alive. Needed for the WebSocket handshake, as we'll see below.proxy_pass http://...:8080/: combined withlocation /, the trailing slash replaces/with/; in other words it has no effect. The rule you saw in the previous post.proxy_http_version 1.1: before Nginx 1.29.7 it went to the upstream with HTTP (Hypertext Transfer Protocol) 1.0 by default; keepalive and WebSockets require 1.1. Since 1.29.7 the default is already 1.1.UpgradeandConnection: hop-by-hop headers; a proxy doesn't forward them on its own, you have to set them explicitly.Host $host: carries the host the client asked for to the app. Without it the upstream's address goes instead, as in the previous post.proxy_cache_bypass $http_upgrade: only meaningful whenproxy_cacheis on; it avoids answering an upgrade request from the cache.X-Forwarded-ForandX-Forwarded-Proto: so the app sees the client correctly; the topic of the third post.
The config is short and correct, but it doesn't do one thing: it doesn't reuse connections to Kestrel. The answer to the guess shows this.
Upstream Keepalive and the 1.29.7 Defaults
Results for the four setups (100 requests each, distinct connections seen by Kestrel):
$ for i in $(seq 1 100); do curl -s -H "Host: bookstore.local" http://172.30.85.10/api/whoami | jq -r .connectionId; done | sort -u | wc -l
# with an upstream block
nginx:1.28 -> 100
nginx:1.30 -> 10
# direct address (including Microsoft's config)
nginx:1.28 -> 100
nginx:1.30 -> 100
On 1.28 every request is a new TCP connection; Kestrel's protocol field confirms it, the requests arrived as HTTP/1.0. On 1.30 with an upstream block, 100 requests fit into 10 connections: this machine has 10 worker processes and each worker keeps its own keepalive pool. Two changes from the 1.29.7 release notes work together: "the keepalive directive in the upstream block is enabled by default" and "the proxy module supports keepalive by default; the default value for proxy_http_version is 1.1 and the Connection header is not sent by default anymore".
But with proxy_pass going straight to an address, even 1.30 opened a new connection for every request. The default keepalive is a feature of the upstream block; Microsoft's reference config doesn't use an upstream block, so it doesn't benefit. The per-connection cost is small on loopback, but under load it means Kestrel accepting a connection on every request and thousands of sockets waiting in TIME_WAIT on the Nginx side. That's why Bookstore's config is written with an upstream block:
upstream bookstore_api {
server 172.30.85.21:8080;
}
server {
listen 80;
server_name bookstore.local;
location /api/ {
proxy_pass http://bookstore_api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
If you're on Nginx older than 1.30, you get the same effect by adding keepalive 16; to the upstream and proxy_http_version 1.1; plus proxy_set_header Connection ""; to the location. Kestrel's idle connection timeout (KeepAliveTimeout, 2 minutes 10 seconds by default) must be longer than the time Nginx keeps an idle upstream connection (keepalive_timeout, 60 seconds by default); the other way round, Nginx tries to write a request to a connection Kestrel has closed.
WebSockets and SignalR: the Upgrade Map
Bookstore.Api has a SignalR hub that broadcasts stock changes (/hubs/stock). The SignalR client first POSTs to the negotiate endpoint, then asks for a WebSocket upgrade. I did the same two steps with curl, first with a config that forwards only the basic headers, then with Microsoft's config with the map:
#!/bin/sh
# SignalR negotiate + WebSocket upgrade through Nginx; print the status line and key headers
tok=$(curl -s -X POST -H "Host: bookstore.local" "http://172.30.85.10/hubs/stock/negotiate?negotiateVersion=1" | jq -r .connectionToken)
echo "connectionToken: $tok"
curl -s -i -N --http1.1 --max-time 2 -H "Host: bookstore.local" -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"http://172.30.85.10/hubs/stock?id=$tok" | tr -d '\r' | grep -E "^HTTP|^Upgrade|^Connection|^Sec-WebSocket-Accept|WebSocket|websocket" | head -6
# Upgrade and Connection not forwarded
connectionToken: AsASzzYCFVtEHEyxARfVqA
HTTP/1.1 200 OK
Connection: keep-alive
# Microsoft's config (with the map)
connectionToken: J9TzWq8-fnLEzrK8hcNZhw
HTTP/1.1 101 Switching Protocols
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The first case is sneaky because there's no error. Since the Upgrade header never reaches Kestrel, the request goes through as a plain GET, and SignalR treats it as a long polling request and returns 200. A real SignalR client falls back to Server-Sent Events when WebSockets fail, and to long polling if that fails too, and the app keeps working; just slower, with more requests and more connections. If you see transport=longPolling in the browser's network tab, look at the proxy first.
In the second case 101 Switching Protocols comes back and the connection turns into a WebSocket. The point of the map is not to write "upgrade" into Connection for requests that aren't upgrades: sending a fixed Connection: upgrade on every request breaks keepalive. A long-lived WebSocket connection runs into Nginx's proxy_read_timeout (60 seconds by default); the SignalR server sends a keep-alive message every 15 seconds by default, so the connection stays open, but with a raw WebSocket you write yourself you have to manage this either with pings or by raising the timeout. The sticky session requirement for SignalR across several instances is in the "Scaling Out SignalR" post of the Real-time Applications series; ip_hash and sticky on the Nginx side are in the seventh post.
Server-Sent Events and Buffering
The /api/stock/stream endpoint in Bookstore.Api sends a stock update every second with .NET 10's TypedResults.ServerSentEvents. Kestrel's response headers when called directly:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Sun, 08 Nov 2026 04:06:30 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Pragma: no-cache
Transfer-Encoding: chunked
Nginx's default is proxy_buffering on. With a small script that measures when events reach the client, without adding any SSE (Server-Sent Events) setting to the config:
+1s data: 04:07:22 Dune stock=19
+2s data: 04:07:23 Dune stock=18
+3s data: 04:07:24 Dune stock=17
+5s data: 04:07:25 Dune stock=16
+6s data: 04:07:26 Dune stock=15
The events arrived without delay. Nginx's buffering doesn't mean "collect the whole response, then send"; as long as the client reads quickly it forwards incoming data without holding it, and the buffer only fills when the client falls behind. What really breaks SSE is compression: the gzip module accumulates data. Because .NET 10's SSE response comes with Content-Encoding: identity, Nginx doesn't try to compress it. All these details, the X-Accel-Buffering header and timeouts on long connections were measured in detail in the "Server-Sent Events in Depth" post of the Real-time Applications series; the .NET 10 API itself is in the ".NET 10 What's New" post of the ASP.NET Core Web API series.
Still, opening a separate location for SSE and long-running streams with proxy_buffering off; and a long proxy_read_timeout is a good habit: behaviour stays predictable with a slow mobile client too, and these settings don't leak into the rest of the API.
Body and Header Limits: Aligning Nginx with Kestrel
Both layers have limits, and Nginx's kick in first. I sent a 2 MB and a 40 MB file to /api/uploads:
$ for f in 2mb 40mb; do curl -s -o /tmp/b -w "$f -> %{http_code} uploaded=%{size_upload} " -H 'Host: bookstore.local' \
-H 'Content-Type: application/octet-stream' --data-binary @/w/www/$f.bin http://172.30.85.10/api/uploads; grep -o '<title>[^<]*\|{.*}' /tmp/b; done
# Nginx default: client_max_body_size 1m
2mb -> 413 uploaded=0 <title>413 Request Entity Too Large
40mb -> 413 uploaded=0 <title>413 Request Entity Too Large
# client_max_body_size 50m
2mb -> 200 uploaded=2097152 {"bytes":2097152}
40mb -> 413 uploaded=41943040
Nginx's error log records the rejection in the first attempt like this:
[error] 21#21: *2 client intended to send too large body: 41943040 bytes, client: 172.30.85.50, server: bookstore.local, request: "POST /api/uploads HTTP/1.1", host: "bookstore.local"
Nginx's default of 1 MB is far smaller than Kestrel's default of 30,000,000 bytes; when you first set up Nginx, the file upload feature comes back as "it worked on Kestrel, it gives 413 on the server". Raising the limit to 50 MB let the 2 MB file through, and this time Kestrel rejected the 40 MB one:
[warn] 21#21: *3 a client request body is buffered to a temporary file /var/cache/nginx/client_temp/0000000002, client: 172.30.85.50, server: bookstore.local, request: "POST /api/uploads HTTP/1.1", host: "bookstore.local"
172.30.85.50 "POST /api/uploads HTTP/1.1" 413 rt=0.101 urt=0.048 us=413 ua=172.30.85.21:8080
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNPT9PQVOK4A", Request id "0HNPT9PQVOK4A:00000001": An unhandled exception was thrown by the application.
Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException: Request body too large. The max request body size is 30000000 bytes.
What stands out is uploaded=41943040: the client uploaded all 40 MB, Nginx wrote it to disk (buffered to a temporary file), then forwarded it to Kestrel, and Kestrel rejected it. A wasted upload for the user, wasted disk and bandwidth for the server. The rule: Nginx's limit should be equal to or smaller than Kestrel's, and endpoints that accept large files should be defined in a separate location with their own limit. Per-endpoint limits on the Kestrel side are in the "File Upload and Download" post of the ASP.NET Core Web API series.
Headers tell the same story. By default Nginx uses an 8 KB buffer for a single header line (large_client_header_buffers 4 8k), while Kestrel allows 32 KB for all headers combined:
# default
cookie=10000B -> 400 <title>400 Request Header Or Cookie Too Large
cookie=40000B -> 400 <title>400 Request Header Or Cookie Too Large
# large_client_header_buffers 4 64k;
cookie=10000B -> 200
cookie=40000B -> 431
Large tokens or cookies carrying many claims (common in apps that work with Microsoft Entra ID) get stuck at Nginx first. After enlarging the buffer, the 40 KB cookie got a 431 (Request Header Fields Too Large) from Kestrel instead. Microsoft's documentation says not to enlarge the buffers unless necessary, and to account for the DoS (Denial of Service) risk if you do. Try shrinking the cookie first; if that fails, tune both layers together.
Timeouts: Telling 502 from 504
Gateway errors are Nginx's way of saying "I couldn't talk to the upstream"; which one you get tells you where the problem is. To tell them apart I added the upstream time and status to the access log:
log_format upstream_timing '$remote_addr "$request" $status '
'rt=$request_time urt=$upstream_response_time '
'us=$upstream_status ua=$upstream_addr';
With proxy_connect_timeout 2s and proxy_read_timeout 5s on the location, three cases:
# 2-second report: no problem
172.30.85.50 "GET /api/reports/slow?seconds=2 HTTP/1.1" 200 rt=2.589 urt=2.589 us=200 ua=172.30.85.21:8080
# 10-second report: Kestrel is up but no response within 5 seconds
172.30.85.50 "GET /api/reports/slow?seconds=10 HTTP/1.1" 504 rt=5.016 urt=5.016 us=504 ua=172.30.85.21:8080
[error] 22#22: *3 upstream timed out (110: Connection timed out) while reading response header from upstream, ...
# process not running, port closed
172.30.85.50 "GET /api/books HTTP/1.1" 502 rt=0.011 urt=0.011 us=502 ua=172.30.85.21:8080
[error] 27#27: *9 connect() failed (111: Connection refused) while connecting to upstream, ...
502 is when the connection couldn't be established at all or the upstream sent a broken response: a crashed process, the wrong port, Unix socket permissions, Kestrel cutting the response short. It takes milliseconds. 504 is when the connection was established but the response didn't arrive in time: a slow query, a starved thread pool, a database wait. It takes exactly the timeout. One more detail: when I stopped the container completely Nginx returned 504, not 502, because the IP (Internet Protocol) address no longer answered and the error came from proxy_connect_timeout, while connecting to upstream. The "while connecting" versus "while reading response header" distinction in the error log tells you more than the status code.
The first reflex on seeing a 504 is to raise proxy_read_timeout. If a report takes 3 minutes, raising the timeout to 5 minutes only moves the problem: a request thread busy for 3 minutes in Kestrel, a user waiting, and the cloud load balancer has its own timeout. The right place for long work is a background job: the endpoint queues the work and returns 202 Accepted with a status address, and the client polls the status or hears back via SSE. This approach is covered in the first post of the Background Jobs and Scheduled Jobs series.
In Production
Collect shared proxy settings in a single file and include it in every location. The proxy.conf in Microsoft's sample does that job; it contains proxy_redirect off, the header set, client_max_body_size 10m and 90-second timeouts. Careful: if proxy_set_header is defined again in a location, all the ones from the outer context are dropped, so do the include inside the location and add location-specific headers after the include.
The difference between $upstream_response_time and $request_time shows the time spent between Nginx and the client. If it's large, what's slow isn't your app but the client's network or the size of the response. Adding these two values to the log solves half of the "the API is slow" complaints at first glance.
When upgrading Nginx, read the "Change" lines in the release notes. The keepalive change in 1.29.7 is free performance for most setups; but if the upstream app manages keepalive badly (for example with a short KeepAliveTimeout), you start seeing occasional 502s after the upgrade.
Common Pitfalls
Writing a direct address instead of an upstream block. The config works, but you don't benefit from 1.30's keepalive default; every request is a new connection.
Forgetting the WebSocket headers. There's no error, SignalR quietly falls back to long polling; you look for the performance problem in the app instead of the proxy.
Setting Nginx's and Kestrel's limits separately. Nginx cuts at 1 MB while you raise Kestrel to 50 MB; or Nginx writes 50 MB to disk and forwards it, and Kestrel rejects it at 30 MB.
Solving a 504 by inflating the timeout. Move long work out of the request; raising the timeout multiplies waiting users and filling connections.
Diagnosing 502 versus 504 from the status code. Stopping a container can produce a 504; look at the "while connecting" versus "while reading" distinction in the error log.
A Small Experiment
★☆☆ Turn the proxy_pass in Microsoft's reference config into an upstream block and repeat the 100-request experiment. How many connections do you see? Then how does the number change when you put worker_processes 1; at the top of the Nginx config?
★★☆ Open a separate location for /api/stock/stream, add gzip on; gzip_types text/event-stream; and measure the arrival times of the events. Then repeat with an endpoint in Bookstore.Api that writes text/event-stream by hand instead of the SSE result (that is, without Content-Encoding: identity). In which case does the delay start?
★★★ Move /api/reports/slow to a background job: the endpoint returns 202 Accepted with a job ID and /api/reports/{id} returns the status. Leave Nginx's proxy_read_timeout at 5 seconds and run a 30-second report end to end.
The API now runs behind Nginx with the right headers, with keepalive and with aligned limits. In the next post we move on to the other half of Bookstore: serving the Angular app with Nginx, SPA (Single Page Application) fallback, cache headers for hashed bundles and index.html, gzip and precompressed files, a runtime config.json, and removing CORS (Cross-Origin Resource Sharing) with an /api proxy on the same origin.