每次在自家機器上多開一個 Docker 服務,就要 ssh 進去 vim /etc/nginx/conf.d/xxx.conf,加 upstream、加 server、nginx -t、systemctl reload nginx。這套流程跑久了,腦袋會自動把它編譯成肌肉記憶——但只要中間哪個變數打錯,整台 reverse proxy 就會 502,然後在 log 裡找半天問題出在哪個 server block。
容器這層本來就主打「即開即關」,IP 會變、port 會變、實例會橫向擴展,再用人工去同步 nginx conf 完全是反模式。docker socket 上的事件流原本就帶有所有需要的資訊:哪個容器起來了、暴露了哪個 port、屬於哪個網路、帶了哪些環境變數。把這些事件當成輸入訊號自動算出一份 nginx conf,這就是 nginx-proxy 在做的事。
這篇整理一下它的運作機制、實際 docker-compose 用法、HTTPS 自動化(搭配 acme-companion)、以及跟 Traefik 的取捨。Repo 累積到 25k 顆星,原始作者 jwilder 後來把專案移交給社群維護,現在是個更新節奏穩定的純社群專案,連帶把姐妹專案 letsencrypt-nginx-proxy-companion 改名叫 acme-companion,背後的 ACME client 從原生 lego 換成更輕量的 acme.sh。
一、痛點:傳統 Nginx 反向代理的維運成本
把容器藏在 Nginx 後面這個架構大家都會寫,但真正煩的是「容器拓撲一直在變」這件事:
- 新增一個服務 → 寫
server { ... proxy_pass http://127.0.0.1:3000; } - 容器重啟 → 如果是 bridge 網路,IP 重新分配,proxy_pass 失效
- 橫向加一個實例 → 改
upstream加新節點 - 換 domain → 找出對應的 server 區塊修改
- 上 HTTPS → certbot 申請、放憑證路徑、改 ssl_certificate、設 cron 自動續期
每一步單獨看都不難,全部加起來就變成「每次部署都得 30 分鐘起跳」。我自己過去在內部測試環境上踩過幾次坑:因為手動 reload nginx 漏掉某條設定,新版服務的流量轉錯地方,要從 access log 比對 5 分鐘前的舊 vhost 才發現問題。事後檢討起來,根本原因不是 nginx 本身難用,而是「容器拓撲是動態的、conf 是靜態的」這個本質落差。
更現實的問題是 CI/CD 流水線:每個 PR 想開一個 preview URL,難道也要在 pipeline 裡 ssh 進機器寫 conf?這不該是 2026 年的 DevOps 該做的事。即便用 Ansible 去管 nginx conf,也只是把改 conf 的動作從手動換成 declarative,整個 reload 流程依然跟容器事件脫鉤。
二、nginx-proxy 怎麼運作
整個專案其實是兩個元件組合而成:一個是真正提供 HTTP 服務的 Nginx,另一個是叫做 docker-gen 的工具。docker-gen 才是核心,它的歷史比 nginx-proxy 更早,原本就是一個「把 docker 容器狀態渲染成任意配置檔」的通用工具,nginx-proxy 只是它最知名的應用場景。
2.1 docker-gen 監聽容器事件
docker-gen 的工作很單純:把它指向 /var/run/docker.sock,它就會持續監聽 Docker daemon 發出的事件流(container start / stop / die / health-status / ...)。每當事件觸發,它把當下所有容器的 metadata(環境變數、labels、暴露的 port、所屬網路、IP)灌進一份 Go template,渲染出新的 nginx.conf。
這個 template 才是 nginx-proxy 真正的「魔法」所在。模板會掃過每個容器,挑出帶有 VIRTUAL_HOST 環境變數的那些,依照 host name 分群,產生對應的 server 與 upstream 區塊。把同一個 VIRTUAL_HOST 對應到多個容器時,nginx 會自動把它們放進同一個 upstream 用 round-robin 分流——擴容變成「再多開一個 container 就好」,conf 完全不用碰。
支援的環境變數比想像中豐富:
| 環境變數 | 用途 |
|---|---|
VIRTUAL_HOST |
對外的 domain,可逗號分隔多個 |
VIRTUAL_PORT |
後端容器 listen 的 port,沒填時自動偵測 expose |
VIRTUAL_PATH |
把容器掛到指定 URL path(/api/),支援 regex |
VIRTUAL_PROTO |
後端協議:http / https / uwsgi / fastcgi |
VIRTUAL_DEST |
反代時改寫 path,/ 表示剝掉 VIRTUAL_PATH |
HTTPS_METHOD |
是否強制 redirect(redirect / noredirect / nohttps / nohttp) |
HSTS |
Strict-Transport-Security header 內容 |
debug 時最常用的兩招:
# 看渲染後的真正 nginx.conf(包含所有 include)
docker exec nginx-proxy nginx -T
# 看 docker-gen 即時的容器狀態
docker exec nginx-proxy curl -s localhost/nginx-proxy-debug
-T 出來的 conf 沒問題、實際請求卻 502 時,幾乎都是 docker network 沒共用,或 VIRTUAL_PORT 跟容器實際 listen 的 port 對不上。
2.2 SIGHUP reload 機制
模板渲染完不會直接 kill nginx 重來——那會中斷現有連線。docker-gen 改用 SIGHUP 通知 nginx 進程:nginx 收到後會 fork 新的 worker 載入新 conf,舊 worker 處理完手上的請求才退出。對線上連線來說是無感的優雅切換,這個機制是 nginx 從 1.x 時代就有的設計,nginx-proxy 沒有重新發明任何東西,只是把觸發訊號從「人手 kill -HUP」換成「容器事件自動觸發」。
實作上有兩種佈署方式。一種是「all-in-one」,nginx-proxy 官方 image 把 nginx 跟 docker-gen 包在同一個容器裡,預設就跑著 supervisor 同時管兩個 process。另一種是「分離模式」,docker-gen 跑單獨容器,透過 -notify-sighup nginx 參數對 nginx 容器發訊號,適合需要客製 nginx image 的場景,例如要塞自家編譯的 ngx_brotli、ngx_http_geoip2_module 之類的第三方模組。
我自己跑的是 all-in-one,省事,而且絕大多數情境下夠用。要客製 nginx 行為直接掛 vhost.d 就能解決,沒必要拆。真的要拆的話官方文件有完整範例,三個容器(nginx、docker-gen、acme-companion)各自獨立、用 volumes_from 串起來。
2.3 與 Traefik 的差異
很多人會問:「現在不是都用 Traefik 了嗎?」Traefik 確實是更現代的方案,但兩者目標不太一樣。
| 面向 | nginx-proxy | Traefik |
|---|---|---|
| 路由產生方式 | docker-gen 渲染 nginx.conf | Traefik 內建 dynamic config |
| 配置介面 | 容器環境變數 | 容器 labels 或檔案 |
| 觀察性 | 標準 nginx access log | 內建 dashboard / metrics |
| 擴展性 | Go template 客製,學習成本中 | middleware plugin 系統 |
| 後端引擎 | nginx,效能與生態成熟 | Go 自寫 HTTP server |
| 適合場景 | 單機 / 小規模 swarm,重視 nginx 熟悉度 | 多 provider(k8s + docker + consul) |
簡單講:如果機器上跑的就是「一台 Linux 加幾十個容器」,nginx-proxy 的學習曲線跟維運成本都比較友善;要是已經在 k8s 上、或同時要對接 Consul / etcd 做服務發現,Traefik 才會發揮價值。另一個 nginx-proxy 沒被取代的理由是:很多公司內部已經有大量 nginx 配置經驗、log 分析工具、WAF 規則都圍繞 nginx 寫,換 Traefik 等於整套生態要重建。
三、快速啟動
3.1 單一 docker run
最小可運作版本只需要一行:
docker run --detach \
--name nginx-proxy \
--publish 80:80 \
--volume /var/run/docker.sock:/tmp/docker.sock:ro \
nginxproxy/nginx-proxy:1.10
幾個關鍵點:
:ro是必要的安全習慣,nginx-proxy 只需要讀 socket,不該有寫入權限- 鎖版號用
1.10,不要用latest(官方 docs 也明確警告過) - Alpine 版叫
1.10-alpine,image 小一些但邊緣案例少測試 - 想用 IPv6 的話加
-e ENABLE_IPV6=true,記得 docker daemon 也要先打開 IPv6
接著任何想被代理的容器只要帶上 VIRTUAL_HOST:
docker run --detach \
--name myapp \
--expose 3000 \
--env VIRTUAL_HOST=app.example.com \
your-app-image
注意是 --expose 不是 -p。-p 會把 port 直接映射到宿主機,反而繞過了 nginx-proxy;--expose 只是宣告容器內哪個 port 在 listen,給同一個 docker 網路裡的其他容器用。這是新手最常踩的坑,沒有之一。
3.2 docker-compose 完整範例
實務上我都用 compose 管理,配置可讀性高也好版控。下面這份是搭配 acme-companion 一起跑的完整範例:
version: "3.8"
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:1.10
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs
- vhost:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
networks:
- proxy
restart: always
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
volumes_from:
- nginx-proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- acme:/etc/acme.sh
environment:
- [email protected]
networks:
- proxy
restart: always
myapp:
image: nginx:alpine
container_name: myapp
expose:
- "80"
environment:
- VIRTUAL_HOST=app.example.com
- LETSENCRYPT_HOST=app.example.com
networks:
- proxy
restart: always
networks:
proxy:
name: proxy
volumes:
certs:
vhost:
html:
acme:
跑 docker compose up -d 後,DNS 把 app.example.com 指到這台機器,第一次請求時 acme-companion 會去 Let's Encrypt 申請憑證、寫進 certs volume、SIGHUP 通知 nginx 重新載入,整個流程不到 1 分鐘。
四、進階配置
4.1 自動 HTTPS(Let's Encrypt companion)
acme-companion 是 nginx-proxy 的姐妹專案(前身叫 letsencrypt-nginx-proxy-companion),專門處理憑證生命週期。它的工作流程:
- 監聽 docker 事件,找出有
LETSENCRYPT_HOST的容器 - 用 acme.sh 跑 HTTP-01 challenge(透過共用的
/usr/share/nginx/html目錄) - 拿到憑證後寫進
/etc/nginx/certs/<domain>.crt跟.key - 對 nginx-proxy 容器發 SIGHUP 觸發 reload
- 內建 cron 每天檢查到期日,剩 30 天內自動續期
需要的環境變數:
DEFAULT_EMAIL(acme-companion 自己的容器)LETSENCRYPT_HOST(每個應用容器都要設,通常等於VIRTUAL_HOST)LETSENCRYPT_EMAIL(可選,覆蓋 DEFAULT_EMAIL)
支援的 ACME provider 不只 Let's Encrypt,也能切到 ZeroSSL 或 BuyPass。Let's Encrypt 有 rate limit(每週同一個 registered domain 50 張),對需要大量子域名的服務(例如 PR preview 環境)建議改用 ZeroSSL 或申請 wildcard 憑證自己掛。Wildcard 憑證沒辦法用 HTTP-01 challenge 申請,需要 DNS-01,acme-companion 也支援,但要在容器裡塞 DNS provider 的 API token,配置複雜度高一些。
4.2 多 host 與 path-based routing
同一個容器可以對應多個 host:
environment:
- VIRTUAL_HOST=app.example.com,api.example.com
也可以反過來,讓不同 path 路由到不同容器:
# 容器 A:API
environment:
- VIRTUAL_HOST=example.com
- VIRTUAL_PATH=/api/
# 容器 B:前端
environment:
- VIRTUAL_HOST=example.com
- VIRTUAL_PATH=/
VIRTUAL_PATH 也支援 regex:~^/(app1|app2)/ 這種寫法可以接住多個前綴。要把 /api/ prefix 在送到後端時剝掉,就加 VIRTUAL_DEST=/。這個機制讓單一 domain 拆成多個微服務變得很容易,而且不需要前端 router 也不需要 BFF。
4.3 WebSocket / load balancing
WebSocket 不需要特別配置,nginx-proxy 預設模板會處理 Connection: Upgrade 與 Upgrade header,瀏覽器跑 socket.io 或原生 WebSocket 直接通。長連線的 timeout 預設值偏短(60 秒),跑視訊串流或 SSE 的話建議在 vhost.d 客製:
proxy_read_timeout 7d;
proxy_send_timeout 7d;
同一個 VIRTUAL_HOST 對應多個容器時自動 round-robin。要改 hash by IP 或 least_conn,可以加 label:
labels:
com.github.nginx-proxy.nginx-proxy.loadbalance: "hash $remote_addr;"
預設加上的 X-Forwarded-* 系列 header 也很完整:X-Real-IP、X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Host、X-Forwarded-Port。Proxy header 永遠被移除,避免 httpoxy 漏洞。
4.4 自訂 Nginx 模板
預設模板能滿足 80% 的需求,但碰到特殊情境(例如要塞自訂 security header、要改 client_max_body_size、要加 rate limit)就需要客製:
- 單一 vhost 的 server 段:在
vhost.dvolume 放一個檔名等於<VIRTUAL_HOST>的檔案,例如vhost.d/app.example.com,內容會被 include 進該 host 的 server block - 單一 vhost 的 location 段:檔名加
_location後綴,例如vhost.d/app.example.com_location - 全站預設:檔名
default跟default_location - proxy-wide:mount 一個
.conf到/etc/nginx/conf.d/
例如要為某個 host 加 client_max_body_size:
echo 'client_max_body_size 100M;' > vhost.d/upload.example.com
docker exec nginx-proxy nginx -s reload
要更深入的話,整份 nginx.tmpl 也可以 override 掛進容器,但這就需要看懂 docker-gen 的 template 語法(Go template 加上一些自訂 function 像 groupByMulti、where),學習曲線陡很多,除非要做大規模客製,不然不建議走這條路。
五、實測:5 分鐘加新服務
跑一次完整流程感受一下落差。我手上有台機器已經跑著上面那份 compose,現在要加一個 Gitea 服務。
# 加到既有 compose
gitea:
image: gitea/gitea:1.21
container_name: gitea
expose:
- "3000"
environment:
- VIRTUAL_HOST=git.example.com
- VIRTUAL_PORT=3000
- LETSENCRYPT_HOST=git.example.com
volumes:
- gitea-data:/data
networks:
- proxy
restart: always
然後:
# DNS:git.example.com A record 指向機器 IP(事前做好)
docker compose up -d gitea
# 看 nginx-proxy 自動 reload
docker logs -f nginx-proxy --tail 20
# 應該看到:Received event start for container ...
# Received signal: hangup
# Generated nginx config
# 看 acme-companion 申請憑證
docker logs -f nginx-proxy-acme --tail 30
# Creating/renewal of git.example.com certificates...
# Reloading nginx proxy
從 docker compose up 到 https://git.example.com 真的能開,整個過程確實 3 分鐘左右。對比過去手動 nginx + certbot 的流程,省下的每一分鐘都是真的。要拆掉 service 也一樣輕鬆,docker compose rm -sf gitea 之後 nginx-proxy 會收到 die 事件、自動把 git.example.com 從 conf 移除。
六、vs. Traefik / Caddy / Token Gateway
把市面上主流的 reverse proxy 工具放在一起對比一下:
| 工具 | 配置來源 | HTTPS 自動化 | 適合規模 | 學習成本 |
|---|---|---|---|---|
| nginx-proxy | docker env / labels | acme-companion(外掛) | 單機到中型 | 低(會 nginx 就行) |
| Traefik | docker labels / k8s ingress | 內建 ACME | 中到大型,多 provider | 中(label 系統需熟悉) |
| Caddy | Caddyfile | 內建(自動 on by default) | 小到中型 | 極低(語法極簡) |
| HAProxy | hapi-runtime API | 需搭配 certbot | 大型,重視效能 | 高 |
Caddy 的「預設開 HTTPS」確實香,但要綁 docker 動態服務需要額外的 plugin(caddy-docker-proxy),生態不如 nginx-proxy 跟 Traefik 成熟。HAProxy 是另一個世界,企業內網跟金融場景比較常見,配置複雜度高一個量級。
選擇上:「我跑 docker,沒有 k8s,喜歡 nginx 慣用的 conf 思維」就 nginx-proxy;「我有 k8s 或多 cluster」就 Traefik;「我只想開三個小服務、connection 量小」就 Caddy。如果手上機器以後可能會搬到 k8s,倒是建議直接學 Traefik 一勞永逸,遷移時還能保留 label 配置。
七、適合與不適合的場景
不是每個場景都該用 nginx-proxy。整理一下我的判斷:
適合
- 個人 / 小團隊在 1~3 台機器上跑多個容器,每個都要獨立 domain
- CI/CD 動態 preview 環境(PR 一開就有對應 URL,PR 一關 URL 自動消失)
- 自架服務站台(Gitea、Mattermost、Vaultwarden、wiki...)
- 微服務內部反代,不需要複雜的 traffic shaping
- 對 nginx 設定熟悉、想保留客製空間的團隊
不適合
- 已經在 Kubernetes 上,那直接用 ingress controller(nginx-ingress、Traefik)才正解
- 需要金絲雀發布、藍綠部署、weighted routing 等進階流量控制
- 後端不是 docker 容器(VM、bare metal、雲端 serverless)
- 多機器 swarm 雖然能跑,但運維成本未必比 Traefik 低
- 對 nginx 完全陌生、且不想學 Go template 的團隊
下面這張圖整理三個元件的互動關係:

整個系統的事件流就是:容器啟動 → docker-gen 收到 → 渲染 conf → SIGHUP → nginx 重載;憑證需求 → acme-companion 跑 challenge → 寫進共用 volume → SIGHUP → nginx 帶上新憑證。沒有外部 control plane,沒有 etcd,沒有 service mesh,純粹是兩個容器加幾個 volume,這個極簡的 architecture 也是它在小規模場景能跑得這麼穩的原因。
7.1 常見踩坑清單
整理這幾年用下來看過的常見問題,給之後要採用的人一個快速查找表:
- 502 Bad Gateway:九成是 docker network 沒共用,或者
VIRTUAL_PORT寫錯。先docker exec nginx-proxy nginx -T看 upstream 的 IP,再docker exec myapp curl localhost:<port>確認後端真的有 listen。 - 憑證一直發不出來:Let's Encrypt 走 HTTP-01,需要 80 port 對外開放、DNS 已正確指向、防火牆放行。第一次失敗會被退到 staging,看 acme-companion log 有沒有
Account creation那段成功訊息。 - 每次 reload 連線中斷:通常是 nginx worker_shutdown_timeout 太短。預設 nginx 行為是 SIGHUP 時優雅切換,這個問題比較少見,遇到的話檢查是不是有 process 異常 panic。
- swarm 模式抓不到 service:原生 nginx-proxy 預設模式是讀 docker daemon 的 container 列表,swarm service 要用
nginxproxy/docker-gen搭 swarm 專用模式,或者改用 Traefik,這個情境 nginx-proxy 並不順手。 - 一台機器跑幾百個 vhost:nginx 自己沒問題,但 docker-gen 每次事件都會重新渲染整份 conf,容器數量大時會看到明顯的 reload 延遲。對策是把不常變動的服務用標準 vhost.d 寫死,把動態的部分留給 docker-gen。
7.2 觀察與監控
跑起來之後別忘了配 log 跟 metrics。nginx-proxy 內建會把 access log 寫到 stdout,配合 docker logging driver 直接送進 Loki / ELK 都可以。要更深入的指標可以考慮:
- 用 nginxinc/nginx-prometheus-exporter 抓 stub_status,掛在同一個 docker network 即可
- 在 vhost.d 加上
log_format帶上$upstream_response_time、$upstream_addr,方便排查慢 upstream - acme-companion 的 log 訂閱起來,憑證失敗第一時間能收到告警
八、小結
nginx-proxy 的價值在於它把「dynamic Docker topology」跟「靜態 nginx.conf」這兩個本質衝突的東西用 docker-gen 接起來,而且做法夠樸素——不重新發明 reverse proxy,只是把現有的 nginx 自動化掉。對中小規模、單機或少量機器的場景,這個取捨非常划算,大型場景才需要 Traefik 或 ingress controller 那層額外的複雜度。
實際在用的時候有幾點小心:版本鎖死(不要追 latest)、acme.sh 的 rate limit、expose 跟 ports 別搞混、共享 docker network 別漏設、要 reload 之前先用 nginx -T 看一下渲染結果。把這幾個踩過一次後,後續加新服務真的就是「寫四行 yaml 然後 up -d」的事。
下次想多開一個服務之前,先想想有沒有必要再 ssh 進去寫 conf。如果答案是「沒必要」,那 nginx-proxy 就是答案。
參考資料
- 25k+ Star!Docker 容器自动化反向代理神器,告别手动配置 Nginx(妙想栈)
- nginx-proxy / nginx-proxy GitHub repo
- nginx-proxy 官方文件
- nginx-proxy / acme-companion GitHub repo
- docker-gen GitHub repo

發佈留言