Layer 4 vs Layer 7 Load Balancing: Nginx Configuration Demonstrated
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 -Vconfirms--with-streamwas compiled in, tested 2026-09). - Two “backends”: inside the same container, port 8081 returns
APP-ONE, port 8082 returnsAPP-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 | http { |
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:

Layer 7’s real killer feature is routing by content — add one location and pin /api/ traffic to a designated backend:
1 | location /api/ { |
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:

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 | stream { |
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:

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 withnginx -V. - Turn off retries for non-idempotent protocols: in stream scenarios,
proxy_next_upstream offshould 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-Forand 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 1or 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.