跳到主要內容

Web Day 33 反向代理:Caddy 與 HTTPS



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/

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...