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".
Advanced
What you will learn
What lives at Layer 4 and why you'd proxy it at all.
The
stream{}block: where it sits, what it shares withhttp{}, and what it cannot do.The session model — NGINX's mental shift from request lifecycle to connection lifecycle.
The stream log vocabulary:
$bytes_sent,$bytes_received,$session_time,$protocol— and what$statusmeans when there is no HTTP.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:
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, noserver_name, norewrite, no headers, noauth, no caching. You can't inspect what you refuse to parse.listenhas nosslhere unless you terminate TLS (ssl_certificatein 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:
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.
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.)
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:
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:9060Decoding the vocabulary — none of it is HTTP:
Field | Meaning at Layer 4 |
|---|---|
|
|
|
session status:
|
| from the client's perspective: sent to client, received from client |
| wall-clock seconds the session lived —
|
| 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:
$ 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):
$ 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 layer4Bytes 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:
nginxRead:
tail /var/log/nginx/stream.logafter each experiment; trytail -f /var/log/nginx/error.login 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 ofhttp{}; 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_addrchains.proxy_timeoutis the idle clock — the stream module's most-tuned production knob for long-lived protocols.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.