Nginx from Zero to Production · Part 3 — Reverse Proxy & Load Balancing
Put Nginx in front of a real app: proxy_pass, the headers you must forward, upstream pools, load-balancing algorithms, health checks and failover, and WebSocket proxying — with a runnable Node demo and exercises.
So far Nginx has served files from disk {Đến giờ Nginx mới phục vụ file từ đĩa}. Now the real job: Nginx as a reverse proxy in front of your application {Giờ tới việc thật: Nginx làm reverse proxy đứng trước ứng dụng của bạn}. This is how almost every production Node, Python, Go, Rails, or Java app is exposed to the internet {Đây là cách gần như mọi app Node, Python, Go, Rails hay Java được đưa ra internet}.
We’ll also turn one proxy into a load balancer across several app instances {Ta cũng sẽ biến một proxy thành load balancer qua nhiều instance app}.
1. What “reverse proxy” means {“Reverse proxy” nghĩa là gì}
A forward proxy sits in front of clients (e.g. a corporate proxy hiding employees) {Forward proxy đứng trước client (vd proxy công ty che nhân viên)}. A reverse proxy sits in front of servers — the client thinks it’s talking to one server, but Nginx quietly relays to a backend behind it {Reverse proxy đứng trước server — client tưởng đang nói chuyện với một server, nhưng Nginx lặng lẽ chuyển tiếp tới backend phía sau}.
Browser ──HTTP──► NGINX :80 ──HTTP──► Node app :3000
◄──────── (public) ◄──────── (private, not exposed)
Why put Nginx in front of an app that can already speak HTTP? {Tại sao đặt Nginx trước một app vốn đã nói được HTTP?}
- TLS termination — Nginx handles HTTPS so your app stays simple (Part 4) {Đầu cuối TLS — Nginx xử lý HTTPS để app của bạn đơn giản (Phần 4)}.
- One public port — serve static files, multiple apps, and APIs under one domain {Một cổng public — phục vụ file tĩnh, nhiều app, và API dưới một domain}.
- Load balancing — fan out to many app instances {Cân bằng tải — toả ra nhiều instance app}.
- Protection — rate limiting, request size limits, hiding backend details {Bảo vệ — giới hạn tốc độ, giới hạn kích thước request, giấu chi tiết backend}.
2. A backend to proxy {Một backend để proxy}
Let’s create a tiny backend so the rest is concrete {Hãy tạo một backend nhỏ để phần sau cụ thể}. Any HTTP server works; here’s a dependency-free Node + TypeScript one {Server HTTP nào cũng được; đây là một cái Node + TypeScript không cần thư viện}:
// server.ts — run several copies on different ports to test load balancing
import { createServer } from 'node:http';
// PORT lets us launch the same code on 3000, 3001, 3002…
const port = Number(process.env.PORT ?? 3000);
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
// Echo the port so we can SEE which instance answered.
res.end(JSON.stringify({ from: port, path: req.url }));
});
server.listen(port, () => console.log(`backend up on :${port}`));
npx tsx server.ts # one instance on :3000
PORT=3001 npx tsx server.ts # in another terminal
PORT=3002 npx tsx server.ts # and another
3. proxy_pass — forward to the backend {proxy_pass — chuyển tiếp tới backend}
The minimal reverse proxy is one directive {Reverse proxy tối giản chỉ một directive}:
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://127.0.0.1:3000; # forward everything to the Node app
}
}
sudo nginx -t && sudo nginx -s reload
curl http://localhost/hello
# → {"from":3000,"path":"/hello"}
Nginx received the request on :80 and relayed it to the app on :3000 {Nginx nhận request ở :80 và chuyển tới app ở :3000}. The browser never sees :3000 {Trình duyệt không bao giờ thấy :3000}.
The trailing-slash gotcha {Cái bẫy dấu gạch chéo cuối}
This catches everyone {Cái này bẫy tất cả mọi người}:
location /api/ {
proxy_pass http://127.0.0.1:3000; # NO trailing slash → keeps /api/ prefix
# request /api/users → backend gets /api/users
}
location /api/ {
proxy_pass http://127.0.0.1:3000/; # WITH trailing slash → strips /api/
# request /api/users → backend gets /users
}
Rule {Quy tắc}: a trailing slash on proxy_pass strips the matched location prefix; no slash keeps it {dấu / cuối trên proxy_pass cắt bỏ prefix location đã khớp; không có dấu / thì giữ lại}. Choose based on whether your backend expects the /api prefix {Chọn tuỳ theo backend của bạn có mong đợi prefix /api hay không}.
4. The headers you MUST forward {Các header bạn BẮT BUỘC chuyển tiếp}
By default the backend loses information about the original request — it sees the connection coming from Nginx (127.0.0.1), not the real client {Mặc định backend mất thông tin về request gốc — nó thấy kết nối đến từ Nginx (127.0.0.1), không phải client thật}. Forward the truth explicitly {Chuyển tiếp sự thật một cách tường minh}:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host; # original Host header
proxy_set_header X-Real-IP $remote_addr; # real client IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # http or https
}
Why each matters {Vì sao mỗi cái quan trọng}:
Host— your app needs the real hostname for routing, links, cookies {app của bạn cần hostname thật để routing, tạo link, cookie}.X-Real-IP/X-Forwarded-For— without these, every visitor looks like127.0.0.1in your app’s logs and rate limiters {thiếu chúng, mọi khách trông như127.0.0.1trong log và rate limiter của app}.X-Forwarded-Proto— tells the app the user came over https even though Nginx→app is plain http; frameworks use this to build correct redirect URLs and setSecurecookies {báo cho app rằng user vào qua https dù Nginx→app là http thường; framework dùng cái này để tạo URL redirect đúng và đặt cookieSecure}.
A huge fraction of “redirect loop” and “wrong IP in logs” bugs come from forgetting these four lines {Một phần rất lớn lỗi “vòng lặp redirect” và “sai IP trong log” đến từ việc quên bốn dòng này}. Put them in a snippet you
includeeverywhere (we’ll do that in Part 5) {Đặt chúng vào một snippet rồiincludeở mọi nơi (ta sẽ làm ở Phần 5)}.
5. upstream — define a pool of backends {upstream — định nghĩa nhóm backend}
To load balance, name a group of servers with an upstream block, then proxy_pass to that name {Để cân bằng tải, đặt tên một nhóm server bằng block upstream, rồi proxy_pass tới tên đó}:
# in the http context, OUTSIDE server { }
upstream app_pool {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 80;
location / {
proxy_pass http://app_pool; # proxy to the pool, not one host
proxy_set_header Host $host;
}
}
sudo nginx -t && sudo nginx -s reload
curl http://localhost/ ; curl http://localhost/ ; curl http://localhost/
# → {"from":3000,...} {"from":3001,...} {"from":3002,...}
The from value rotates — Nginx is spreading requests across all three instances {Giá trị from xoay vòng — Nginx đang chia request qua cả ba instance}.
6. Load-balancing algorithms {Thuật toán cân bằng tải}
Choose how Nginx picks the next backend {Chọn cách Nginx chọn backend tiếp theo}:
upstream app_pool {
# round_robin; # DEFAULT — rotate evenly through servers
least_conn; # send to the server with fewest active connections
# ip_hash; # same client IP always → same server (sticky sessions)
server 127.0.0.1:3000 weight=3; # gets 3× the traffic (beefier box)
server 127.0.0.1:3001;
server 127.0.0.1:3002 backup; # only used when others are down
}
| Algorithm {Thuật toán} | When to use {Khi nào dùng} |
|---|---|
round_robin (default) | Stateless apps, evenly-sized requests {App stateless, request đều nhau} |
least_conn | Requests vary in duration (some slow, some fast) {Request dài ngắn khác nhau} |
ip_hash | You need session stickiness without shared session store {Cần dính phiên mà không có session store chung} |
Modifiers {Bổ ngữ}: weight=N sends proportionally more traffic to stronger servers; backup marks a standby; down takes one out without deleting the line {weight=N gửi nhiều traffic hơn theo tỉ lệ tới server mạnh; backup đánh dấu dự phòng; down loại một cái ra mà không xoá dòng}.
Prefer stateless backends + a shared session store (Redis) so any instance can serve any user — then you don’t need
ip_hashat all {Ưu tiên backend stateless + session store chung (Redis) để mọi instance phục vụ được mọi user — khi đó bạn chẳng cầnip_hash}.
7. Health checks & failover {Health check & chuyển dự phòng}
Open-source Nginx does passive health checks: if a backend fails too many times, it’s marked unavailable for a while {Nginx bản mã nguồn mở làm health check bị động: nếu một backend lỗi quá nhiều lần, nó bị đánh dấu không khả dụng một lúc}:
upstream app_pool {
server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
}
max_fails=3— after 3 failed attempts {sau 3 lần thử thất bại}…fail_timeout=10s— …stop sending traffic there for 10 seconds, then try again {…ngừng gửi traffic tới đó 10 giây, rồi thử lại}.
Test it live {Test trực tiếp}: kill the :3000 instance and keep curl-ing — after a couple of failures Nginx routes everything to the survivors, with no error shown to the user {tắt instance :3000 và cứ curl tiếp — sau vài lần lỗi Nginx dồn mọi thứ sang các cái còn sống, không hiện lỗi cho user}. (Active health checks — Nginx probing /health on a schedule — are an Nginx Plus feature {Health check chủ động — Nginx thăm dò /health theo lịch — là tính năng của Nginx Plus}.)
8. Timeouts, buffering & body size {Timeout, buffering & kích thước body}
Sensible proxy defaults you’ll want almost everywhere {Mặc định proxy hợp lý mà bạn sẽ muốn ở gần như mọi nơi}:
location / {
proxy_pass http://app_pool;
proxy_connect_timeout 5s; # max time to CONNECT to backend
proxy_send_timeout 60s; # max time to SEND the request
proxy_read_timeout 60s; # max time to wait for a response chunk
client_max_body_size 10m; # reject uploads bigger than 10 MB (413)
proxy_buffering on; # buffer the backend response (default on)
}
Two gotchas {Hai cái bẫy}:
- The default
client_max_body_sizeis 1 MB — file uploads larger than that fail with 413 Request Entity Too Large until you raise it {Mặc địnhclient_max_body_sizelà 1 MB — upload lớn hơn sẽ lỗi 413 Request Entity Too Large cho tới khi bạn nâng lên}. - For streaming responses (Server-Sent Events, live logs), turn
proxy_buffering off;in thatlocation, or Nginx will hold the stream and the client sees nothing until it ends {Với response streaming (Server-Sent Events, log trực tiếp), tắtproxy_buffering off;tronglocationđó, nếu không Nginx sẽ giữ luồng và client không thấy gì cho tới khi kết thúc}.
9. Proxying WebSockets {Proxy WebSocket}
WebSockets start as HTTP then upgrade the connection {WebSocket bắt đầu là HTTP rồi nâng cấp kết nối}. Nginx won’t forward the upgrade unless you tell it to {Nginx sẽ không chuyển tiếp việc nâng cấp trừ khi bạn bảo nó}:
location /ws/ {
proxy_pass http://app_pool;
proxy_http_version 1.1; # upgrades require HTTP/1.1
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; # the magic that allows ws://
proxy_read_timeout 3600s; # keep idle sockets alive (1h)
}
Forget the Upgrade/Connection headers and your WebSocket “connects then immediately closes” — a very common, very confusing bug {Quên header Upgrade/Connection thì WebSocket của bạn “kết nối rồi đóng ngay” — một lỗi rất phổ biến, rất khó hiểu}.
10. Recap {Tóm tắt}
proxy_passforwards a request to a backend; the trailing slash decides whether the location prefix is stripped {proxy_passchuyển request tới backend; dấu gạch chéo cuối quyết định prefix có bị cắt không}.- Always forward
Host,X-Real-IP,X-Forwarded-For,X-Forwarded-Proto{Luôn chuyển tiếpHost,X-Real-IP,X-Forwarded-For,X-Forwarded-Proto}. upstream+proxy_pass http://namegives you load balancing; pickround_robin/least_conn/ip_hash{upstream+proxy_pass http://namecho bạn cân bằng tải; chọnround_robin/least_conn/ip_hash}.max_fails+fail_timeoutgive passive failover {max_fails+fail_timeoutcho chuyển dự phòng bị động}.- Mind
client_max_body_size, streaming buffering, and WebSocket upgrade headers {Để ýclient_max_body_size, buffering cho streaming, và header nâng cấp WebSocket}.
Next — Part 4: TLS/HTTPS, security & performance {Tiếp — Phần 4: TLS/HTTPS, bảo mật & hiệu năng}: certificates, HTTP/2, gzip/brotli, caching, and rate limiting {chứng chỉ, HTTP/2, gzip/brotli, caching, và giới hạn tốc độ}.
Exercises {Bài tập}
- Proxy a real app {Proxy một app thật}: run the Node server on :3000 and proxy
/to it; confirmcurl http://localhost/returns the JSON from :3000 {chạy server Node ở :3000 và proxy/tới nó; xác nhậncurl http://localhost/trả JSON từ :3000}. - Trailing slash {Dấu gạch chéo cuối}: prove the difference between
proxy_pass http://127.0.0.1:3000;and.../;underlocation /api/by inspecting thepathin the JSON echo {chứng minh khác biệt giữaproxy_pass http://127.0.0.1:3000;và.../;dướilocation /api/bằng cách xempathtrong JSON echo}. - Load balance {Cân bằng tải}: run three instances (3000/3001/3002), define an
upstream, and watchfromrotate across 9 requests {chạy ba instance (3000/3001/3002), định nghĩaupstream, và xemfromxoay vòng qua 9 request}. - Failover {Chuyển dự phòng}: with
max_fails=2 fail_timeout=10s, kill one instance and confirm traffic shifts to the others with no client error {vớimax_fails=2 fail_timeout=10s, tắt một instance và xác nhận traffic chuyển sang các cái khác mà client không lỗi}. - Real client IP {IP client thật}: add the four
proxy_set_headerlines, logX-Forwarded-Forin your Node app, and confirm you see your real IP instead of127.0.0.1{thêm bốn dòngproxy_set_header, logX-Forwarded-Fortrong app Node, và xác nhận bạn thấy IP thật thay vì127.0.0.1}. - Stretch {Nâng cao}: set
client_max_body_size 1m, upload a 2 MB file, observe the 413, then raise the limit and succeed {đặtclient_max_body_size 1m, upload file 2 MB, quan sát 413, rồi nâng giới hạn và thành công}.