Nginx 反向代理完整教學 2026:proxy_pass、WebSocket、gzip 到 HTTPS

Nginx 反向代理(reverse proxy)是把外部請求轉給後端應用的閘門:對外只開 80/443,Node.js、FastAPI、Docker 容器都藏在後面。本文用可直接貼上的設定帶你走完 10 個實戰場景:最小 proxy_pass、四個必設 header、WebSocket 與 SSE 串流、413 上傳上限、gzip 調校、Cloudflare 後的真人 IP、limit_req 限流、proxy_cache 快取、certbot 自動 HTTPS 與 HTTP/2(1.25.1 起改成 http2 on)。另附 502/504/499/413 錯誤對照表、Nginx vs Caddy vs Traefik 選型比較,以及 7 個最常見的設定地雷。

  • Dennis
  • 12 分鐘閱讀
Nginx 反向代理完整教學 2026:proxy_pass、WebSocket、gzip 到 HTTPS

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:

HeaderNginx 預設值不設會怎樣
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: no header,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_size1m依需求上傳檔案總大小
client_body_buffer_size8k/16k128k超過就落地成暫存檔,太小會頻繁寫磁碟
client_body_timeout60s60s~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;
}

三個必記:

  1. 不要在 gzip_types 裡寫 text/html——Nginx 原始碼已預設包含,重複寫會噴 warning。
  2. 不要壓已經壓過的格式:JPEG、PNG、WebP、MP4、ZIP、PDF 都不該進清單。
  3. 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」的經典原因。兩個解法:

  1. 設定 resolver 127.0.0.11 valid=10s; 並把 upstream 寫成變數(set $upstream app:3000; proxy_pass http://$upstream;),強制每次重新解析
  2. 用 nginx 1.27.3 以後支援的 upstream + zone + server app:3000 resolve;

十一、錯誤碼對照表:先看 error.log 再改設定

狀態碼意思常見成因排查指令
502 Bad Gateway連不上或收到無效回應後端沒啟動、port 錯、socket 權限、容器換 IP、upstream sent too big headertail -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 Largebody 超過上限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 headerproxy_buffer_size / proxy_buffers 太小
upstream prematurely closed connection後端處理中崩潰(OOM、例外)

養成兩個習慣:一是 error.log 用 tail -f 開著,二是在 browser 之外用 curl -I 直打後端 port 確認服務本身正常。先分清「後端壞」還是「Nginx 轉錯」,可以省掉一半時間。

十二、Nginx vs Caddy vs Traefik:什麼時候不該用 Nginx

比較項目NginxCaddyTraefik
自動 TLS需外部工具(certbot/acme.sh)內建,開箱自動申請續約內建 ACME
設定方式自有區塊語法(nginx.conf)Caddyfile,極簡;也支援 JSON APIYAML/TOML,或 Docker labels/K8s CRD
動態服務發現傳統靜態,改設定要 reload可用 API 動態配置原生強項:監聽 Docker socket、K8s API 自動增減路由
生態與資源最大,教學與模組最多中,官方文件清楚中,雲原生圈為主
適合誰需要極致效能調校、精細控制、既有維運經驗個人專案、小團隊、想少管事微服務、容器大量增減、K8s

判斷方式很簡單:

  • 只有一兩台機器、服務不會天天增減 → Nginx 最穩,資源最多
  • 不想碰憑證、想要一個 Caddyfile 解決 → Caddy 完整教學 2026 那篇有完整範例
  • 容器一天到晚重開、服務幾十個 → Traefik 的 label 自動發現會救你
  • 根本不想顧伺服器 → 直接走 Cloudflare Tunnel 完整教學 2026,連 port 都不用開

十三、七個最常見的設定地雷

  1. 改了沒生效 → nginx -t 沒過,reload 靜默失敗。永遠先驗證。
  2. proxy_pass 尾斜線 → 後端 404,但直打後端正常。
  3. proxy_set_header 覆蓋繼承 → 在 location 寫了一行,父層的其他 header 全部失效。要嘛每層寫齊,要嘛用 include 檔。
  4. WebSocket 60 秒斷線 → 忘了 proxy_read_timeout。
  5. SSE 卡住不動 → 忘了 proxy_buffering off。
  6. 上傳失敗 → client_max_body_size 留在 1m。
  7. CDN 後所有 IP 都一樣 → 沒設 real_ip_header,限流因此封掉所有人。

站內延伸閱讀

資料來源與查證方法

查證界線:本文所有設定皆為語法層級的正確性驗證與官方文件對照,未在你的環境實際部署;效能數字(gzip 壓縮率、CPU 倍數)為來源引用的通用量測值,實際結果依內容型態與硬體而異。上線前請務必 nginx -t,並先在 staging 環境驗證 WebSocket 與 SSE 行為。

📬 訂閱 most.tw 電子報

每週精選 AI 工具教學與技術乾貨,直接送到你的信箱。免費、隨時可退訂。

訂閱即表示同意收到 most.tw 電子報,隨時可一鍵退訂。

💬 有問題想討論?加 LINE 聯絡我

歡迎透過 LINE 官方帳號直接留言,我會盡快回覆你的問題。

加入 LINE 好友