What is load balancing?
Load balancing distributes incoming network traffic across multiple application servers to ensure no single server bears too much load. It is a foundational pattern for building systems that are highly available, horizontally scalable, and resilient to failure.
In a modern .NET microservices architecture, Nginx serves as the entry point — acting as a reverse proxy that accepts all client requests and forwards them intelligently to a pool of upstream .NET application instances.
High-level architecture: client → Nginx → .NET instances
Client browser
→
Nginx (port 80/443)
↓
ASP.NET API :5001
ASP.NET API :5002
ASP.NET API :5003
Why use Nginx with .NET?
High throughput
Nginx handles tens of thousands of concurrent connections with minimal memory using an event-driven, non-blocking architecture.
TLS termination
Nginx offloads SSL/TLS processing from your .NET apps, reducing CPU load and centralizing certificate management.
Horizontal scaling
Add or remove .NET instances at runtime without downtime. Nginx health checks automatically remove unhealthy upstream nodes.
Rate limiting & security
Protect .NET backends from traffic spikes and DDoS with built-in Nginx rate limiting and connection throttling directives.
Observability
Centralised access logs, upstream timing headers, and health-check endpoints give full visibility across all application instances.
Zero-downtime deploys
Rolling deployments update one upstream at a time while Nginx continues serving traffic from healthy instances.
Step 1 — Setting up the .NET API
Create a minimal ASP.NET Core Web API that is aware of its own port, which is critical for diagnosing load balancer behaviour during development.
Program.cs
C#Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Read forwarded headers from Nginx reverse proxy
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
var app = builder.Build();
app.UseForwardedHeaders();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapGet("/health", () => Results.Ok(new
{
status = "healthy",
instance = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "default",
timestamp = DateTime.UtcNow
}));
app.MapGet("/api/data", (IHttpContextAccessor ctx) =>
{
var clientIp = ctx.HttpContext?.Connection.RemoteIpAddress?.ToString();
return Results.Ok(new
{
message = "Response from instance",
instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID"),
clientIp,
utc = DateTime.UtcNow
});
});
app.Run();Step 2 — Nginx upstream configuration
Configure Nginx’s upstream block to define the pool of .NET instances and choose a load balancing algorithm. The default is round-robin, but you can switch to least-connections or IP-hash based on your use case.
nginx.conf
Nginx/etc/nginx/nginx.conf
events {
worker_connections 1024;
}
http {
# Define the upstream pool of .NET API instances
upstream dotnet_api {
# Algorithm: round_robin (default) — remove comment for others
# least_conn; — use for long-lived connections
# ip_hash; — sticky sessions by client IP
server dotnet_app1:5001 weight=3;
server dotnet_app2:5002 weight=2;
server dotnet_app3:5003 weight=1;
# Health check: mark server down after 3 failures in 30s
keepalive 32;
}
server {
listen 80;
server_name api.yourdomain.com;
# Rate limiting: 100 req/s per IP
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
location /api/ {
proxy_pass http://dotnet_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
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_cache_bypass $http_upgrade;
# Timeout configuration
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
limit_req zone=api_limit burst=20 nodelay;
}
location /health {
proxy_pass http://dotnet_api/health;
access_log off;
}
}
}Step 3 — Docker Compose orchestration
Use Docker Compose to spin up multiple .NET API instances and connect them to the Nginx load balancer within the same network. Each instance receives a unique INSTANCE_ID environment variable for identification.
YAMLdocker-compose.yml
version: '3.9'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- dotnet_app1
- dotnet_app2
- dotnet_app3
networks:
- api_network
dotnet_app1:
build: .
environment:
- INSTANCE_ID=APP-1
- ASPNETCORE_URLS=http://+:5001
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
interval: 10s
timeout: 5s
retries: 3
networks:
- api_network
dotnet_app2:
build: .
environment:
- INSTANCE_ID=APP-2
- ASPNETCORE_URLS=http://+:5002
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5002/health"]
interval: 10s
timeout: 5s
retries: 3
networks:
- api_network
dotnet_app3:
build: .
environment:
- INSTANCE_ID=APP-3
- ASPNETCORE_URLS=http://+:5003
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5003/health"]
interval: 10s
timeout: 5s
retries: 3
networks:
- api_network
networks:
api_network:
driver: bridgeLoad balancing algorithms compared
| Algorithm | Nginx directive | Best for | Drawback |
|---|---|---|---|
| Round-robin | (default) | Stateless APIs with equal server capacity | Ignores server load |
| Weighted round-robin | weight=N | Mixed-capacity server pools | Static weight assignment |
| Least connections | least_conn | Long-lived requests (e.g. streaming) | Slight overhead tracking state |
| IP hash | ip_hash | Session-affinity requirements | Uneven distribution possible |
| Random | random two least_conn | Large server pools with unpredictable load | Non-deterministic |
Step 4 — Health check middleware in .NET
Integrate ASP.NET Core’s built-in health check framework to expose a structured endpoint that Nginx (and orchestrators like Kubernetes) can probe. This allows automatic removal of degraded instances from the rotation.
C#HealthCheckExtensions.cs
public static class HealthCheckExtensions
{
public static IServiceCollection AddApplicationHealthChecks(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddHealthChecks()
.AddSqlServer(
connectionString: configuration.GetConnectionString("DefaultConnection")!,
name: "database",
failureStatus: HealthStatus.Degraded,
tags: new[] { "db", "sql" })
.AddRedis(
configuration["Redis:ConnectionString"]!,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: new[] { "cache" })
.AddCheck("self", () => HealthCheckResult.Healthy("API is running"));
return services;
}
public static WebApplication MapApplicationHealthChecks(this WebApplication app)
{
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
Predicate = _ => true
});
// Liveness probe — used by Nginx / Kubernetes
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("self")
});
// Readiness probe — only mark ready when DB is reachable
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("db")
});
return app;
}
}Step 5 — TLS termination at Nginx
Offload HTTPS to Nginx so .NET instances communicate internally over plain HTTP. This reduces overhead on each application process and centralises certificate rotation.
Nginxnginx-tls.conf (server block)
server {
listen 443 ssl http2;
server_name api.yourdomain.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# HSTS — tell clients to always use HTTPS
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://dotnet_api;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Redirect all HTTP to HTTPS
server {
listen 80;
server_name api.yourdomain.com;
return 301 https://$host$request_uri;
}Use Certbot with the Nginx plugin (certbot --nginx -d api.yourdomain.com) to automate Let’s Encrypt certificate provisioning and renewal. This eliminates manual certificate rotation entirely in most production setups.
Benefits summary
Combining Nginx load balancing with .NET gives you a production-grade architecture with the following concrete advantages:
| Concern | Without load balancer | With Nginx + .NET |
|---|---|---|
| Availability | Single point of failure | Automatic failover to healthy instances |
| Throughput | Bound to one server | Linear scale with added instances |
| TLS management | Per-instance certificates | Centralised at Nginx |
| Deployment | Downtime required | Zero-downtime rolling updates |
| Security | App exposed directly | Nginx as hardened perimeter |
| Cost | Vertical scaling only | Cheaper horizontal commodity hardware |
