Load balancing .NET APIs with Nginx: a complete guide

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: bridge

Load balancing algorithms compared

AlgorithmNginx directiveBest forDrawback
Round-robin(default)Stateless APIs with equal server capacityIgnores server load
Weighted round-robinweight=NMixed-capacity server poolsStatic weight assignment
Least connectionsleast_connLong-lived requests (e.g. streaming)Slight overhead tracking state
IP haship_hashSession-affinity requirementsUneven distribution possible
Randomrandom two least_connLarge server pools with unpredictable loadNon-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:

ConcernWithout load balancerWith Nginx + .NET
AvailabilitySingle point of failureAutomatic failover to healthy instances
ThroughputBound to one serverLinear scale with added instances
TLS managementPer-instance certificatesCentralised at Nginx
DeploymentDowntime requiredZero-downtime rolling updates
SecurityApp exposed directlyNginx as hardened perimeter
CostVertical scaling onlyCheaper horizontal commodity hardware

Leave a Comment

Your email address will not be published. Required fields are marked *