Load Balancing Multiple Services with Nginx Reverse Proxy

Load Balancing Multiple Services with Nginx Reverse Proxy

When you're running a self-hosted homelab with multiple services—Nextcloud, Jellyfin, a dashboard, Vaultwarden, maybe an API service or two—routing them all through a single reverse proxy is non-negotiable. I prefer Nginx for this job because it's lightweight, predictable, and handles load balancing without breaking a sweat. Unlike Caddy (which I like for simplicity) or Traefik (which shines with Docker), Nginx gives me granular control over exactly how traffic flows, which upstream gets requests, and what happens when one service is slower than the others.

In this guide, I'll show you how to configure Nginx as a reverse proxy that not only routes traffic to multiple backends, but also distributes load between multiple instances of the same service. Whether you're running redundant Docker containers or actual separate VMs, this setup scales.

Why Load Balancing Matters in a Homelab

Most homelab tutorials stop at simple reverse proxying: route example.com/files to Nextcloud, example.com/media to Jellyfin, done. That works fine if each service runs once and never fails. But the moment you want to:

…you need load balancing. Nginx does this transparently. It's also the foundation if you ever graduate to a small VPS (around $40/year at providers like RackNerd) where you can host multiple applications and need proper traffic distribution.

Basic Nginx Upstream Configuration

Let me start with the simplest working example. I assume you have Nginx installed on a system running as a reverse proxy—this could be your home server, a Raspberry Pi, or a cheap VPS. I prefer keeping reverse proxy and backend services on separate machines, but Docker on the same host works too.

Here's the core config block for defining upstream servers:

upstream backend_nextcloud {
    server 192.168.1.100:8080;
    server 192.168.1.101:8080;
    server 192.168.1.102:8080;
}

upstream backend_jellyfin {
    server 192.168.1.103:8096;
    server 192.168.1.104:8096;
}

upstream api_services {
    server localhost:5000;
    server localhost:5001;
    server localhost:5002;
}

server {
    listen 80;
    server_name example.com;

    location /nextcloud {
        proxy_pass http://backend_nextcloud;
        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;
    }

    location /media {
        proxy_pass http://backend_jellyfin;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /api {
        proxy_pass http://api_services;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

By default, Nginx uses round-robin load balancing: request 1 goes to server 1, request 2 to server 2, request 3 to server 3, then back to server 1. This is simple and usually fine for homelabs.

Tip: Always include the proxy headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Proto). Your backend applications use these to know the real client IP and protocol. Without them, logs show all traffic coming from the proxy, and some apps (like Nextcloud) may get confused about HTTPS.

Load Balancing Methods: Round-Robin, Least Connections, and Weighted

Round-robin is the default, but Nginx supports other strategies. Choose based on your workload:

# Least connections: sends request to server with fewest active connections
upstream backend_nextcloud {
    least_conn;
    server 192.168.1.100:8080;
    server 192.168.1.101:8080;
    server 192.168.1.102:8080;
}

# IP hash: same client IP always routes to same server (sticky sessions)
upstream backend_jellyfin {
    ip_hash;
    server 192.168.1.103:8096;
    server 192.168.1.104:8096;
}

# Weighted round-robin: distribute unequally
# If one server is more powerful, give it more traffic
upstream api_services {
    server localhost:5000 weight=5;
    server localhost:5001 weight=3;
    server localhost:5002 weight=1;
}

# Random with two choices (least connections among them)
upstream backup_service {
    random two least_conn;
    server 192.168.1.50:9000;
    server 192.168.1.51:9000;
    server 192.168.1.52:9000;
}

I typically use least_conn for Nextcloud and media services because they hold connections longer. For stateless APIs, round-robin is fine. Use ip_hash sparingly—it can create uneven load distribution if your clients have different IP patterns.

Health Checks and Failover

Here's where it gets interesting. In the open-source Nginx (not Nginx Plus), passive health checks are built-in. Nginx automatically removes a server that returns errors:

upstream backend_critical {
    server 192.168.1.100:8080 max_fails=3 fail_timeout=30s;
    server 192.168.1.101:8080 max_fails=3 fail_timeout=30s;
    server 192.168.1.102:8080 backup;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://backend_critical;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        # Retry on upstream errors
        proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        proxy_next_upstream_tries 2;
        proxy_connect_timeout 5s;
    }
}

What's happening here:

Watch out: Passive health checks only catch errors on requests that come in. If a backend dies and nobody makes a request for 5 minutes, Nginx won't know. For production, consider active health checks (Nginx Plus only) or external monitoring like Uptime Kuma paired with a script that rewrites Nginx config.

Practical Homelab Example: Running Multiple Services with Docker Compose

Let me show you a real setup. I'm running three services (Nextcloud, Vaultwarden, and a simple API) in Docker containers on the same machine, and Nginx on the host proxying to all of them:

# docker-compose.yml
version: '3.9'
services:
  nextcloud_1:
    image: nextcloud:latest
    container_name: nextcloud_1
    ports:
      - "8001:80"
    environment:
      - NEXTCLOUD_TRUSTED_DOMAINS=example.com
    volumes:
      - /data/nextcloud:/var/www/html
    networks:
      - backend

  nextcloud_2:
    image: nextcloud:latest
    container_name: nextcloud_2
    ports:
      - "8002:80"
    environment:
      - NEXTCLOUD_TRUSTED_DOMAINS=example.com
    volumes:
      - /data/nextcloud:/var/www/html
    networks:
      - backend

  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    ports:
      - "8080:80"
    environment:
      - DOMAIN=https://example.com
    volumes:
      - /data/vaultwarden:/data
    networks:
      - backend

networks:
  backend:
    driver: bridge

And the Nginx config to load-balance those two Nextcloud instances while proxying Vaultwarden to a single backend:

upstream nextcloud_lb {
    least_conn;
    server localhost:8001 max_fails=2 fail_timeout=20s;
    server localhost:8002 max_fails=2 fail_timeout=20s;
}

upstream vaultwarden_svc {
    server localhost:8080;
}

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;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    # Root redirect
    location = / {
        return 301 https://$server_name/nextcloud;
    }

    # Nextcloud with load balancing
    location /nextcloud {
        proxy_pass http://nextcloud_lb;
        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_buffering off;
        proxy_request_buffering off;

        # Retry on errors
        proxy_next_upstream error timeout http_502 http_503;
        proxy_next_upstream_tries 2;
        proxy_connect_timeout 10s;
    }

    # Vaultwarden (single instance)
    location /vault {
        proxy_pass http://vaultwarden_svc;
        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;
    }
}

This config gives me:

Testing Your Load Balancer

After you've written your Nginx config, test it:

# Syntax check (catches config errors)
sudo nginx -t

# Reload config without dropping connections
sudo systemctl reload nginx

# Watch real-time access logs with upstream info
sudo tail -f /var/log/nginx/access.log | grep "upstream:"

# Use curl to simulate requests (watch which backend responds)
for i in {1..10}; do curl -s https://example.com/nextcloud | grep -i "server:" | head -1; done

Each request should alternate between your backends (or distribute according to your chosen method). If all 10 requests go to the same server, you may have session stickiness (ip_hash) or another issue.

Performance Tuning for Your Homelab

A few settings that make a difference:

# In http block (top of nginx.conf)
http {
    # Upstream connection pooling
    upstream_keepalive_connections 32;
    upstream_keepalive_requests 100;
    upstream_keepalive_timeout 60s;

    # Timeouts
    client_body_timeout 12;
    client_header_timeout 12;
    keepalive_timeout 15;

    # Buffering (adjust if proxying large files)
    proxy_buffer_size 4k;
    proxy_buffers 8 4k;
    proxy_busy_buffers_size 8k;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css text/