Nginx 反向代理(reverse proxy)就是一個「對外收件、對內分送」的櫃檯:使用者只連到 Nginx 的 80/443,Nginx 再依網域或路徑把請求轉給後端的 Node.js、Python、Go 或 Docker 容器。好處是後端只聽 localhost、憑證與壓縮集中一處、多個服務共用一個 IP 與一組憑證。
這篇不談理論,直接給你可以貼上去跑的設定。從最小可用的 proxy_pass 開始,一路補到 WebSocket、SSE 串流、上傳大小、gzip、真實 IP、限流、快取與 HTTPS,最後給一張錯誤碼對照表和選型比較。
版本基準:本文語法以 nginx 1.31.x(mainline)/1.30.x(stable) 為準,2026-10-23 於 nginx.org 查詢。Debian/Ubuntu 套件版(
nginx/1.2x)同樣適用,只有 HTTP/2 的寫法要注意版本(見第 9 節)。
一、先搞懂三個 context 與檔案位置
Nginx 設定看起來亂,其實只有三層繼承關係:
| Context | 意思 | 常見設定 |
|---|---|---|
http | 全域 HTTP 行為 | gzip、limit_req_zone、proxy_cache_path、map |
server | 一個 virtual host(一組網域+port) | listen、server_name、ssl_certificate |
location | 某個路徑前綴的行為 | proxy_pass、client_max_body_size |
繼承規則只有一句話要記:子層沒寫才繼承父層。所以 proxy_set_header 只要在 location 寫了一次,就不會再繼承 server 或 http 的設定——這是最常見的「為什麼我的 header 不見了」來源。
檔案放哪裡(Debian/Ubuntu 慣例):
/etc/nginx/nginx.conf # 主檔,http {} 寫這裡
/etc/nginx/conf.d/*.conf # 自動載入,適合全域設定
/etc/nginx/sites-available/foo.conf # 站台設定(不自動載入)
/etc/nginx/sites-enabled/foo.conf # 連到上面的 symlink(自動載入)
改完一律先驗證再 reload,不要直接 restart:
sudo nginx -t && sudo systemctl reload nginx
nginx -t 會回 syntax is ok / test is successful。設定有錯時 reload 會失敗但舊設定繼續跑,這是 Nginx 的保護機制——所以「改了沒生效」十次有九次是 nginx -t 根本沒過。
二、最小可用的反向代理
假設後端是 127.0.0.1:3000 的 Node.js:
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
看起來只有五行 header,但每一行都在修一個真實的 bug:
| Header | Nginx 預設值 | 不設會怎樣 |
|---|---|---|
Host | $proxy_host(即 127.0.0.1:3000) | 後端看到的主機名是內部位址,多網域、絕對網址、CSRF origin 檢查、redirect 全部錯 |
X-Real-IP | 不送 | 後端只看到 127.0.0.1,登入紀錄、rate limit、地理位置全失效 |
X-Forwarded-For | 不送 | 多層代理時拿不到完整 IP 鏈;$proxy_add_x_forwarded_for 會把現有值加上 $remote_addr |
X-Forwarded-Proto | 不送 | 後端以為是 HTTP,產生 http:// 的 redirect 或 cookie 沒加 Secure,直接 infinite redirect |
$host 與 $http_host 的差別:$host 取 Host header 的網域部分(不含 port),欄位不存在時退回 server_name;$http_host 是原封不動的 Host header。九成情況用 $host。
proxy_pass 的尾斜線陷阱
這條規則會讓很多人卡一整個下午:
# A:結尾沒有斜線 → 原樣轉,後端收到 /api/users
location /api/ { proxy_pass http://127.0.0.1:3000; }
# B:結尾有斜線 → 砍掉 location 前綴,後端收到 /users
location /api/ { proxy_pass http://127.0.0.1:3000/; }
記法:proxy_pass 結尾有 / 就代表「把 location 那段吃掉」。 你的後端路由是 /users 就選 B,是 /api/users 就選 A。寫錯的症狀是後端回 404,但 curl 直打後端又正常。
三、WebSocket 與 SSE:長連線的兩個必改設定
一般請求 Nginx 收到就轉、轉完就關,但 WebSocket 與 SSE(Server-Sent Events)是不會結束的連線,預設值會直接把你的連線砍掉。
先在 http 層放一個 map(判斷客戶端有沒有要升級):
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
再套到 location:
# WebSocket(含 Socket.io)
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; # 必須,HTTP/1.0 不能升級
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# SSE(LLM 串流輸出、通知推送)
location /sse/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # 關鍵:關掉緩衝
proxy_cache off;
proxy_read_timeout 86400s;
chunked_transfer_encoding on;
}
三個關鍵字:
proxy_http_version 1.1:預設是 1.0,而 1.0 不支援 Upgrade 握手。少了這行,WebSocket 一定失敗。proxy_read_timeout:預設 60 秒。閒置超過 60 秒沒資料就斷線——聊天室、終端機、Agent 長任務全部中招。proxy_buffering off:預設 on。Nginx 會把後端回應緩衝起來,等累積夠多或回應結束才吐給瀏覽器;SSE 要的是「後端吐一個字就送一個字」,開著緩衝就變成「等很久然後一次全部出現」。
小技巧:如果不想改 Nginx,也可以在後端回應加上
X-Accel-Buffering: noheader,Nginx 看到會自動對該回應關閉緩衝。適合只有少數端點需要串流的情況。
Socket.io 另外要注意 session affinity:它先用 HTTP long-polling 再升級,多台後端時要加 ip_hash,否則輪詢打到不同機器會握手失敗。
四、413 Request Entity Too Large:上傳上限
預設值只有 1 MB,超過直接回 413。可以放在 http、server、location 任一層:
http {
client_max_body_size 32m; # 全站 32MB
server {
listen 80;
server_name app.example.com;
location /upload/ {
client_max_body_size 512m; # 只有這個路徑放寬
}
}
}
同一家族的三個常一起調的 directive:
| Directive | 預設 | 建議 | 用途 |
|---|---|---|---|
client_max_body_size | 1m | 依需求 | 上傳檔案總大小 |
client_body_buffer_size | 8k/16k | 128k | 超過就落地成暫存檔,太小會頻繁寫磁碟 |
client_body_timeout | 60s | 60s~120s | 讀取 request body 的閒置逾時,上傳慢的網路要放寬 |
五、gzip 與 brotli:預設其實什麼都沒壓
gzip 的預設值是 off,gzip_types 的預設只有 text/html——所以「我啟用 gzip 了怎麼還是沒效果」是月經題。
http {
gzip on;
gzip_vary on; # 預設 off,開著才會送 Vary: Accept-Encoding
gzip_proxied any; # 反向代理的回應也要壓
gzip_comp_level 6; # 預設 1;6 約有 75% 壓縮率但 CPU 只有 9 的一半
gzip_min_length 256; # 預設 20 bytes,太小的檔案壓了反而變大
gzip_types
text/plain text/css text/xml
text/javascript application/javascript
application/json application/xml
application/rss+xml application/atom+xml
image/svg+xml;
}
三個必記:
- 不要在
gzip_types裡寫text/html——Nginx 原始碼已預設包含,重複寫會噴 warning。 - 不要壓已經壓過的格式:JPEG、PNG、WebP、MP4、ZIP、PDF 都不該進清單。
gzip_comp_level從 6 拉到 9 只多 2–5% 壓縮率,CPU 卻是 3–4 倍。6 是甜蜜點。
想再進步就上 Brotli(比 gzip 多 15–25% 壓縮率,CPU 相近)。它是第三方模組,ngx_brotli 需自行編譯或安裝發行版套件;設定可與 gzip 並存,瀏覽器支援就用 brotli,不支援自動退回 gzip。Nginx 官方版沒有內建,別直接抄一段 brotli on; 就 reload,會噴 unknown directive。
六、躲在 Cloudflare 後面:怎麼拿到真人 IP
前面加了 CDN 或 WAF 之後,$remote_addr 全部變成 CDN 的 IP,你的 access log 與限流會一次廢掉。解法是 ngx_http_realip_module:
http {
# 只信任這些來源送來的「真實 IP」標頭
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 2400:cb00::/32;
# ...其餘 Cloudflare 網段
real_ip_header CF-Connecting-IP; # Cloudflare 專用;其他 CDN 多半是 X-Forwarded-For
real_ip_recursive on; # 多層代理時找第一個非信任 IP
}
三個重點:
- Cloudflare 用的是專屬 header
CF-Connecting-IP,不是X-Forwarded-For。抄到舊文章的real_ip_header X-Forwarded-For會拿到錯的值。 - Cloudflare 的網段會更新,要寫個 cron 每天抓
https://www.cloudflare.com/ips-v4與ips-v6產出設定檔,再nginx -s reload。 - 安全前提:只有來自
set_real_ip_from的請求,Nginx 才採信那個標頭。所以你的 origin 一定要擋掉非 Cloudflare 的直接連線,否則任何人都能自己偽造CF-Connecting-IP繞過限流。
七、限流:保護後端的第一道門
http {
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_status 429; # 預設 503,建議改成 429
limit_conn_status 429;
server {
location /api/ {
limit_conn conn_limit 10; # 同一 IP 最多 10 條同時連線
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
}
}
rate=10r/s:每秒 10 個請求(漏桶演算法,會把突發攤平)burst=20:排隊佇列容量,允許瞬間多 20 個nodelay:佇列內的請求立刻處理,不依照速率延遲;沒有它,第 11 個請求會被拖到下一秒才回limit_conn管同時連線數,limit_req管速率,兩者要一起用
一個容易忽略的點:limit_req_zone 用的是 $binary_remote_addr(4 bytes)而不是 $remote_addr(最多 15 bytes),同樣 10MB 記憶體可以記住兩倍以上的 IP。官方估算 1MB 約可存 8 千個 key。
八、反向代理快取:把後端壓力砍掉一個數量級
http {
proxy_cache_path /var/cache/nginx levels=1:2
keys_zone=my_cache:10m
inactive=60m max_size=1g;
server {
location / {
proxy_pass http://127.0.0.1:3000;
proxy_cache my_cache; # 預設 off
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating;
proxy_cache_lock on; # 同一 key 只放一個請求回源
add_header X-Proxy-Cache $upstream_cache_status;
}
}
}
inactive預設 10 分鐘,時間內沒被存取就從快取刪掉(不管過期沒)proxy_cache_valid只寫時間不寫狀態碼時,只對200/301/302生效X-Proxy-Cache會回HIT/MISS/BYPASS/EXPIRED,驗收就看這個 header,不要憑感覺- 快取目錄要先建好並給 nginx 使用者權限:
sudo mkdir -p /var/cache/nginx && sudo chown www-data: /var/cache/nginx
不適合快取:登入後的個人頁、帶 Authorization 的 API、有 CSRF token 的表單。這些請用 proxy_cache_bypass $http_authorization; 或直接關掉該 location 的快取。
九、HTTPS:certbot 三步,但 HTTP/2 寫法變了
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run # 驗證自動續約
systemctl list-timers | grep certbot # 確認 timer 存在
certbot 會自己改 Nginx 設定、加 443 server block 與 80→443 轉址。之後手動補上現代 TLS 設定:
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # 1.25.1 起的新寫法
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
HTTP/2 的踩雷點:網路上多數教學還寫 listen 443 ssl http2;。這個寫法從 1.25.1 起已被列為 deprecated,新寫法是獨立一行 http2 on;。用舊寫法在 1.31 上會噴 warning(還能跑),但設定檔會愈來愈髒。看到 http2 on; 就知道那份教學是新的。
HSTS 加上 preload 之前請想清楚:一旦瀏覽器記住,你的網域在有效期內只能用 HTTPS,憑證出錯就整站打不開。先在 max-age 用 300 秒測,穩定後再拉長。
十、Docker 場景:容器名稱直接當 upstream
services:
nginx:
image: nginx:1.31-alpine
ports: ["80:80", "443:443"]
volumes:
- ./conf.d:/etc/nginx/conf.d:ro
- ./certs:/etc/letsencrypt:ro
- ./www:/var/www/certbot:ro
depends_on: [app]
app:
image: myapp:latest
expose: ["3000"]
server {
listen 80;
server_name app.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot; # webroot 模式驗證
}
location / {
proxy_pass http://app:3000; # 用 service 名稱,Docker DNS 會解析
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
重點:Docker 內部 DNS 只在容器啟動時解析一次。app 容器重建換了 IP,Nginx 會繼續打舊 IP 直到 reload——這就是「明明服務正常,Nginx 一直 502」的經典原因。兩個解法:
- 設定
resolver 127.0.0.11 valid=10s;並把 upstream 寫成變數(set $upstream app:3000; proxy_pass http://$upstream;),強制每次重新解析 - 用 nginx 1.27.3 以後支援的
upstream+zone+server app:3000 resolve;
十一、錯誤碼對照表:先看 error.log 再改設定
| 狀態碼 | 意思 | 常見成因 | 排查指令 |
|---|---|---|---|
| 502 Bad Gateway | 連不上或收到無效回應 | 後端沒啟動、port 錯、socket 權限、容器換 IP、upstream sent too big header | tail -f /var/log/nginx/error.log、curl -I http://127.0.0.1:3000 |
| 504 Gateway Timeout | 後端處理太久 | proxy_read_timeout 60s 不夠(長報表、LLM 生成) | 加大 timeout 或改非同步任務 |
| 499 Client Closed Request | 客戶端先斷線(Nginx 專有) | 前端 timeout 太短、後端太慢、使用者關頁面 | 對照前端 fetch timeout 與後端 P95 |
| 413 Request Entity Too Large | body 超過上限 | client_max_body_size 預設 1m | 看 access log 的 request size |
| 400 Bad Request | 標頭過大或不合法 | large_client_header_buffers 太小(大 cookie) | 加大 buffer 到 8k–16k |
502 的 error.log 訊息可以直接定位病因:
| error.log 訊息 | 病因 |
|---|---|
connect() failed (111: Connection refused) | 後端根本沒在聽那個 port |
connect() failed (2: No such file or directory) | unix socket 路徑錯或不存在 |
connect() failed (13: Permission denied) | socket 權限或 SELinux |
upstream sent too big header | proxy_buffer_size / proxy_buffers 太小 |
upstream prematurely closed connection | 後端處理中崩潰(OOM、例外) |
養成兩個習慣:一是 error.log 用 tail -f 開著,二是在 browser 之外用 curl -I 直打後端 port 確認服務本身正常。先分清「後端壞」還是「Nginx 轉錯」,可以省掉一半時間。
十二、Nginx vs Caddy vs Traefik:什麼時候不該用 Nginx
| 比較項目 | Nginx | Caddy | Traefik |
|---|---|---|---|
| 自動 TLS | 需外部工具(certbot/acme.sh) | 內建,開箱自動申請續約 | 內建 ACME |
| 設定方式 | 自有區塊語法(nginx.conf) | Caddyfile,極簡;也支援 JSON API | YAML/TOML,或 Docker labels/K8s CRD |
| 動態服務發現 | 傳統靜態,改設定要 reload | 可用 API 動態配置 | 原生強項:監聽 Docker socket、K8s API 自動增減路由 |
| 生態與資源 | 最大,教學與模組最多 | 中,官方文件清楚 | 中,雲原生圈為主 |
| 適合誰 | 需要極致效能調校、精細控制、既有維運經驗 | 個人專案、小團隊、想少管事 | 微服務、容器大量增減、K8s |
判斷方式很簡單:
- 只有一兩台機器、服務不會天天增減 → Nginx 最穩,資源最多
- 不想碰憑證、想要一個 Caddyfile 解決 → Caddy 完整教學 2026 那篇有完整範例
- 容器一天到晚重開、服務幾十個 → Traefik 的 label 自動發現會救你
- 根本不想顧伺服器 → 直接走 Cloudflare Tunnel 完整教學 2026,連 port 都不用開
十三、七個最常見的設定地雷
- 改了沒生效 →
nginx -t沒過,reload 靜默失敗。永遠先驗證。 proxy_pass尾斜線 → 後端 404,但直打後端正常。proxy_set_header覆蓋繼承 → 在location寫了一行,父層的其他 header 全部失效。要嘛每層寫齊,要嘛用 include 檔。- WebSocket 60 秒斷線 → 忘了
proxy_read_timeout。 - SSE 卡住不動 → 忘了
proxy_buffering off。 - 上傳失敗 →
client_max_body_size留在 1m。 - CDN 後所有 IP 都一樣 → 沒設
real_ip_header,限流因此封掉所有人。
站內延伸閱讀
- Caddy 完整教學 2026:自動 HTTPS 網頁伺服器:同樣是反向代理,但憑證全自動,適合不想碰 certbot 的人
- Cloudflare Tunnel 完整教學 2026:不開防火牆 port 也能把自架服務搬上網,跟本文的 real_ip 章節剛好互補
- CrowdSec 完整教學 2026:開源 IDS/IPS+WAF:Nginx 前面的行為分析層,跟
limit_req是不同層次的防護 - Coolify 完整教學 2026:自架版 Vercel/Heroku:它會自動幫你產生 Nginx/Traefik 設定,看懂本文才知道它背地裡做了什麼
- Uptime Kuma 完整教學 2026:自架監控神器:反向代理上線後,第一個要接的就是它
資料來源與查證方法
- Nginx 官方 reverse proxy 指南:https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/(header 傳遞、buffer、
$proxy_add_x_forwarded_for行為) ngx_http_proxy_module官方指令參考:https://nginx.org/en/docs/http/ngx_http_proxy_module.html(proxy_read_timeout預設 60s、proxy_buffering預設 on、proxy_cache預設 off、inactive預設 10m)ngx_http_v2_module:https://nginx.org/en/docs/http/ngx_http_v2_module.html(官方範例已是listen 443 ssl;+http2 on;)- 版本號:https://nginx.org/en/download.html,2026-10-23 查詢為 mainline 1.31.8、stable 1.30.6
- 其餘設定值(gzip 預設值與建議、
client_max_body_size預設 1m、limit_req_status預設 503、CloudflareCF-Connecting-IP)經 NotebookLM fast 研究彙整 10 個來源交叉核對,並以官方文件為準
查證界線:本文所有設定皆為語法層級的正確性驗證與官方文件對照,未在你的環境實際部署;效能數字(gzip 壓縮率、CPU 倍數)為來源引用的通用量測值,實際結果依內容型態與硬體而異。上線前請務必 nginx -t,並先在 staging 環境驗證 WebSocket 與 SSE 行為。
