TechQuant Blog

Caddy で自宅サーバーをHTTPS化する — nginx より楽すぎて戻れなくなった話

7分で読める

先週、自宅の Raspberry Pi 5 に動かしているダッシュボードを家族にも見せたくなった。条件は二つだけ。HTTPS にすること。設定を最小限にすること。

nginx を引っ張り出して /etc/nginx/sites-available/ を編集して、certbot を入れて、cron で更新を仕込んで……と頭の中で手順を組み立てた瞬間に、急にやる気が萎えた。何度やっても面倒なんだ、この作業。

そこで思い出したのが Caddy だった。「Let's Encrypt 自動取得・自動更新が標準装備」と聞いてはいたが、ちゃんと触るのは初めて。結果から言うと、設定ファイル3行で HTTPS 化が完了した。比喩じゃなくて、本当に3行。

その勢いで自宅の他のサービスも全部 Caddy に移した。半年使い込んだ正直な感想と、ハマりどころを書いておく。

Caddy とは何か——nginx と何が違うのか

Caddy は Go 製のWebサーバ兼リバースプロキシ。最大の特徴は HTTPS が標準でオン という思想。ドメイン名を書くだけで、勝手に Let's Encrypt から証明書を取って、勝手に自動更新する。certbot を別で動かす必要がない。

nginx との比較で言うと、こんな感じ。

項目nginxCaddy
HTTPScertbot 等で別途設定標準・自動
設定ファイルnginx.conf(独自構文)Caddyfile(簡潔・JSON も可)
HTTP/3 (QUIC)experimental標準
パフォーマンス非常に高速nginx よりやや劣る場面あり
学習コスト高い低い

大規模サイトで秒間数万リクエストを捌くなら nginx に分がある。ただ自宅サーバや小〜中規模の運用では、Caddy の手軽さが圧倒的に効く。

インストール(Ubuntu / Raspberry Pi OS)

公式の APT リポジトリを追加するのが一番楽。

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list

sudo apt update && sudo apt install caddy

インストールすると caddy.service が systemd に自動登録される。Raspberry Pi 5 でも問題なく ARM64 バイナリが落ちてくる。

確認はこれだけ。

systemctl status caddy
caddy version

最小構成——本当に3行で HTTPS 化

設定ファイルは /etc/caddy/Caddyfile。インストール直後はデフォルトで :80 リスンするだけのサンプルが入っているので、それを書き換える。

仮に手元のドメインが dash.example.com で、内部の Grafana が localhost:3000 で動いているとすれば——

dash.example.com {
    reverse_proxy localhost:3000
}

これで終わり。sudo systemctl reload caddy を叩いて30秒ほど待つと、ACME チャレンジが完走して証明書が降ってくる。https://dash.example.com がそのまま開く。

初めてやったときは「いや、ちゃんと検証されたのか?」と疑って journalctl -u caddy -f を眺めた。certificate obtained successfully のログが流れてきて、本当に終わってた。拍子抜けした。

前提条件: ドメインの A レコードがサーバの公開IPを向いていて、80番と443番ポートが外部から到達可能であること。これがないと Let's Encrypt の HTTP-01 チャレンジが失敗する。

複数サービスを1台で捌く

自宅サーバの本領は、1台で複数のサービスをホストすること。Caddyfile はサイトブロックを並べるだけでいい。

dash.example.com {
    reverse_proxy localhost:3000
}

api.example.com {
    reverse_proxy localhost:8000
}

files.example.com {
    root * /srv/files
    file_server browse
}

# パス単位の振り分けも可能
app.example.com {
    handle /api/* {
        reverse_proxy localhost:8080
    }
    handle {
        reverse_proxy localhost:5173
    }
}

このシンプルさが効く。nginx で同じことをやろうとすると server ブロックを書いて、listen 443 ssl を書いて、証明書のパスを書いて、location ブロックを切って——となる。Caddyfile は 意図がそのまま設定になる

運用で実際に役立った機能

basic_auth で簡易認証

家族にだけ見せるダッシュボードに認証を付けたかった。Caddy なら1行。

dash.example.com {
    basic_auth {
        family JDJhJDE0JEh...
    }
    reverse_proxy localhost:3000
}

ハッシュは caddy hash-password で生成する。bcrypt なので強度的にも問題ない。OAuth みたいな大掛かりな認証基盤を立てるほどでもない、というシーンに刺さる。

自動 HTTP→HTTPS リダイレクト

これも自動。何も書かなくても、80番ポートに来たリクエストは443にリダイレクトされる。「うっかり http:// で叩いてしまった」を救ってくれる。

ログのアクセス記録

サイトブロック内に log ディレクティブを書くだけ。

dash.example.com {
    log {
        output file /var/log/caddy/dash.log
        format json
    }
    reverse_proxy localhost:3000
}

JSON で吐けるので、ローカルで jq 集計するのも楽。journalctl 単体だとフィルタが面倒なので、サイトごとに分けると後で助かる。 jq の実践テクニック はこのあたりで使う。

nginx から移行してハマったこと

1. WebSocket は意外と引っかかる

素朴に reverse_proxy を書いただけでは、一部の WebSocket アプリで切断が発生した。Caddy の v2 系は WebSocket を自動でアップグレードするはずなのだが、ヘッダ周りで噛み合わないケースがあった。

結局こう書いて解決した。

chat.example.com {
    reverse_proxy localhost:8765 {
        header_up Host {host}
        header_up X-Real-IP {remote_host}
    }
}

正直、ここは原因をちゃんと追い切れていない。ベースの WebSocket は通るのに、特定のフレームワークだけ駄目だった。再現条件をまとめて GitHub issue を漁る予定。

2. レスポンスのバッファリング

長時間のストリーミングレスポンス(SSE)で、終端側に届くタイミングが妙に遅れる現象に遭遇した。flush_interval を明示すれば直る。

sse.example.com {
    reverse_proxy localhost:9000 {
        flush_interval -1
    }
}

nginx の proxy_buffering off 相当だ。デフォルトでこうしておいてくれてもいい気はする。

3. systemd ユニットの権限

Caddy の systemd ユニットは caddy ユーザで動く。/var/log/caddy/ へのログ出力は問題ないが、自前のディレクトリ(例えば /srv/files)を file_server で配る場合、所有者・読み取り権限を caddy に合わせる必要がある。これを忘れて30分ハマった。

systemd 周りの調査は journalctl 実践ガイド でも書いたが、journalctl -u caddy --since "10 min ago" で見れば原因はだいたい分かる。

外部公開しない場合の使いどころ

面白いのは、外に公開しない用途でも Caddy は便利 ということ。

たとえば自分の場合、 Tailscale で家のネットワークに入る 構成にしているので、外には何も穴を開けていない。それでも内部用の *.ts.net ドメインに対して Caddy で HTTPS 化している。tls internal を書くと自己署名証明書を発行してくれて、内部だけで完結する。

{
    auto_https disable_redirects
}

grafana.tailnet-xxxx.ts.net {
    tls internal
    reverse_proxy localhost:3000
}

Tailscale の MagicDNS と組み合わせると、家の中のサービスが全部 https:// で参照できる。地味だが、ブラウザの「保護されていない通信」警告が消えるだけで気分が違う。

Cloudflare Tunnel と並用しているサービスもある。 Cloudflare Tunnel の構築手順 を別記事で書いたが、用途で使い分けている。外部公開が必要なものは Tunnel、内部だけで使うものは Caddy 単体、という分け方。

パフォーマンスは正直どうなのか

自宅レベルでは差を感じない。Raspberry Pi 5 で wrk -t4 -c100 -d30s を投げてみると、静的ファイル配信で約 38,000 req/s、リバースプロキシ越しで約 12,000 req/s だった。家族3人が同時にアクセスする程度の負荷では誤差にもならない。

ベンチマーク結果は環境とテスト条件次第なので、参考程度に見てほしい。本番のWebサービスで nginx -> Caddy 移行を検討しているなら、自分のワークロードで実測すべき。

ちなみに自分は外部公開用のサービスを お名前.comの高性能VPS に置いていて、そこにも Caddy を入れて運用している。家から離れた場所で動かす用途でも、設定の単純さは効く。

nginx を選ぶべきケース

Caddy 推しではあるが、無条件におすすめはしない。次のケースは nginx の方が向いている。

  • 秒間数万リクエストを捌く規模——枯れた最適化の蓄積で nginx に分がある
  • 細かいキャッシュ制御が必要なケース(proxy_cache_path 周りの柔軟性)
  • 既存のチームが nginx に習熟していて、移行コストが見合わない
  • サードパーティのモジュール(Lua スクリプティングなど)を使っている

逆に、自宅サーバ・個人プロジェクト・スタートアップ初期のサービスなら、Caddy の手軽さは時間を返してくれる。設定の短さは正義。

結局どう運用しているか

今の自分の構成はこう。

  • 自宅 Raspberry Pi 5: Caddy + Tailscale + 内部用サービス(HTTPS は内部証明書)
  • 外部 VPS: Caddy + 外部公開API(Let's Encrypt)
  • 一部のサービス: Cloudflare Tunnel 経由(DNS 周りを Cloudflare に寄せたいもの)

Caddyfile は git 管理して、caddy validate --config /etc/caddy/Caddyfile で構文チェックしてから reload する流れ。validate を CI に組み込んでおくと、設定ミスでサイトが全停止する事故が防げる。

nginx に戻る理由は、今のところ思いつかない。手元のスケールには合わないからだ。「設定が短い」「自動 HTTPS」「systemd と素直に噛み合う」。この3つだけで自宅運用ならもう十分。

もし自宅サーバを始めたばかりで HTTPS 化に手こずっているなら、最初に試すツールとして真剣に推せる。少なくとも自分は、最初から Caddy にしておけば良かった、と何度か思った。