Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

10.1 Below HTTP: The Stream Module

NGINX's second career: the same master/worker engine, the same event loop — but the unit of work is no longer "a request". It's "a connection".

Levels: Advanced ~35 min read

Advanced

What you will learn

  1. What lives at Layer 4 and why you'd proxy it at all.

  2. The stream{} block: where it sits, what it shares with http{}, and what it cannot do.

  3. The session model — NGINX's mental shift from request lifecycle to connection lifecycle.

  4. The stream log vocabulary: $bytes_sent, $bytes_received, $session_time, $protocol — and what $status means when there is no HTTP.

  5. A pure-stream instance, started and read for the first time.


1. What lives below HTTP

Everything you've built so far assumed the client speaks HTTP — NGINX parsed method, URI, and headers, and that parsing is what made rewrites, caches, and access rules possible. But a huge part of real infrastructure doesn't speak HTTP:

Protocol

Transport

Why it's proxied at all

PostgreSQL / MySQL

TCP

connection pooling, TLS offload, read/write splits

MQTT / AMQP

TCP

protocol-aware load balancing, terminated connections

Redis Sentinel

TCP

port fronting, failover hides behind one address

SMTP / IMAP

TCP

greylisting, routing by hostname before the DATA phase

DNS

UDP (and TCP for large answers)

anycast-style fronting, rate limiting

Game/routing protocols

UDP

session routing, region steering

None of these have a Host: header. The only facts NGINX has at connect time are the 4-tuple: source IP, source port, destination IP, destination port. The stream module — available since 1.9.0 and present in official packages, but NOT built by default from source — --with-stream is the flag (check your Lesson 1.3 nginx -V receipt) — does for that world what http{} did for the web.

2. The block, and where it sits

stream{} is a sibling of http{} , not a child. This entire config makes the point — there is no http{} at all:

nginx
worker_processes  auto;
error_log  logs/error.log  warn;
pid        logs/nginx.pid;

events {
    worker_connections  1024;
}

stream {
    log_format  stream_log  '$remote_addr [$time_local] $protocol $status '
                            'sent=$bytes_sent received=$bytes_received '
                            'session=$session_time upstream=$upstream_addr';
    access_log  logs/stream.log  stream_log;

    # upstream pools, maps, and servers — same grammar you know,
    # smaller dictionary (no locations, no headers, no rewrites)
    server {
        listen       9200;
        proxy_pass   127.0.0.1:9060;
        proxy_timeout  30s;
    }
    # ...
}

What transfers over from http{} more or less unchanged:

  • the event-driven, multi-worker model (Lesson 1.4) — one worker handles thousands of idle sessions cheaply;

  • upstream{} pools with the familiar algorithms (next lesson);

  • map{}, variables, include, log formats with a stream-specific vocabulary, and the master's reload/upgrade dance.

What does not exist in stream{} — worth internalizing, because the config parser will remind you rudely otherwise:

  • no location, no server_name, no rewrite, no headers, no auth, no caching. You can't inspect what you refuse to parse.

  • listen has no ssl here unless you terminate TLS ( ssl_certificate in the stream server) — and Lesson 10.5 shows the third option: ssl_preread, peeking without terminating.

3. The session model

In http{}, the unit of work is the request; a keep-alive connection is an optimization holding many units. In stream{}, the connection is the unit — NGINX calls it a session: the lifetime of a client TCP connection (or, for UDP, a synthesized window — Lesson 10.4) bound to exactly one upstream connection.

Consequences that trip up HTTP people:

  1. A session pins one backend. Load balancing happens at connect time only. Five minutes of chat on one connection = five minutes on the same backend, no matter how unbalanced the pool gets.

  2. There is no per-request retry. If the upstream dies mid-session, the session dies. (Connect-time failures retry across the pool — next lesson's failure receipts show both cases.)

  3. The clock that matters is idle time. proxy_timeout (default 10m) is the session heartbeat: no data in either direction for that long and NGINX closes both sides. It is not an HTTP timeout.

4. Reading the stream log

The log_format stream_log output below is real, from a live capture run — TCP and UDP, success and failure:

text
127.0.0.1 [23/Sep/2026:00:39:09 +0000] TCP 200 sent=27 received=13 session=0.000 upstream=127.0.0.1:9060
127.0.0.1 [23/Sep/2026:00:39:09 +0000] TCP 200 sent=17 received=3 session=0.001 upstream=127.0.0.1:9061
127.0.0.1 [23/Sep/2026:00:38:10 +0000] UDP 200 sent=19 received=4 session=10.005 upstream=127.0.0.1:9070
127.0.0.1 [23/Sep/2026:00:34:46 +0000] TCP 502 sent=0 received=0 session=0.000 upstream=127.0.0.1:9061, 127.0.0.1:9060

Decoding the vocabulary — none of it is HTTP:

Field

Meaning at Layer 4

$protocol

TCP or UDP — the transport, not an application protocol

$status

session status: 200 = completed normally, 502 = upstream connect failed (the receipt above shows a retry chain — two addresses tried), others per failure mode

$bytes_sent / $bytes_received

from the client's perspective: sent to client, received from client

$session_time

wall-clock seconds the session lived — 0.000 for quick echo exchanges, 10.005 for the UDP session that lived out its timeout window

$upstream_addr

which backend (or chain of backends, comma-separated on retries) served the session

Note the direction convention on sent/ received — it matches the http{} access log's client-centric view, and it's the difference you'll use when capacity-planning a database proxy: received is what applications sent you.

5. First contact

Start the pure-stream config and look around — this is a complete NGINX with no HTTP side at all:

bash
$ nginx -t
nginx: the configuration file /etc/nginx/nginx.conf test is successful

$ ss -tln | grep -E '920[0-3]|9243'
LISTEN 0      511          0.0.0.0:9200     0.0.0.0:*
LISTEN 0      511          0.0.0.0:9201     0.0.0.0:9200:*    # (truncated)
LISTEN 0      511          0.0.0.0:9202     0.0.0.0:*
LISTEN 0      511          0.0.0.0:9203     0.0.0.0:*
LISTEN 0      511          0.0.0.0:9243     0.0.0.0:*

$ ss -uln | grep 921
UNCONN 0      0          0.0.0.0:9210      0.0.0.0:*
UNCONN 0      0          0.0.0.0:9211      0.0.0.0:*

TCP listeners and UDP "listeners" ( UNCONN — UDP sockets are unconnected by nature) side by side. The simplest possible L4 hop is three directives, and the round trip works (real capture):

bash
$ printf 'hello layer4\n' | timeout 3 bash -c \
    'exec 3<>/dev/tcp/127.0.0.1/9200; cat >&3; head -1 <&3'
b-tcp-echo-1: hello layer4

Bytes in, bytes out — NGINX never looked at the content. The session it just served is already in logs/stream.log, in the vocabulary you learned above.


Try it yourself

  • Start it: nginx

  • Read: tail /var/log/nginx/stream.log after each experiment; try tail -f /var/log/nginx/error.log in another terminal while you connect.

Q&A

Q: Can one NGINX run http{} and stream{} together? A: Yes — they're siblings in the same nginx.conf, and that's the normal production shape (one binary fronting web traffic and databases). This lesson keeps them separate so you feel that stream stands alone.

Q: What happened to worker_connections semantics? A: Same directive, but now it counts sessions (two FDs each: client + upstream) instead of client connections. Your fd math from Lesson 9.1 doubles per session.

Q: Does stream{} do access control? A: Yes — allow/ deny work here (same grammar as Lesson 6.5). What doesn't work is anything needing to read the payload.

Takeaways

  • stream{} is a sibling of http{}; it shares the engine, pools, maps, and reload machinery — and has no locations, rewrites, or headers, because it never parses the application protocol.

  • The unit of work is the session: one client connection bound to one upstream connection, scheduled once, pinned for life.

  • The log vocabulary is session-based: $protocol, session- $status ( 200/ 502), client-perspective byte counters, $session_time, and retry-revealing $upstream_addr chains.

  • proxy_timeout is the idle clock — the stream module's most-tuned production knob for long-lived protocols.

Was this page helpful?

Mark this learning resource complete to track your learning progress.