Stream Production Lab
Learn NGINX In Action - Part 090
Production lab for NGINX stream TCP/UDP edge proxying with PROXY protocol, TLS passthrough, SNI routing, structured logs, smoke tests, and failure drills.
Part 090 — Stream Production Lab
Pada bagian ini kita membangun lab Layer 4 production-style dengan NGINX stream.
Targetnya bukan sekadar “TCP forwarding berhasil”. Targetnya adalah punya edge proxy yang:
- bisa meneruskan TCP service,
- bisa meneruskan UDP service,
- bisa melakukan TLS passthrough berbasis SNI,
- bisa menerima/meneruskan PROXY protocol,
- punya structured stream logs,
- punya smoke test yang jelas,
- punya failure drill,
- dan punya boundary yang tidak menipu operator.
Kita akan membangun lab dengan tiga jalur traffic:
1. TCP echo-like service
2. UDP echo-like service
3. TLS passthrough by SNI
Selain itu kita akan menyiapkan jalur PROXY protocol supaya kamu melihat perbedaan antara:
client IP yang connect ke NGINX
client IP yang diklaim/dibawa oleh PROXY protocol
upstream yang menerima koneksi dari NGINX
1. Architecture
We will run everything locally with Docker Compose.
This lab intentionally uses simple echo/demo services so the NGINX behavior is visible without database-specific complexity.
2. Directory layout
nginx-stream-lab/
docker-compose.yml
nginx/
nginx.conf
stream.d/
00-log.conf
10-upstreams.conf
20-tcp.conf
30-udp.conf
40-tls-passthrough.conf
50-proxy-protocol.conf
certs/
tls-a.crt
tls-a.key
tls-b.crt
tls-b.key
services/
tcp_server.py
udp_server.py
tls_server.py
proxy_protocol_server.py
scripts/
gen-certs.sh
smoke.sh
Why this layout?
nginx.confowns process/global config.stream.downs Layer 4 config fragments.- each traffic type has one file.
- logs are centralized in
00-log.conf. - certs are generated for lab-only TLS backends.
- services are intentionally small and observable.
3. Docker Compose
Create docker-compose.yml:
services:
nginx:
image: nginx:1.29-alpine
container_name: nginx-stream-lab
depends_on:
- tcp-a
- tcp-b
- udp-a
- tls-a
- tls-b
- proxy-aware
ports:
- "9000:9000/tcp"
- "9001:9001/udp"
- "9443:9443/tcp"
- "9100:9100/tcp"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/stream.d:/etc/nginx/stream.d:ro
- ./logs:/var/log/nginx
command: ["nginx", "-g", "daemon off;"]
tcp-a:
image: python:3.12-alpine
container_name: tcp-a
volumes:
- ./services:/services:ro
command: ["python", "/services/tcp_server.py", "0.0.0.0", "7000", "tcp-a"]
tcp-b:
image: python:3.12-alpine
container_name: tcp-b
volumes:
- ./services:/services:ro
command: ["python", "/services/tcp_server.py", "0.0.0.0", "7000", "tcp-b"]
udp-a:
image: python:3.12-alpine
container_name: udp-a
volumes:
- ./services:/services:ro
command: ["python", "/services/udp_server.py", "0.0.0.0", "7001", "udp-a"]
tls-a:
image: python:3.12-alpine
container_name: tls-a
volumes:
- ./services:/services:ro
- ./certs:/certs:ro
command: ["python", "/services/tls_server.py", "0.0.0.0", "7443", "tls-a", "/certs/tls-a.crt", "/certs/tls-a.key"]
tls-b:
image: python:3.12-alpine
container_name: tls-b
volumes:
- ./services:/services:ro
- ./certs:/certs:ro
command: ["python", "/services/tls_server.py", "0.0.0.0", "7443", "tls-b", "/certs/tls-b.crt", "/certs/tls-b.key"]
proxy-aware:
image: python:3.12-alpine
container_name: proxy-aware
volumes:
- ./services:/services:ro
command: ["python", "/services/proxy_protocol_server.py", "0.0.0.0", "7100", "proxy-aware"]
Version pin note:
- Use an NGINX image available in your environment.
- The lab assumes
stream,stream_ssl_preread, and stream logging support are available in the image. - Validate with
nginx -Vinside the container.
docker compose exec nginx nginx -V
4. Demo TCP server
Create services/tcp_server.py:
import socket
import sys
import threading
import time
host = sys.argv[1]
port = int(sys.argv[2])
name = sys.argv[3]
def handle(conn, addr):
print(f"[{name}] connection from {addr}", flush=True)
with conn:
conn.sendall(f"hello from {name}\n".encode())
while True:
data = conn.recv(4096)
if not data:
break
reply = f"{name} echo: ".encode() + data
conn.sendall(reply)
print(f"[{name}] closed {addr}", flush=True)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
s.listen(128)
print(f"[{name}] listening on {host}:{port}", flush=True)
while True:
conn, addr = s.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
This is enough to observe:
- NGINX connection forwarding,
- upstream load balancing,
- bytes both directions,
- short/long sessions.
5. Demo UDP server
Create services/udp_server.py:
import socket
import sys
host = sys.argv[1]
port = int(sys.argv[2])
name = sys.argv[3]
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.bind((host, port))
print(f"[{name}] listening on udp {host}:{port}", flush=True)
while True:
data, addr = s.recvfrom(4096)
print(f"[{name}] datagram from {addr}: {data!r}", flush=True)
s.sendto(f"{name} echo: ".encode() + data, addr)
UDP has no connection handshake. We use this service to test datagram forwarding and timeout behavior.
6. Demo TLS backend server
Create services/tls_server.py:
import socket
import ssl
import sys
import threading
host = sys.argv[1]
port = int(sys.argv[2])
name = sys.argv[3]
cert = sys.argv[4]
key = sys.argv[5]
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=cert, keyfile=key)
def handle(conn, addr):
print(f"[{name}] tls connection from {addr}", flush=True)
with conn:
conn.sendall(f"hello from {name} over tls\n".encode())
while True:
data = conn.recv(4096)
if not data:
break
conn.sendall(f"{name} tls echo: ".encode() + data)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as raw:
raw.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
raw.bind((host, port))
raw.listen(128)
print(f"[{name}] tls listening on {host}:{port}", flush=True)
while True:
client, addr = raw.accept()
try:
tls = context.wrap_socket(client, server_side=True)
threading.Thread(target=handle, args=(tls, addr), daemon=True).start()
except Exception as e:
print(f"[{name}] tls error from {addr}: {e}", flush=True)
client.close()
NGINX will not terminate TLS in the passthrough route. It will only inspect ClientHello SNI with ssl_preread.
7. Demo PROXY protocol-aware server
Create services/proxy_protocol_server.py:
import socket
import sys
import threading
host = sys.argv[1]
port = int(sys.argv[2])
name = sys.argv[3]
def read_line(conn):
data = b""
while not data.endswith(b"\r\n") and len(data) < 512:
b = conn.recv(1)
if not b:
break
data += b
return data
def handle(conn, addr):
print(f"[{name}] connection from tcp peer {addr}", flush=True)
with conn:
header = read_line(conn)
print(f"[{name}] first line: {header!r}", flush=True)
if header.startswith(b"PROXY "):
conn.sendall(b"proxy protocol received\n")
else:
conn.sendall(b"no proxy protocol header\n")
while True:
data = conn.recv(4096)
if not data:
break
conn.sendall(f"{name} echo: ".encode() + data)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
s.listen(128)
print(f"[{name}] listening on {host}:{port}", flush=True)
while True:
conn, addr = s.accept()
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
This server demonstrates whether NGINX is sending a PROXY protocol header to upstream.
8. Generate lab certificates
Create scripts/gen-certs.sh:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
-subj "/CN=tls-a.example.test" \
-addext "subjectAltName=DNS:tls-a.example.test" \
-keyout certs/tls-a.key \
-out certs/tls-a.crt
openssl req -x509 -newkey rsa:2048 -nodes -days 30 \
-subj "/CN=tls-b.example.test" \
-addext "subjectAltName=DNS:tls-b.example.test" \
-keyout certs/tls-b.key \
-out certs/tls-b.crt
Run:
chmod +x scripts/gen-certs.sh
./scripts/gen-certs.sh
These are lab-only self-signed certs.
9. Main NGINX config
Create nginx/nginx.conf:
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
stream {
include /etc/nginx/stream.d/*.conf;
}
No http block is needed for this lab.
The point is to isolate the stream subsystem.
10. Stream log config
Create nginx/stream.d/00-log.conf:
map $server_port $stream_service {
9000 tcp_echo;
9001 udp_echo;
9443 tls_passthrough;
9100 proxy_protocol_demo;
default unknown;
}
map $ssl_preread_server_name $stream_route {
tls-a.example.test tls_a;
tls-b.example.test tls_b;
default unknown_sni;
}
log_format stream_json escape=json
'{'
'"ts":"$time_iso8601",'
'"service":"$stream_service",'
'"route":"$stream_route",'
'"protocol":"$protocol",'
'"status":"$status",'
'"session_time":$session_time,'
'"remote_addr":"$remote_addr",'
'"remote_port":"$remote_port",'
'"proxy_protocol_addr":"$proxy_protocol_addr",'
'"proxy_protocol_port":"$proxy_protocol_port",'
'"server_addr":"$server_addr",'
'"server_port":"$server_port",'
'"upstream_addr":"$upstream_addr",'
'"bytes_sent":$bytes_sent,'
'"bytes_received":$bytes_received,'
'"upstream_bytes_sent":$upstream_bytes_sent,'
'"upstream_bytes_received":$upstream_bytes_received,'
'"sni":"$ssl_preread_server_name",'
'"alpn":"$ssl_preread_alpn_protocols",'
'"tls_preread_protocol":"$ssl_preread_protocol"'
'}';
access_log /var/log/nginx/stream-access.json stream_json buffer=64k flush=2s;
This gives a single log format for all stream routes.
11. Upstream config
Create nginx/stream.d/10-upstreams.conf:
upstream tcp_echo_pool {
server tcp-a:7000 max_fails=2 fail_timeout=10s;
server tcp-b:7000 max_fails=2 fail_timeout=10s;
}
upstream udp_echo_pool {
server udp-a:7001 max_fails=2 fail_timeout=10s;
}
upstream tls_a_backend {
server tls-a:7443 max_fails=2 fail_timeout=10s;
}
upstream tls_b_backend {
server tls-b:7443 max_fails=2 fail_timeout=10s;
}
upstream reject_backend {
# Port 9 discard is normally closed in this lab; it creates a clear failure for unknown SNI.
server 127.0.0.1:9;
}
upstream proxy_aware_backend {
server proxy-aware:7100 max_fails=2 fail_timeout=10s;
}
Production note:
reject_backendis a lab shortcut.- In real production, you may route unknown SNI to a controlled reject service or close the connection intentionally.
- Avoid silently routing unknown SNI to a real tenant.
12. TCP proxy route
Create nginx/stream.d/20-tcp.conf:
server {
listen 9000;
proxy_connect_timeout 2s;
proxy_timeout 30s;
proxy_pass tcp_echo_pool;
}
Behavior:
- Client connects to
localhost:9000. - NGINX selects
tcp-aortcp-b. - NGINX forwards raw TCP bytes.
- Access log is written when the session closes.
Test:
printf 'ping\n' | nc localhost 9000
Expected output:
hello from tcp-a
# or hello from tcp-b
tcp-a echo: ping
Depending on timing, output order may differ slightly.
13. UDP proxy route
Create nginx/stream.d/30-udp.conf:
server {
listen 9001 udp reuseport;
proxy_timeout 10s;
proxy_responses 1;
proxy_pass udp_echo_pool;
}
Behavior:
- Client sends UDP datagram to
localhost:9001. - NGINX forwards datagram to
udp-a:7001. - NGINX forwards one response datagram back.
- Session expires by timeout.
Test with nc if your local version supports UDP:
echo 'hello udp' | nc -u -w2 localhost 9001
Expected output:
udp-a echo: hello udp
If nc -u behaves differently on your OS, use Python:
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.sendto(b'hello udp', ('127.0.0.1', 9001))
print(s.recvfrom(4096))
PY
14. TLS passthrough route by SNI
Create nginx/stream.d/40-tls-passthrough.conf:
map $ssl_preread_server_name $tls_passthrough_backend {
tls-a.example.test tls_a_backend;
tls-b.example.test tls_b_backend;
default reject_backend;
}
server {
listen 9443;
ssl_preread on;
proxy_connect_timeout 2s;
proxy_timeout 60s;
proxy_pass $tls_passthrough_backend;
}
Key point:
NGINX does not decrypt TLS here. It only reads the TLS ClientHello enough to extract SNI and route.
Test route A:
openssl s_client -connect localhost:9443 -servername tls-a.example.test -quiet -ign_eof
Type:
hello
Expected:
hello from tls-a over tls
tls-a tls echo: hello
Test route B:
openssl s_client -connect localhost:9443 -servername tls-b.example.test -quiet -ign_eof
Expected:
hello from tls-b over tls
Test unknown SNI:
openssl s_client -connect localhost:9443 -servername unknown.example.test -brief
Expected:
connection failure or handshake failure
Then check log:
tail -f logs/stream-access.json
You should see fields like:
{"service":"tls_passthrough","route":"tls_a","sni":"tls-a.example.test","upstream_addr":"172.x.x.x:7443"}
15. PROXY protocol route
Create nginx/stream.d/50-proxy-protocol.conf:
server {
listen 9100;
proxy_connect_timeout 2s;
proxy_timeout 30s;
# Send PROXY protocol to the upstream server.
proxy_protocol on;
proxy_pass proxy_aware_backend;
}
This route demonstrates NGINX sending PROXY protocol to upstream.
Test:
printf 'hello\n' | nc localhost 9100
Expected:
proxy protocol received
proxy-aware echo: hello
Check backend logs:
docker compose logs proxy-aware
You should see first line similar to:
PROXY TCP4 172.x.x.x 172.x.x.x 12345 7100\r\n
Important distinction:
- This route does not require the client to send PROXY protocol.
- NGINX creates and sends PROXY protocol to the backend.
- If you also want NGINX to receive PROXY protocol from a previous load balancer, listener config is different.
16. Receiving PROXY protocol from previous hop
If a cloud LB sends PROXY protocol to NGINX, listener must expect it:
server {
listen 9101 proxy_protocol;
proxy_pass proxy_aware_backend;
proxy_protocol on;
}
Then logs should include:
$remote_addr = previous hop IP
$proxy_protocol_addr = original client IP from PROXY header
Do not expose a PROXY-protocol listener directly to arbitrary clients. A normal client that does not send the PROXY line will fail.
Trust invariant:
Only enable
listen ... proxy_protocolon ports reachable exclusively by trusted upstream proxies/load balancers.
17. Start the lab
Run:
mkdir -p logs
./scripts/gen-certs.sh
docker compose up -d
Validate NGINX config:
docker compose exec nginx nginx -t
Show effective config:
docker compose exec nginx nginx -T
Show compile flags:
docker compose exec nginx nginx -V
Confirm ports:
docker compose ps
18. Smoke test script
Create scripts/smoke.sh:
#!/usr/bin/env bash
set -euo pipefail
printf '\n== TCP ==\n'
printf 'ping tcp\n' | nc -w2 localhost 9000 || true
printf '\n== UDP ==\n'
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.sendto(b'ping udp', ('127.0.0.1', 9001))
print(s.recvfrom(4096)[0].decode(errors='replace'))
PY
printf '\n== TLS A ==\n'
printf 'ping tls a\n' | openssl s_client -connect localhost:9443 -servername tls-a.example.test -quiet 2>/dev/null || true
printf '\n== TLS B ==\n'
printf 'ping tls b\n' | openssl s_client -connect localhost:9443 -servername tls-b.example.test -quiet 2>/dev/null || true
printf '\n== PROXY to upstream ==\n'
printf 'ping proxy\n' | nc -w2 localhost 9100 || true
printf '\n== Recent stream logs ==\n'
tail -n 10 logs/stream-access.json || true
Run:
chmod +x scripts/smoke.sh
./scripts/smoke.sh
19. Expected log analysis
After smoke test:
tail -n 20 logs/stream-access.json | jq .
You should see records grouped by service:
{
"service": "tcp_echo",
"route": "unknown_sni",
"protocol": "TCP",
"status": "200",
"session_time": 0.002,
"upstream_addr": "172.20.0.3:7000",
"bytes_sent": 18,
"bytes_received": 9
}
For TLS passthrough:
{
"service": "tls_passthrough",
"route": "tls_a",
"protocol": "TCP",
"sni": "tls-a.example.test",
"upstream_addr": "172.20.0.5:7443"
}
For UDP:
{
"service": "udp_echo",
"protocol": "UDP",
"upstream_addr": "172.20.0.4:7001"
}
Do not obsess over exact byte counts; different client tools add newline/close differently.
20. Failure drill 1 — one TCP backend down
Stop tcp-a:
docker compose stop tcp-a
Run TCP smoke multiple times:
for i in $(seq 1 10); do printf "ping $i\n" | nc -w2 localhost 9000 || true; done
Observe:
tail -n 20 logs/stream-access.json | jq 'select(.service=="tcp_echo")'
docker compose logs nginx
Expected:
- some attempts may fail while passive failure state is learned,
- traffic should use remaining backend after failure marking,
- error log should reveal connect failure/refused/timeout depending shutdown state.
Restore:
docker compose start tcp-a
Lesson:
Passive health check is reactive. The first client(s) may pay the failure cost.
21. Failure drill 2 — wrong SNI
Run:
openssl s_client -connect localhost:9443 -servername wrong.example.test -brief
Inspect logs:
tail -n 10 logs/stream-access.json | jq 'select(.service=="tls_passthrough")'
Expected:
{
"route": "unknown_sni",
"sni": "wrong.example.test",
"upstream_addr": "127.0.0.1:9"
}
Lesson:
Unknown SNI must be observable. Silent fallback to a real backend is a tenant isolation risk.
22. Failure drill 3 — no SNI client
Run:
openssl s_client -connect localhost:9443 -brief
Because no -servername is supplied, SNI may be empty.
Expected log:
{
"sni": "",
"route": "unknown_sni"
}
Decision you must make in production:
Do we support no-SNI legacy clients?
If yes, which backend is safe default?
If no, do we reject clearly and monitor volume?
23. Failure drill 4 — PROXY protocol mismatch
Modify route temporarily:
server {
listen 9100 proxy_protocol;
proxy_connect_timeout 2s;
proxy_timeout 30s;
proxy_protocol on;
proxy_pass proxy_aware_backend;
}
Reload:
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
Test with normal client:
printf 'hello\n' | nc -w2 localhost 9100
Expected:
- connection fails,
- error log complains about invalid/broken PROXY protocol header.
Rollback the config to listen 9100;.
Lesson:
listen ... proxy_protocolchanges the wire protocol expected from the previous hop. It is not a harmless metadata flag.
24. Failure drill 5 — UDP backend down
Stop UDP backend:
docker compose stop udp-a
Run:
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
s.sendto(b'ping udp', ('127.0.0.1', 9001))
try:
print(s.recvfrom(4096))
except Exception as e:
print(type(e).__name__, e)
PY
Observe logs and error log:
tail -n 20 logs/stream-access.json | jq 'select(.service=="udp_echo")'
docker compose logs nginx
Restore:
docker compose start udp-a
Lesson:
UDP failure may show as client timeout, not clean connection failure. Packet capture and backend counters are often necessary.
25. Reload safety
Validate before reload:
docker compose exec nginx nginx -t
Reload:
docker compose exec nginx nginx -s reload
Watch logs:
docker compose logs -f nginx
For stream traffic, reload behavior matters for long-lived connections. Existing workers may continue handling old sessions until they drain, while new workers accept new config. During config changes, ask:
Will old long-lived TCP sessions stay on old route?
Can we tolerate mixed config during drain?
Do we need connection draining at outer LB?
Do clients reconnect automatically?
For database/protocol stateful traffic, deployment should be more careful than stateless HTTP.
26. Capacity checks
Inside NGINX container:
cat /proc/1/limits
On host:
docker stats
ss -ntp | grep 9000
Production capacity variables:
worker_connections
worker_processes
open file limit
upstream max connections
backend connection limit
kernel listen backlog
NAT/conntrack table
UDP socket buffers
log IO throughput
Stream proxy can exhaust file descriptors quickly because each proxied TCP session uses at least one client-side and one upstream-side socket.
Approximate FD model:
required_fd ≈ active_tcp_sessions * 2 + listening_sockets + logs + overhead
If you expect 50k active TCP sessions, a tiny ulimit -n 1024 is not production-ready.
27. Security review
Before using a stream proxy pattern in production:
[ ] Are all exposed ports intentional?
[ ] Is unknown SNI rejected or routed safely?
[ ] Are PROXY protocol listeners reachable only from trusted hops?
[ ] Are backend services protected from direct public access?
[ ] Is TLS passthrough compatible with required inspection/auth policy?
[ ] Are real client IP assumptions documented?
[ ] Are logs free from sensitive payload?
[ ] Are packet-capture procedures restricted?
[ ] Are idle timeouts aligned with client/backend/LB?
[ ] Are long-lived sessions considered during deploy/reload?
Remember:
TLS passthrough protects backend-owned TLS but prevents NGINX from enforcing HTTP-level security policy.
If you need WAF, HTTP auth, header normalization, or URI routing, you need HTTP/TLS termination somewhere before that policy.
28. Production hardening version of TCP route
A more explicit route:
upstream postgres_pool {
zone postgres_pool 64k;
server 10.0.20.11:5432 max_fails=2 fail_timeout=10s max_conns=500;
server 10.0.20.12:5432 max_fails=2 fail_timeout=10s max_conns=500;
}
server {
listen 5432;
proxy_connect_timeout 2s;
proxy_timeout 1h;
proxy_socket_keepalive on;
proxy_pass postgres_pool;
}
But do not blindly use proxy_timeout 1h.
Ask:
- Is the protocol long-lived?
- Does backend have idle timeout?
- Does client pool reconnect safely?
- Will reload/drain be delayed?
- Does this expose too many backend connections?
29. Production hardening version of TLS passthrough
map $ssl_preread_server_name $tls_backend {
app-a.example.com app_a_tls;
app-b.example.com app_b_tls;
default reject_tls;
}
upstream app_a_tls {
zone app_a_tls 64k;
server 10.0.30.11:443 max_fails=2 fail_timeout=10s;
server 10.0.30.12:443 max_fails=2 fail_timeout=10s;
}
upstream app_b_tls {
zone app_b_tls 64k;
server 10.0.31.11:443 max_fails=2 fail_timeout=10s;
server 10.0.31.12:443 max_fails=2 fail_timeout=10s;
}
upstream reject_tls {
server 127.0.0.1:9;
}
server {
listen 443;
ssl_preread on;
proxy_connect_timeout 2s;
proxy_timeout 10m;
proxy_pass $tls_backend;
}
Observability must include:
SNI
ALPN
upstream
session_time
unknown SNI count
empty SNI count
30. Production hardening version of UDP route
upstream dns_pool {
zone dns_pool 64k;
server 10.0.40.11:53 max_fails=2 fail_timeout=10s;
server 10.0.40.12:53 max_fails=2 fail_timeout=10s;
}
server {
listen 53 udp reuseport;
proxy_timeout 5s;
proxy_responses 1;
proxy_pass dns_pool;
}
For DNS, also consider:
- TCP fallback for large responses.
- EDNS buffer size.
- MTU/fragmentation behavior.
- cache/recursive resolver policy.
- rate limiting at network edge.
NGINX stream can proxy UDP. It does not make UDP reliable.
31. CI validation
At minimum, CI should run:
nginx -t -c /path/to/nginx.conf
For generated configs:
[ ] every stream server has access logging via inherited stream log
[ ] no unknown public listen ports
[ ] no PROXY protocol listener on public CIDR
[ ] every SNI map has default behavior
[ ] every upstream has explicit max_fails/fail_timeout policy
[ ] every long-lived route documents timeout intent
[ ] every TLS passthrough route logs SNI
[ ] every UDP route documents proxy_responses/proxy_timeout
A config that passes nginx -t can still be operationally unsafe. Add semantic checks.
32. Lab cleanup
docker compose down -v
rm -rf logs
Keep the files if you want to turn this into a reusable local regression lab.
33. What this lab proves
This lab proves:
- NGINX can proxy TCP through
stream. - NGINX can proxy UDP through
stream. - NGINX can inspect TLS ClientHello with
ssl_prereadand route by SNI without terminating TLS. - NGINX can send PROXY protocol to upstream.
- Stream logs can provide session-level observability.
- Failure drills reveal the boundaries of passive health checks, SNI routing, UDP behavior, and PROXY protocol mismatch.
It does not prove:
- database-level query correctness,
- business transaction success,
- transparent client identity preservation for arbitrary backend protocols,
- HTTP-layer security policy under TLS passthrough,
- packet-loss-free UDP delivery,
- active health checking in Open Source NGINX.
Those require separate architecture decisions.
34. Final mental model for Phase 7
The stream module turns NGINX into a Layer 4 proxy/load balancer.
That power is useful, but the abstraction is lower-level than HTTP.
HTTP proxy: route and inspect requests.
Stream proxy: route and relay byte streams/datagrams.
When you choose stream, you gain:
- protocol independence,
- TCP/UDP proxying,
- TLS passthrough,
- SNI-based L4 routing,
- database/custom-protocol edge patterns.
You lose or reduce:
- HTTP header visibility,
- URI routing,
- WAF/header auth control,
- request-level metrics,
- application status insight.
A top-tier engineer does not ask “can NGINX forward this port?” only.
They ask:
What identity is preserved?
What policy can still be enforced?
What telemetry remains visible?
What failure does the client see?
What happens during reload?
What happens under overload?
What evidence will we have at 03:00 during an incident?
That is the production difference.
References
- NGINX official documentation —
ngx_stream_core_module - NGINX official documentation —
ngx_stream_proxy_module - NGINX official documentation —
ngx_stream_log_module - NGINX official documentation —
ngx_stream_ssl_preread_module - NGINX official documentation —
ngx_stream_ssl_module - NGINX official documentation —
ngx_stream_realip_module - F5 NGINX documentation — TCP/UDP Load Balancing
- F5 NGINX documentation — Accepting the PROXY Protocol
You just completed lesson 90 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.