Web Day 33 反向代理:Caddy 與 HTTPS
執行需求:需 Docker。今天是上線章節的第四天,我們要在 Day 31 的 docker-compose 基礎上加一層反向代理,使用 Caddy 2.10 世代。Caddy 最大的特色是「HTTPS 自動化」:只要設定好域名,Caddy 會自己跟 Let's Encrypt 申請憑證、幫你續期,連憑證檔都不用管。我們會用 Caddyfile 設定反向代理與 TLS、並把 Day 30 的 compose 檔擴充成三個服務(caddy + api + db)。沒有 Docker 的讀者可以直接裝 Caddy 二進位,整份設定檔可以原封不動搬過去用。
引言
前面的章節裡,FastAPI 直接對外開放 8000 port、用 HTTP 明文傳輸。在開發階段這樣做沒問題,但正式環境至少有兩個問題:第一,HTTP 沒有加密,使用者帳號密碼、token、個資都會在網路上裸奔,瀏覽器也會標示「不安全」;第二,直接暴露 8000 port 等於把整個應用程式介面直接暴露在公開網路上,攻擊面太大、也無法做負載平衡與多服務整合。今天的目標就是把這兩個問題一次解決。
「反向代理」就是一個放在應用程式前面、負責對外收 HTTP 請求的程式。對外它終止 TLS(HTTPS 加密)、處理 HTTP/2 與 HTTP/3、擋下惡意流量;對內它把請求轉發給後面的 API 伺服器。Caddy 是這類工具裡「最不囉嗦」的選擇:設定檔只要十幾行就能跑,憑證自動申請與續期,HTTP/3 開箱即用。今天就來把這個層疊上去。
今天的學習地圖分四段走:第一,理解反向代理為什麼必要、Caddy 與其他選項的差別;第二,寫一份完整的 Caddyfile 涵蓋 reverse_proxy、headers、log、HSTS;第三,把 docker-compose 擴充成三個服務(caddy + api + db)並驗證 HTTPS 真的能用;第四,介紹幾支實用腳本檢查憑證、解析 log、查 admin API。學完之後你會有一份可以一鍵部署的多容器 HTTPS 服務,並能自己除錯憑證與設定問題。
反向代理的關鍵觀念
反向代理(reverse proxy)跟正向代理(forward proxy)的差別是:正向代理代理「使用者」,例如公司內網透過 proxy 連外網;反向代理代理「伺服器」,把多個請求正確分給後面的多台 API。今天的 Caddy 就是反向代理。
反向代理解決的核心問題有四個:第一,TLS 終止,讓 HTTPS 的加解密集中在 proxy,後面的 API 只要專心處理 HTTP;第二,域名與路徑分發,把 api.example.com、admin.example.com 對應到不同服務;第三,限流與防禦,在 proxy 層擋下太密集的請求;第四,壓縮與快取,把 gzip、brotli、快取 headers 在 proxy 統一處理,後端不用自己做。
Caddy 在這些工具裡相對年輕(首個 stable 在 2015 年),但靠著「設定檔簡潔」、「HTTPS 開箱即用」、「效能直逼 nginx」三個優勢在 2020 年代快速崛起。2025 年 7 月主流版本是 2.10 世代,模組系統成熟、官方映像 caddy:2-alpine 也有對應版本。今天就用這個版本。
Caddyfile 設定
Caddy 的設定檔叫 Caddyfile,採用 Caddy 特有的 DSL(domain-specific language)。比起 nginx 的「一堆 if 與 location block」,Caddyfile 的結構非常線性:先列域名、再寫路由邏輯。底下這份設定是我們這系列「預約管理系統」要用的標準版,包含三個子域(api、admin、www)與幾個常用 middleware。
# Caddyfile
{
email admin@example.com
auto_https on
}
api.example.com {
reverse_proxy api:8000 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
health_uri /healthz
health_interval 10s
health_timeout 3s
}
encode gzip zstd
log {
output file /var/log/caddy/access.log {
roll_size 50mb
roll_keep 5
}
}
}
admin.example.com {
reverse_proxy api:8000 {
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
}
basicauth {
admin JDJhJDE0JDhZc2JnLi5BQmJUK09YdGQu # 範例雜湊,正式請換
}
}
www.example.com {
root * /srv/www
encode gzip
file_server
}
這份設定檔展示了 Caddy 的三個常用區塊:`api.example.com` 把流量轉發到後面的 `api:8000`(docker compose 的 service 名稱)、加上真實 IP headers、`/healthz` 健康檢查、gzip 與 zstd 壓縮;`admin.example.com` 在轉發之前加上 Basic Auth,這對管理後台是常見的雙重保護;`www.example.com` 則純粹 serve 靜態檔案,這部分會在 Day 25 的 Next.js 章節再展開。
每個 reverse_proxy 區塊的 `header_up` 把真實的客戶端 IP 傳給後端,這樣 API 才能正確記錄使用者來源 IP;沒有這層的話,API 收到的會全部是 caddy 容器內部的 IP,稽核 log 就失去意義。`health_uri` 與 `health_interval` 讓 Caddy 自己監測後端健康,連續失敗會自動把該 backend 從 pool 移除(如果設定多個 backend),這對正式環境的零停機部署非常重要。
Docker Compose:把 Caddy 串進來
昨天 Day 31 的 docker-compose.yml 只有 api 與 db 兩個服務。今天加上 caddy 服務,並讓 api 不再對外暴露 port——所有外部流量都要經過 caddy,這樣才能用 HTTPS 統一管理。
services:
caddy:
image: caddy:2-alpine
container_name: booking-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
api:
condition: service_healthy
db:
image: postgres:17-alpine
container_name: booking-db
restart: unless-stopped
environment:
POSTGRES_USER: booking
POSTGRES_PASSWORD: booking_dev_pw
POSTGRES_DB: booking
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U booking -d booking"]
interval: 5s
timeout: 3s
retries: 10
start_period: 15s
api:
build:
context: .
dockerfile: Dockerfile
image: booking-api:dev
container_name: booking-api
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://booking:booking_dev_pw@db:5432/booking
POOL_SIZE: "5"
MAX_OVERFLOW: "10"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2).status == 200 else 1)"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
volumes:
pg-data:
caddy-data:
caddy-config:
這份 compose 檔做了三個關鍵改動:第一,caddy 服務只對外暴露 80 與 443 port,內部網路與 api/db 互通但不被外界直接觸碰;第二,caddy-data 與 caddy-config 兩個 volume 保存 Let's Encrypt 憑證與 Caddy 內部狀態,容器刪除後重新啟動不會丟掉;第三,api 服務不再有 ports 設定——這代表 8000 只能從 caddy 存取,符合「最小暴露面」原則。
注意 `api.example.com` 的 reverse_proxy 目標是 `api:8000`,這是 docker compose 的內部 DNS 解析:service 名稱會被解析成對應 container 的內部 IP。如果你的域名還沒指向這台主機,Caddy 會先用 HTTP challenge 失敗、無法申請憑證,所以正式部署前務必把 DNS A 記錄先指好。
啟動與驗證
整個流程只要三個指令:
docker compose up -d caddy
docker compose ps # 三個服務應都 healthy
curl -I https://api.example.com/healthz # 預期:HTTP/2 200,且憑證有效
第一次啟動時 Caddy 會跟 Let's Encrypt 申請憑證,整個過程在 log 裡看得到。幾個關鍵 log:`obtained certificate` 表示憑證申請成功、`certificate renewed` 表示續期完成。如果看到 `acme: error: 400`,通常是 DNS 還沒指過來、或 80 port 被防火牆擋下——Let's Encrypt 的 HTTP challenge 需要從外部能打到 80 port。
憑證有效期是 90 天,Caddy 會在到期前 30 天自動續期,這個機制不需要外部 cron。要驗證憑證,用瀏覽器打開 https://api.example.com 應該看到鎖頭符號,或用命令列查:
curl -vI https://api.example.com/healthz -o /tmp/cert.out
grep -i "subject\|issuer\|expire" /tmp/cert.out
# 輸出(範例):
# * subject: CN=api.example.com
# * issuer: C=US; O=Let's Encrypt; CN=R10
# * expire date: Oct 14 12:00:00 2025 GMT
對 production 等級的設定,建議額外做兩件事:把 HSTS headers 加上、強制 HTTP 自動轉址到 HTTPS。Caddy 預設就會做 HTTP→HTTPS 轉址,只要 port 80 還在就會回應 301 給所有 HTTP 請求,這對 SEO 也是好事(搜尋引擎不會把 http 與 https 當成兩個站)。
沒有 Docker 時的替代流程
如果本機不能跑 Docker,可以直接裝 Caddy 二進位:macOS 用 `brew install caddy`、Ubuntu 用 `apt install caddy`、Windows 用 Chocolatey。裝好之後把 Caddyfile 放到 `/etc/caddy/Caddyfile`(或自訂路徑),用 `caddy run` 啟動;API 與 PostgreSQL 則照 Day 30/Day 31 的替代方案跑,整個 stack 就能組合起來。
開發階段常常需要「沒有正式域名也能用 HTTPS」。Caddyfile 對本地端可以用 `localhost` 域名,會自動套用 self-signed 憑證;另一個更好的選擇是用 mkcert 工具生成本地信任的憑證,再用 `tls /path/to/cert.pem /path/to/key.pem` 區塊指定。今天範例假設你已經有正式域名;如果只是本機測試,把 api.example.com 換成 localhost 就能直接跑。
另一個常見情境是「我只有一台伺服器、想要順便跑其他服務」。Caddy 可以在同一台主機跑多個站,只要在 Caddyfile 加多個 site block 即可。每個站可以對應到不同 docker container 或不同 port,Caddy 會依域名分流,這對一人公司或 side project 來說非常方便。
完整 Caddyfile 範例(含 HSTS 與 rate limit)
前面那份是基礎版,正式環境會多幾個設定。底下這份加上了 HSTS headers、per-IP 限流、以及更完整的 log 格式,示範「production 等級」的 Caddyfile 長什麼樣。
# Caddyfile.production
{
email admin@example.com
auto_https on
admin off
}
api.example.com {
reverse_proxy api:8000 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
health_uri /healthz
health_interval 10s
health_timeout 3s
health_status 200
}
encode gzip zstd
header {
# HSTS:強制瀏覽器一年內只用 HTTPS 連
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# 其他 security headers
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
# 隱藏伺服器資訊
-Server
}
log {
output file /var/log/caddy/api-access.log {
roll_size 100mb
roll_keep 10
roll_local_time
}
}
}
這份設定在三個地方強化了正式環境的安全性:Strict-Transport-Security header 告訴瀏覽器「未來一年都只接受 HTTPS」,這能擋下中間人嘗試把 HTTPS 降級成 HTTP 的攻擊;X-Frame-Options DENY 防止網頁被嵌入 iframe,降低 clickjacking 風險;`-Server` 把 Server header 拿掉,不讓攻擊者知道我們用的是 Caddy,避免針對特定版本的攻擊。`roll_local_time` 用本地時間切 log 檔,否則預設 UTC 會讓你半夜找 log 時多一個時區換算。
驗證 HTTPS 與 Caddy 設定的小工具
部署完成後,可以用底下這支 Python 腳本批次檢查多個端點的憑證有效性與回應時間。它會打每個 URL、印出 HTTP 狀態、TLS 過期天數、回應大小,方便你寫進部署後 smoke test。
"""scripts/check_tls.py:檢查多個 URL 的 TLS 憑證與回應。"""
import datetime as dt
import socket
import ssl
import sys
import httpx
URLS = [
"https://api.example.com/healthz",
"https://admin.example.com/",
"https://www.example.com/",
]
def cert_expiry_days(host: str, port: int = 443) -> int:
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.create_connection((host, port), timeout=5), server_hostname=host) as s:
info = s.getpeercert()
not_after = dt.datetime.strptime(info["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=dt.timezone.utc)
return (not_after - dt.datetime.now(dt.timezone.utc)).days
def check(url: str) -> dict:
res = httpx.get(url, timeout=10, follow_redirects=True)
host = url.split("/")[2]
return {
"url": url,
"status": res.status_code,
"size": len(res.content),
"cert_days_left": cert_expiry_days(host),
}
def main() -> int:
failures = 0
for url in URLS:
info = check(url)
ok = info["status"] == 200 and info["cert_days_left"] > 7
marker = "OK" if ok else "FAIL"
if not ok:
failures += 1
print(f"[{marker}] {info['url']:50s} status={info['status']} cert_left={info['cert_days_left']}d")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
這支腳本用 ssl 模組直接抓 TLS 憑證的 `notAfter` 欄位,並與當下時間相減得到剩餘天數。`httpx` 加上 `follow_redirects=True` 會自動處理 HTTP→HTTPS 的 301 轉址,這樣我們就不必區分 http 與 https 兩種輸入。實務上把這個腳本接進部署後 smoke test,並把門檻設為「剩餘天數大於 7 天」(避免憑證即將過期還沒續期),就能擋下大多數 TLS 設定錯誤。
Caddyfile 結構驗證腳本
在 commit 之前先驗證 Caddyfile 沒有語法錯誤,能省下 docker compose 重啟時間。Caddy 有內建的 `caddy validate` 指令,這支腳本把它包成 Python 函式,方便接進 CI 或 pre-commit。
"""scripts/validate_caddyfile.py:呼叫 caddy validate 並回報結果。"""
import shutil
import subprocess
import sys
from pathlib import Path
def validate(path: Path) -> tuple[bool, str]:
binary = shutil.which("caddy") or shutil.which("docker")
if binary is None:
return False, "找不到 caddy 或 docker"
if binary.endswith("docker"):
cmd = ["docker", "run", "--rm", "-v", f"{path.parent}:/srv", "caddy:2-alpine",
"caddy", "validate", "--config", f"/srv/{path.name}"]
else:
cmd = [binary, "validate", "--config", str(path)]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0, (result.stdout + result.stderr).strip()
if __name__ == "__main__":
caddyfile = Path(sys.argv[1] if len(sys.argv) > 1 else "Caddyfile")
ok, message = validate(caddyfile)
print(message)
sys.exit(0 if ok else 1)
這支腳本聰明的地方在於自動偵測環境:有裝 caddy 二進位就用本地端驗證、沒有就用 docker 跑一個臨時 container 驗證。`--rm` 確保驗證完 container 就被刪掉,不留垃圾。實務上把這支接進 CI 的 test job,在 push 時自動跑,能擋下「Caddyfile 寫壞了、部署後 caddy 容器不斷重啟」的慘案。
Caddy log parser:把 access log 變成 metrics
Caddy 的 access log 預設是 JSON Lines 格式,每行一個請求。我們可以寫一支小工具把 log 檔讀進來,計算每分鐘的請求數與回應時間統計,作為 Day 34 監控的基礎。
"""scripts/caddy_log_summary.py:把 Caddy access log 整理成每分鐘 metrics。"""
import json
import sys
from collections import defaultdict
from dataclasses import dataclass
@dataclass
class Bucket:
count: int = 0
total_ms: float = 0.0
def add(self, duration_ms: float) -> None:
self.count += 1
self.total_ms += duration_ms
def parse(line: str) -> dict:
return json.loads(line)
def summarize(path: str) -> dict[str, Bucket]:
buckets: dict[str, Bucket] = defaultdict(Bucket)
with open(path, encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
record = parse(line)
minute = record["ts"][:16] # 取到分鐘
buckets[minute].add(float(record.get("duration", 0.0)))
return buckets
def render(buckets: dict[str, Bucket]) -> None:
for minute in sorted(buckets):
b = buckets[minute]
avg = (b.total_ms / b.count) if b.count else 0.0
print(f"{minute} count={b.count:5d} avg_ms={avg:7.2f}")
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "/var/log/caddy/api-access.log"
render(summarize(path))
這支腳本示範了「把 log 變成 metrics」的基本套路:JSON Lines 解析、按時間分桶、加總請求數與耗時。Caddy 的 access log 預設輸出到 stdout,要寫成檔案需要在 Caddyfile 加 log block 把 `output` 指向檔案。今天的範例有寫 `/var/log/caddy/api-access.log`,跑這支腳本就能看到每分鐘的請求數與平均回應時間。實務上會把這些 metrics 推到 Prometheus,再由 Grafana 畫儀表板。
看 Caddy 執行狀態的小工具
Caddy 內建 admin API(預設監聽 `localhost:2019`),可以查詢目前設定、動態改設定。我們把這個 API 包成 Python 函式,方便部署後驗證目前生效的設定檔內容。
"""scripts/caddy_admin.py:讀取 Caddy admin API 的目前設定。"""
import json
import sys
import httpx
ADMIN_URL = "http://127.0.0.1:2019"
def get_config() -> dict:
res = httpx.get(f"{ADMIN_URL}/config/", timeout=5)
res.raise_for_status()
return res.json()
def list_servers(config: dict) -> list[str]:
return list(config.get("apps", {}).get("http", {}).get("servers", {}).keys())
def main() -> int:
try:
config = get_config()
except httpx.HTTPError as exc:
print(f"連不到 Caddy admin API:{exc}", file=sys.stderr)
return 1
servers = list_servers(config)
print(f"目前生效的 servers:{servers}")
print(f"apps:{list(config.get('apps', {}).keys())}")
print(json.dumps(config, indent=2, ensure_ascii=False)[:500])
return 0
if __name__ == "__main__":
sys.exit(main())
這支腳本示範了 Caddy admin API 的兩個常用端點:`GET /config/` 拿回目前生效的完整設定,`POST /load` 可以動態載入新設定。實務上部署後驗證「目前跑的是哪個 Caddyfile」、「憑證是否真的申請下來」時,這支腳本比直接 `docker logs` 更直觀。注意 admin API 預設只監聽 localhost,要從外部查詢需要把 admin 區塊改成 `admin 0.0.0.0:2019` 並搭配防火牆限制來源 IP。
HSTS preload 與瀏覽器支援
前面的 Caddyfile 加上了 Strict-Transport-Security header,這個 header 告訴瀏覽器「未來一年請都用 HTTPS 連我」。對使用者來說,這能避免被中間人攻擊降級到 HTTP。HSTS preload 是更進階的機制:把網域提交到瀏覽器內建清單,新安裝的瀏覽器從第一天起就只用 HTTPS,連第一次連線都不會走 HTTP。Chrome、HSTS Preload List、Firefox 都支援這個機制。
但 HSTS 是「一旦啟用就很難回頭」的機制——瀏覽器記住一年內都用 HTTPS,期間若憑證出問題,使用者會被擋在外面無法降級。正式啟用前務必確認憑證 renewal 機制、Caddy 容器重啟策略、Let's Encrypt 帳號都正常運作。對開發中的 side project 來說,建議先把 max-age 設短(例如 300 秒),確認沒問題再延長。
看 docker compose 內服務對應的小工具
docker compose 啟動後,Caddyfile 寫的 `api:8000` 是 compose 內部的 DNS 解析結果。這個 IP 可能會隨 container 重啟而改變,但 compose 內部的 DNS 會自動更新。我們寫一支小工具列出目前 compose 網路內所有服務的 IP,方便除錯。
"""scripts/compose_dns.py:列出 docker compose 網路內所有服務的 IP。"""
import json
import subprocess
import sys
def list_networks() -> list[dict]:
out = subprocess.check_output(
["docker", "network", "ls", "--format", "{{json .}}"], text=True
)
networks: list[dict] = []
for line in out.splitlines():
if "compose" in line.lower():
networks.append(json.loads(line))
return networks
def inspect(name: str) -> dict:
return json.loads(subprocess.check_output(["docker", "network", "inspect", name], text=True))[0]
def render(networks: list[dict]) -> None:
for n in networks:
info = inspect(n["Name"])
for container, cfg in info.get("Containers", {}).items():
print(f"{cfg['Name']:20s} {cfg['IPv4Address']}")
if __name__ == "__main__":
networks = list_networks()
if not networks:
print("找不到 compose 網路,先跑 docker compose up -d", file=sys.stderr)
sys.exit(1)
render(networks)
這支腳本列出目前 compose 網路內所有 container 的名稱與 IP。實務上當 `api:8000` 連不上時,這支工具能讓你確認 api container 的 IP 真的有被指派。如果 IP 欄位是空的,通常代表 container 還沒進入 running 狀態,或是 DNS resolver 還沒更新。搭配 `docker compose ps` 一起用,能在 30 秒內定位大多數網路問題。
smoke test:部署後驗證三層架構都活著
部署完成後,用底下這支腳本同時驗證 caddy、api、db 三個服務都還活著。它會打 caddy 的 HTTPS 端點、檢查 Caddy 是否真的把請求轉發給 api(看 response header 與 log)、最後用 psycopg 直接 ping 資料庫。
"""scripts/stack_smoke.py:部署後驗證 caddy + api + db 三層。"""
import os
import sys
import httpx
import psycopg
PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://api.example.com")
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://booking:booking_dev_pw@127.0.0.1:5432/booking")
def check_public() -> bool:
res = httpx.get(f"{PUBLIC_URL}/healthz", timeout=10, follow_redirects=True)
ok = res.status_code == 200
print(f"[{'OK' if ok else 'FAIL'}] caddy {PUBLIC_URL}/healthz status={res.status_code}")
return ok
def check_db() -> bool:
try:
with psycopg.connect(DATABASE_URL, connect_timeout=5) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
print(f"[OK] db 連線成功")
return True
except psycopg.OperationalError as exc:
print(f"[FAIL] db 連線失敗:{exc}")
return False
def main() -> int:
results = [check_public(), check_db()]
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())
這支 smoke test 涵蓋三件事:透過 caddy 打 https、確認 HTTPS 真的有終止(cert 有效、HTTP/2 生效)、資料庫能直接連線。實務上可以接進部署 workflow 的最後一個 step,當所有檢查都通過才標記 deploy 成功。失敗時則讓 deploy workflow 失敗、觸發前面 Day 32 提到的 rollback 流程。這種「三層 smoke test」的觀念在 Day 34 監控章節會再延伸,那時候會把檢查改成「每 30 秒跑一次、連續失敗 N 次發警報」。
常見錯誤與踩雷
DNS 還沒指好就啟動 caddy。Let's Encrypt 的 HTTP-01 challenge 需要從外部打 port 80 才能驗證域名擁有權。如果 DNS A 記錄還沒指過來、或 TTL 太長還沒生效,Caddy 會一直噴 acme error。解法是先用 `dig api.example.com` 確認指向正確的 IP、等 5 到 10 分鐘 DNS 生效後再啟動 caddy。
沒保存 caddy-data volume。如果 compose 裡的 caddy-data 是 bind mount 但主機目錄不存在、或 volume 沒掛上去,每次 `docker compose up -d` 都會重抽憑證,很快就被 Let's Encrypt 限流(每週 5 次)。務必用 named volume,並在 compose 裡明確宣告。
80 port 被佔用。很多機器預設會跑 nginx 或 apache,把 port 80 佔住。Caddy 啟動時會直接 fail:bind: address already in use。解法是先停掉舊服務、或把 caddy 的 80/443 改成別的 port(但 Let's Encrypt 就無法自動申請了)。
Caddyfile 的 reverse_proxy 寫成 IP 而不是 service name。在 compose 裡各 service 之間互通要用 service name(會被 docker DNS 解析成 container IP),寫成 `localhost:8000` 只會打到 caddy 自己、寫成主機 IP 會繞過 compose 內部網路。務必確認 reverse_proxy 後面是 service name。
沒有把真實 IP 傳給後端。少了 `header_up X-Real-IP {remote}` 這行,API 收到的 client IP 永遠是 caddy 內部 IP,rate limit 與稽核 log 都會失準。這是 Caddyfile 最常見的 bug 之一,建議每個 reverse_proxy 區塊都加上這三行 headers。
效能與實務提醒
Caddy 在 2025 年 7 月的版本對 HTTP/3(QUIC)已經穩定支援,只要瀏覽器與客戶端支援,TLS handshake 與並行請求都會更快。預設情況下 Caddy 會自動升級到 HTTP/3,不過這個行為有時會被中介的 proxy 或防火牆擋下,正式環境建議用 `curl -I --http3-only` 或瀏覽器開發者工具確認 HTTP/3 是否真的生效。
rate limiting 在 Caddy 2.10 可以用 `rate_limit` directive(位於 http.ratelimit 模組),這對正式環境很重要。我們這份基礎 Caddyfile 沒寫 rate limit,是為了簡化範例;正式環境建議至少對 /login、/api/auth/token 這類端點加上限制,避免暴力破解。每分鐘 60 次是個合理的起點,可依實際流量調整。
最後一個觀念是「Caddy 不應該當應用伺服器」。Caddy 雖然可以跑 PHP、跑 markdown 渲染,但這些都不應該在正式環境做。我們的設計是 Caddy 純粹做 TLS 終止與反向代理,把應用邏輯完全交給後面的 FastAPI 容器,這樣未來要加新的服務(例如另一個 API、websocket server)只要在 Caddyfile 加一個 site block 即可,架構彈性大很多。
小結
今天我們把 Caddy 加進 docker-compose,作為對外的反向代理與 HTTPS 終止。重點觀念包括:Caddy 自動申請 Let's Encrypt 憑證、Caddyfile 的 reverse_proxy 與 header_up 怎麼設定真實 IP、為什麼要把 api 從外部 port 拿掉只用 caddy 對外。我們也寫了兩支腳本來檢查 TLS 憑證與 Caddyfile 結構,這些在部署後與 CI 都會用到。文末也提供了不安裝 Docker 時的替代流程。明天我們會進入健康檢查與監控章節,把應用的健康指標與可觀察性補完。
結語
加上 Caddy 之後,部署才算真正「對外可用」:使用者用 HTTPS 連到正式域名,TLS 自動續期,後端多容器互不影響。明天,我們會進入健康檢查與監控章節,從「服務是否還活著」開始,把整個系統的可觀察性補完,確保出問題時能在第一時間知道。
延伸資源
- Caddy 官方文件:
https://caddyserver.com/docs/ - Let's Encrypt 速率限制說明:
https://letsencrypt.org/docs/rate-limits/ - Caddy reverse_proxy directive 完整說明:
https://caddyserver.com/docs/caddyfile/directives/reverse_proxy - Caddy 模組總覽:
https://caddyserver.com/docs/modules/ - SSL Labs 測試工具(測 TLS 設定品質):
https://www.ssllabs.com/ssltest/
留言
張貼留言