Hosting .NET on Linux behind Nginx
If you're running .NET applications in production on Linux, you've likely asked yourself: how do I safely expose my Kestrel server without it handling TLS termination, compression, and all the complexity of a public interface? The answer I've used across multiple production platforms—from e-commerce to ride-hailing systems—is a straightforward setup: hosting .NET on Linux behind Nginx. This architecture gives you the security, performance, and operational simplicity that makes deploying ASP.NET Core at scale genuinely boring (in the best way).
The Request Path: Understanding Your Architecture
Before we dig into configuration, let's map out how a request actually travels through your system. When a client connects to your domain, they never actually talk to Kestrel directly. Instead, the path looks like this:
- Client sends HTTPS request to your domain
- Nginx receives the request on port 443, terminates TLS, and decompresses any gzip payload
- Nginx forwards the request internally to Kestrel on localhost:5000 over plain HTTP
- Kestrel receives the request, your ASP.NET Core app processes it, and generates a response
- Kestrel sends the response back to Nginx
- Nginx compresses the response with gzip, re-encrypts with TLS, and sends it to the client
This separation of concerns is critical. Nginx is battle-tested, lightweight, and purpose-built for handling thousands of concurrent connections with minimal overhead. Kestrel stays small and focused on running your application logic. If Kestrel crashes, systemd restarts it. If you need to deploy a new version, you can do it without dropping a single request (more on that in the zero-downtime section).
Here's a minimal Nginx configuration to get you started:
upstream kestrel_backend {
server localhost:5000;
}
server {
listen 80;
server_name example.com;
return 301 https://$server_name$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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
location / {
proxy_pass http://kestrel_backend;
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_redirect off;
}
}Notice how Nginx is handling TLS termination and gzip compression. Your ASP.NET Core app doesn't need to worry about any of that complexity. It just receives clean HTTP requests on localhost:5000.
Forwarded Headers: Making Your App Aware of the Real Client
Here's a subtle but crucial issue: when your ASP.NET Core app looks at the incoming request, it sees the connection coming from localhost (Nginx's forwarding). Without proper configuration, your code won't know the real client IP, and worse, it won't know the original request used HTTPS. This breaks logging, security checks, and rate limiting.
The fix is to configure ForwardedHeaders middleware in your ASP.NET Core app. This middleware reads the headers that Nginx set (like X-Forwarded-For and X-Forwarded-Proto) and updates the request context accordingly.
In your Program.cs, add this early in your middleware pipeline:
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
var app = builder.Build();
// Add this middleware BEFORE UseAuthentication and UseAuthorization
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();A practical scenario: I was building a CRM system a few years back, and we started seeing all requests logged as coming from 127.0.0.1. Our security team flagged it during review. Once I added this middleware, we could see actual client IPs in our logs. We also had a feature that redirects HTTP to HTTPS, but without X-Forwarded-Proto, the app thought everything was already HTTPS and created redirect loops. The middleware fixed both issues instantly.
One important note: KnownNetworks.Clear() and KnownProxies.Clear() make the middleware trust all forwarded headers. This is safe if Nginx is the only thing that can reach your Kestrel instance (which it should be—bind Kestrel to localhost only). If your infrastructure allows multiple proxies or untrusted sources to reach Kestrel, you should whitelist specific proxy IPs instead.
systemd Service: Keeping Your App Alive
You need a way to start your .NET application on boot and automatically restart it if it crashes. Linux systemd is perfect for this. Create a service file at /etc/systemd/system/myapp.service:
[Unit]
Description=My ASP.NET Core Application
After=network.target
[Service]
Type=notify
User=appuser
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/dotnet /opt/myapp/MyApp.dll
Restart=always
RestartSec=10
SyslogIdentifier=myapp
Environment="ASPNETCORE_ENVIRONMENT=Production"
Environment="ASPNETCORE_URLS=http://localhost:5000"
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetLet me break down the key parts:
Type=notify: Tells systemd to wait for the app to signal it's ready. ASP.NET Core does this automatically.User=appuser: Run as a dedicated, unprivileged user (not root). Create this user withuseradd -r -s /bin/false appuser.WorkingDirectory: Where your published app lives.ExecStart: The command to run. Point to your published DLL, not the project file.Restart=always: If the process crashes, systemd will restart it after 10 seconds (RestartSec).Environment: SetASPNETCORE_ENVIRONMENT=Productionand bind Kestrel to localhost only.StandardOutput=journal: Send logs to systemd's journal. You can view them withjournalctl -u myapp -f.
After creating the file, enable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myappTo view logs in real-time:
sudo journalctl -u myapp -fI've used this exact setup across a trading platform and a travel booking system. The beauty is that it's invisible—the service just runs, restarts on crashes, and you get journal logs for debugging. No custom restart scripts, no cron jobs, no complexity.
Zero-Downtime Deploys: Restarting Without Dropping Requests
The setup above works great for basic scenarios, but in production, you sometimes need to deploy without cutting traffic. The solution is to run two instances of your app on different ports (say, 5000 and 5001), put both behind Nginx upstreams, and restart them one at a time.
Update your Nginx configuration:
upstream kestrel_backend {
server localhost:5000;
server localhost:5001;
}
server {
listen 443 ssl http2;
server_name example.com;
# ... SSL config ...
location / {
proxy_pass http://kestrel_backend;
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;
# Keep connections alive to backends
proxy_set_header Connection "";
# Timeouts for long-running requests
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}Create two systemd services: myapp-a.service and myapp-b.service. Change only the ASPNETCORE_URLS environment variable:
# In myapp-a.service:
Environment="ASPNETCORE_URLS=http://localhost:5000"
# In myapp-b.service:
Environment="ASPNETCORE_URLS=http://localhost:5001"Start both services:
sudo systemctl start myapp-a myapp-bNow, when you need to deploy, publish your new code and restart one instance at a time:
sudo systemctl restart myapp-a
# Wait 30 seconds for it to be healthy
sudo systemctl restart myapp-bNginx automatically handles the transition. Existing requests stay on whichever instance they're connected to. New requests load-balance between the two. If one instance isn't ready yet, Nginx routes to the healthy one. I've used this exact approach for deployments on systems handling thousands of concurrent users without a single visible hiccup.
Common Mistakes
Binding Kestrel to 0.0.0.0 instead of localhost: If you bind to 0.0.0.0:5000, your app is exposed directly to the network without Nginx's protection. Always use http://localhost:5000.
Forgetting to use ForwardedHeaders middleware: Without it, your app sees all requests as plain HTTP from localhost. This breaks HTTPS redirects, security headers, and logging.
Not configuring Nginx gzip correctly: If you compress in both Nginx and your app, you waste CPU. Let Nginx handle it; Kestrel doesn't need to know about compression at all.
Using RestartSec=0 or not setting RestartSec: If your app crashes immediately on startup (e.g., database connection fails), systemd will restart it so fast it hammers your database. Use RestartSec=10 or higher to give your infrastructure time to recover.
Running the systemd service as root: This is a security risk. Always create a dedicated, unprivileged user.
Key Takeaways
- Nginx sits in front of Kestrel and handles TLS termination, gzip compression, and load balancing. Your app stays simple and focused on business logic.
- Use
ForwardedHeadersmiddleware to ensure your app sees the real client IP and knows when requests came in over HTTPS. - A systemd service with
Restart=alwaysand a dedicated user gives you automatic recovery and clean log integration without additional tooling. - For zero-downtime deployments, run two instances behind Nginx upstreams and restart them sequentially. Nginx automatically routes traffic to the healthy instance.
- Always bind Kestrel to localhost, never expose it directly to the network.
- Test your ForwardedHeaders configuration by checking
HttpContext.Connection.RemoteIpAddressin your logs—it should show the real client IP, not 127.0.0.1.






Comments
Loading comments…