.NET Core (now just called .NET, but I’ll use “Core” throughout since that’s still the common search term) ships with its own built-in web server, Kestrel, which is genuinely fast and production-capable on its own. But in almost every real deployment, you still want Nginx sitting in front of it as a reverse proxy. In this guide, I’ll cover why that pattern makes sense, how to set it up correctly for both Linux-hosted ASP.NET Core apps and apps running behind Kestrel via systemd, and the specific headers and settings ASP.NET Core needs from a reverse proxy to work correctly.
Why Put Nginx in Front of Kestrel?
Kestrel is designed to be a fast, cross-platform application server, but Microsoft’s own documentation explicitly recommends running it behind a reverse proxy like Nginx or IIS in production rather than exposing it directly to the internet. The reasons come down to:
- TLS termination — it’s simpler to manage certificates and TLS configuration in Nginx than to configure Kestrel directly for HTTPS, especially with Let’s Encrypt automation.
- Handling multiple sites on one server — Nginx can route to multiple Kestrel instances (different apps, different ports) using a single public IP and virtual hosting by domain name.
- Buffering and slow client protection — Nginx is better tuned to absorb slow or malicious clients without tying up your application’s request-handling threads.
- Static file serving and caching — Nginx can serve static assets directly, taking that load off your .NET application entirely.
- Additional layers — rate limiting, IP restriction, request logging, and load balancing are all easier to bolt on at the Nginx layer.
Requirements
- .NET SDK/Runtime installed on your Linux server. Verify with:
dotnet --version
- Nginx installed (
nginx -vto confirm). - Your ASP.NET Core application published and ready to run, typically via
dotnet publish. - Root/sudo access for configuring systemd and Nginx.
Publishing Your .NET Core Application
From your development machine or CI pipeline, publish a self-contained or framework-dependent build:
dotnet publish -c Release -o /var/www/myapp
Copy the output to your server if you built it elsewhere:
scp -r ./publish/* user@yourserver:/var/www/myapp
Test that it actually runs directly with Kestrel first, before involving Nginx at all — this isolates whether a problem is in your app or in the proxy layer:
cd /var/www/myapp
dotnet myapp.dll --urls "http://localhost:5000"
Visit http://your-server-ip:5000 to confirm it’s working, then stop it with Ctrl+C — we’ll run it properly via systemd next.
Running Your App as a systemd Service
You don’t want to run dotnet myapp.dll in a terminal session that dies the moment you disconnect. Create a systemd unit file instead:
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My ASP.NET Core Application
After=network.target
[Service]
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/dotnet /var/www/myapp/myapp.dll
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=myapp
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://localhost:5000
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
[Install]
WantedBy=multi-user.target
A few notes on this file: I’m binding Kestrel to localhost:5000 only, not 0.0.0.0 — since Nginx will be the only thing talking to it directly, there’s no reason to expose it on all interfaces. Running as www-data (or a dedicated app-specific user) rather than root follows the principle of least privilege.
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp
Check logs if something’s wrong:
sudo journalctl -u myapp -f
Configuring Nginx as the Reverse Proxy
Here’s the core proxy configuration, including the headers ASP.NET Core specifically needs to correctly determine the original client IP and scheme (important for things like Request.IsHttps, generated absolute URLs, and any IP-based logic in your app):
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_cache_bypass $http_upgrade;
}
}
I included the Upgrade/Connection headers here because many ASP.NET Core apps use SignalR, which relies on WebSockets (or falls back to long-polling) — having this in place from the start avoids a second round of debugging later if you add real-time features.
Telling ASP.NET Core to Trust the Proxy
By default, ASP.NET Core doesn’t automatically trust the X-Forwarded-For and X-Forwarded-Proto headers Nginx sends — you need to explicitly configure the Forwarded Headers Middleware in your application so HttpContext.Connection.RemoteIpAddress and Request.Scheme reflect the real client, not Nginx itself.
In Program.cs (for the modern minimal hosting model):
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// If Nginx is on the same host, localhost is a known proxy by default.
// If Nginx is on a different host, add its IP explicitly:
// options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
});
var app = builder.Build();
app.UseForwardedHeaders();
// UseForwardedHeaders should come before other middleware that reads
// the scheme or client IP, such as authentication or HTTPS redirection.
app.UseRouting();
app.MapControllers();
app.Run();
This is a step people frequently miss, and it causes subtle bugs — like the app thinking every request is coming from 127.0.0.1 (Nginx’s IP, since it’s the one making the actual connection to Kestrel), or generated links using http:// even when the real client connected over https://.
Serving Static Files Directly Through Nginx
Even though ASP.NET Core can serve static files itself via app.UseStaticFiles();, it’s more efficient to let Nginx handle them directly, bypassing your application entirely for things like images, CSS, and JS:
server {
listen 80;
server_name example.com;
location /css/ {
alias /var/www/myapp/wwwroot/css/;
expires 30d;
}
location /js/ {
alias /var/www/myapp/wwwroot/js/;
expires 30d;
}
location /images/ {
alias /var/www/myapp/wwwroot/images/;
expires 30d;
}
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Complete Example Configuration
Here’s a fuller production example with HTTPS, static file offloading, and SignalR-friendly WebSocket support:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 20M;
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
root /var/www/myapp/wwwroot;
expires 30d;
add_header Cache-Control "public";
}
location /hubs/ {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Note client_max_body_size 20M; — ASP.NET Core apps handling file uploads will hit Nginx’s default 1MB body size limit otherwise, resulting in a 413 Request Entity Too Large before the request even reaches your app.
Testing Your Configuration
Validate and reload:
sudo nginx -t
sudo systemctl reload nginx
Confirm the app itself is healthy directly on Kestrel first:
curl -I http://localhost:5000
Then confirm it through Nginx:
curl -I https://example.com
Check that forwarded headers are actually reaching your app correctly by adding a temporary debug endpoint:
app.MapGet("/debug-headers", (HttpContext context) =>
{
return Results.Ok(new
{
RemoteIp = context.Connection.RemoteIpAddress?.ToString(),
Scheme = context.Request.Scheme,
Host = context.Request.Host.ToString()
});
});
curl https://example.com/debug-headers
If RemoteIp shows Nginx’s own IP (127.0.0.1) instead of your actual client IP, the Forwarded Headers Middleware isn’t configured correctly, or KnownProxies/KnownNetworks needs adjustment.
Troubleshooting Common Issues
502 Bad Gateway. Check that Kestrel is actually running (systemctl status myapp) and listening on the port Nginx expects. Check journalctl -u myapp for application startup errors — a common cause is a missing environment variable or connection string that only shows up under Production configuration.
413 Request Entity Too Large on file uploads. Increase client_max_body_size in the relevant server or location block, and make sure it matches or exceeds any request size limits configured on the ASP.NET Core side (Kestrel:Limits:MaxRequestBodySize or [RequestSizeLimit] attributes).
App thinks every request is HTTP even though the site is HTTPS. This is the Forwarded Headers Middleware issue described above — confirm UseForwardedHeaders() is called early in the pipeline, before middleware that checks Request.Scheme, and that KnownProxies/KnownNetworks is set correctly if Nginx isn’t on the same host.
SignalR connections fail or constantly fall back to long-polling. Confirm the WebSocket Upgrade/Connection headers are present on the relevant location block (typically /hubs/ or wherever you mapped your hub), and that proxy_read_timeout is generous enough for long-lived connections.
Service won’t start after a reboot. Confirm sudo systemctl enable myapp was actually run — start alone doesn’t persist across reboots.
Security Considerations
- Never bind Kestrel to
0.0.0.0when Nginx is meant to be the only entry point — keep it onlocalhostso it’s unreachable directly from the internet even if firewall rules are misconfigured. - Run the systemd service as a dedicated low-privilege user, not root.
- Set
ASPNETCORE_ENVIRONMENT=Productionexplicitly — running inDevelopmentmode in production exposes detailed exception pages and debugging information you don’t want public. - Configure
KnownProxiesexplicitly in the Forwarded Headers Middleware rather than trusting all proxies blindly, especially if Nginx and your app aren’t on the same host. - Keep both the .NET runtime and Nginx patched — security updates for either layer matter.
Performance Tips
- Let Nginx serve static files directly rather than routing them through Kestrel and the ASP.NET Core middleware pipeline.
- Enable response compression either at the Nginx layer (
gzip on;) or via ASP.NET Core’s Response Compression middleware — not usually both, to avoid double compression overhead. - Use
proxy_buffering on;(the default) for typical request/response traffic; only disable it for streaming or Server-Sent Events endpoints where you need data flushed immediately. - Consider running multiple Kestrel instances behind Nginx load balancing (
upstreamblock with severalserverentries on different ports) if a single instance becomes CPU-bound.
Real-World Use Cases
- Internal line-of-business applications — a common .NET shop pattern, deployed to Linux servers with Nginx handling TLS and routing.
- Public-facing web APIs — Nginx providing rate limiting, IP restriction, and caching in front of an ASP.NET Core Web API.
- Real-time applications with SignalR — chat, live dashboards, and notifications, with Nginx correctly proxying the WebSocket upgrade.
- Multi-tenant deployments — several .NET applications on different ports, each behind its own Nginx virtual host on a shared server.
- Blue-green deployments — running two Kestrel instances on different ports and switching Nginx’s
proxy_passtarget (or upstream weight) during a release.
Best Practices
- Always configure the Forwarded Headers Middleware in your .NET app — don’t assume Nginx headers “just work” without it.
- Bind Kestrel to localhost only when Nginx is your entry point.
- Run your app under systemd with
Restart=alwaysfor resilience against crashes. - Offload static file serving to Nginx.
- Set
client_max_body_sizedeliberately based on your actual upload requirements, not just the default. - Keep environment-specific configuration (
ASPNETCORE_ENVIRONMENT, connection strings, secrets) out of source control and managed through systemd environment files or a secrets manager.
This pairing — Kestrel doing what it does well, and Nginx handling everything around the edges — is a genuinely solid, well-tested production pattern for .NET applications on Linux, and it scales from a single small app up to fairly serious multi-service deployments without needing to change the fundamental architecture.
