Load balancing means spreading traffic across multiple servers: better resource utilization, higher throughput, and no single machine getting knocked over. Nginx does both: Layer 7 (HTTP) works out of the box, Layer 4 (TCP/UDP) comes via the stream module. This post runs both kinds of load balancing at once in an nginx:1.28 container — the same set of backends, two entry points — and shows exactly where their distribution behavior and capability boundaries differ.

Lab Environment

  • Docker container nginx:1.28-alpine (nginx/1.28.3, nginx -V confirms --with-stream was compiled in, tested 2026-09).
  • Two “backends”: inside the same container, port 8081 returns APP-ONE, port 8082 returns APP-TWO — the response bodies carry identity, so who got the request is unmistakable.
  • The demo config uses worker_processes 1: with multiple workers each process keeps its own round-robin cursor, and rapid-fire requests concentrate on each worker’s first backend; a single worker makes the round-robin effect most visible.

Layer 7 Load Balancing: Only Something That Reads Content Can Route by Content

The standard trio inside the http block — the upstream backend group, the entry server, and the proxy pass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
http {
upstream myapp1 {
server 127.0.0.1:8081;
server 127.0.0.1:8082;
}
server {
listen 8097; # port 80 was already taken inside the container; switch back to 80 on a physical host
location / {
proxy_pass http://myapp1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

The three proxy_set_header lines are standard reverse-proxy moves: pass the original Host, the client’s real IP (X-Real-IP), and the forwarding chain (X-Forwarded-For) to the backend, so backend logs can record the true origin.

Verification point: request / repeatedly; distribution alternates between the two backends per the default round-robin:

Figure 1

Layer 7’s real killer feature is routing by content — add one location and pin /api/ traffic to a designated backend:

1
2
3
location /api/ {
proxy_pass http://127.0.0.1:8081/;
}

Verification point: requests to /api/x always hit APP-ONE, while other paths keep participating in the round-robin — URL-level routing, something only a Layer 7 proxy that parses HTTP can do:

Figure 2

Layer 4 Load Balancing: The stream Module Forwards by Connection

The stream block sits at the same level as the http block, and the config shape is almost identical:

1
2
3
4
5
6
7
8
9
10
11
stream {
upstream backend {
server 127.0.0.1:8081;
server 127.0.0.1:8082;
}
server {
listen 12345;
proxy_pass backend;
proxy_next_upstream off;
}
}

listen 12345 listens on a TCP port; Nginx doesn’t parse the content and forwards the whole TCP connection as-is to the backend group. proxy_next_upstream off disables retry-on-failure — for non-idempotent protocols like databases and message queues, a retry means re-execution, so it must be off.

Verification point: send HTTP requests to port 12345 (at the TCP layer it’s just ordinary traffic); distribution alternates between the two backends:

Figure 3

Note one detail: Layer 4 distributes at the granularity of a connection — the backend is chosen when the connection is established, and every request inside that connection follows it; Layer 7 distributes per request, so every HTTP request within the same connection can land on a different backend.

The Boundary Between Layer 4 and Layer 7

Two dividing lines emerge clearly from the tests:

  • Capability: Layer 4 sees only IP and port — fast forwarding, protocol-agnostic (MySQL, Redis, SSH all work); Layer 7 parses HTTP and can route finely by URL, Header, and Cookie — in the test, pinning /api/ to a designated backend is something Layer 4 architecturally cannot do.
  • Overhead: Layer 7 must parse and rewrite HTTP headers, costing extra CPU; Layer 4 follows a kernel-level forwarding philosophy with a higher single-machine throughput ceiling. TLS termination differs too: Layer 7 (HTTPS) decrypts at Nginx and forwards plaintext, while Layer 4 can only forward the encrypted stream as-is or be configured with stream_ssl.

Both entry points can also live in the same Nginx instance — this post’s experiment ran exactly that way, stream and http each minding their own port, no interference.

Notes and Caveats

  • stream is not a third-party module: it’s built in since 1.9.0; compile with --with-stream — the official Docker image includes it by default, verifiable with nginx -V.
  • Turn off retries for non-idempotent protocols: in stream scenarios, proxy_next_upstream off should be a reflex; connection-forwarding retry is a disaster for database-class traffic.
  • Remember to pass the real IP on Layer 7 forwarding: miss X-Real-IP/X-Forwarded-For and the backend forever sees Nginx’s IP — rate limiting and auditing all become distorted.
  • Multi-worker setups distort round-robin observation: each worker process maintains its own round-robin cursor, so rapid-fire requests skew toward each worker’s first backend; when verifying a load-balancing policy, pin worker_processes 1 or increase the sample size.

Wrapping Up

Back to the opening scenario: spreading traffic across multiple machines — one Nginx config handles both kinds of load balancing. The tested dividing lines are intuitive: Layer 7 sees URLs and can do content routing like sending /api/ to a dedicated backend, at the price of parsing overhead; Layer 4 only knows connections — fast and protocol-agnostic, the home of database and message-queue traffic. Remember three things — stream sits alongside http, non-idempotent traffic gets proxy_next_upstream off, and forwarding carries the real IP — and each kind of load balancing settles into its place.


This post was rewritten from the author’s CSDN blog articles originally published between 2020 and 2024 on CSDN.