本文へ移動
購読
GUIDE#infra更新 2026.09.24

Caddyで自動HTTPSのリバースプロキシを作る:CaddyfileとTLS証明書の仕組み

Caddyはドメイン名を書くだけで証明書の取得・更新とHTTPS化まで自動で行います。Caddyfileの書き方と自動で付くヘッダー、LAN専用サーバー向けのDNS-01とtls internalを実機で確認。

level
初級
time
約30分
stack
Caddy 2.11 / Docker Compose / Let's Encrypt
author
@ryoma_t
// 前提条件
Docker Composeが使えるLinuxサーバー / 公開するなら独自ドメインと80・443番の到達性(LAN専用ならDNSのAPIトークン)
Caddyのバージョン、HTTPからHTTPSへの308リダイレクト、リバースプロキシのヘッダー、ローカルCAの発行者を確認したターミナル
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

Caddyとは、Go言語で書かれたWebサーバー兼リバースプロキシで、設定ファイルにドメイン名を書くだけで、TLS証明書の取得と更新、HTTPからHTTPSへのリダイレクトまでを自動で行うソフトウェアです。nginxとcertbotを組み合わせて作っていた仕組みが、本体だけで完結します。

ここではDocker版のCaddy 2.11.4(執筆時点)を使い、複数のサービスをホスト名で振り分けるCaddyfileを書きます。自動HTTPSが動く条件と証明書の保存場所、転送時に自動で付くヘッダー、外から届かないLAN専用サーバーをHTTPSにするDNS-01と tls internal の使い分けまでを、実際に動かした結果とあわせて確認します。

この記事でわかること

  • 自動HTTPSが動く条件(名前・80/443番・/data の永続化)と、証明書がどこに保存されるか
  • Caddyfileでの振り分け(reverse_proxy、handle_path、スニペット)と、自動で付く転送ヘッダー
  • LAN専用のサーバーをHTTPSにする2つの方法:DNS-01のモジュール入りビルドと tls internal
  • nginx+certbotと比べたときの向き不向き
  • 起動しない、502になるときのエラーメッセージと直し方

Caddyとは:nginxとの違いは「証明書を本体が管理する」こと

Caddyの設定の単位は「サイトブロック」で、app.example.com { reverse_proxy app:8080 } の1行だけで、証明書を取得してHTTPSで待ち受け、80番へのアクセスを308でHTTPSへ転送し、app:8080 にリクエストを渡すところまでが揃います。nginxで同じことをするには、serverブロックを2つ(80番と443番)書き、certbotを別に入れて更新タイマーを確認する必要があります。

項目Caddynginx+certbot
証明書の取得・更新本体内蔵のACMEクライアントが自動で行うcertbotを別に入れ、systemdタイマーで更新
HTTP→HTTPSのリダイレクト自動(308)設定を書く、またはcertbotが追記する
転送ヘッダーX-Forwarded-*を自動で付け、Hostはそのまま渡すproxy_set_header を自分で書く
WebSocket設定不要Upgrade・Connectionヘッダーを明示する
設定の反映caddy reload(失敗したら旧設定のまま動き続ける)nginx -t のあと systemctl reload
機能の追加DNSプロバイダなどはモジュールを入れて再ビルドパッケージや動的モジュール
情報量・運用経験者nginxより少ない多い

新しく組むホームラボや小規模な社内サービスなら、Caddyを選ぶ方が設定も更新作業も少なく済みます。nginxを残すべきなのは、既存の設定資産がある場合、細かいキャッシュ制御やrewriteを多用している場合、TCP/UDPをそのまま中継するstreamプロキシが必要な場合、そしてチームにnginxの運用経験がありCaddyの経験者がいない場合です。nginxでの書き方はnginxでリバースプロキシを構築する手順にまとめています。

自動HTTPSの仕組み:ACMEと3つのチャレンジ

サイトアドレスに app.example.com のような公開ドメイン名を書くと、CaddyはACME(証明書を自動発行するためのプロトコル)のクライアントとして動き、Let's Encryptなどの認証局に証明書を注文します。認証局は「このドメインを本当に管理しているか」をチャレンジで確かめ、通れば証明書が発行されます。更新も本体の仕事で、有効期間の残りがおよそ3分の1になった時点で自動的に取り直すため、cronもタイマーも要りません。

チャレンジ認証局が確かめる方法ワイルドカード証明書外から届かないLAN専用サーバーCaddyでの扱い
HTTP-0180番に確認用のファイルを取りに来る不可不可既定で有効
TLS-ALPN-01443番で特別なTLSハンドシェイクをする不可不可既定で有効
DNS-01DNSのTXTレコードを見る可可DNSプロバイダのモジュールが必要

既定の2つはどちらも、インターネットからこのサーバーの80番か443番に届くことが前提です。ルーターでポートを転送していない、あるいはIPv4の着信ができない回線(MAP-EやDS-Lite)では、既定のままでは証明書を取れません。その場合はDNS-01を使います。

localhost、*.localhost、IPアドレスのように公開証明書を取れない名前をサイトアドレスに書くと、CaddyはACMEの代わりに自前の認証局(Caddy Local Authority)で証明書を発行します。実際に app.localhost で起動すると、ログに obtaining certificate と certificate obtained successfully(issuerは local)が出て、発行された証明書の有効期間は12時間でした。短い期間で自動更新する前提の作りです。

証明書と秘密鍵、ACMEのアカウント鍵は、Docker版では /data(中の caddy/certificates/ と caddy/pki/)に保存されます。/data をボリュームにせずにコンテナを作り直すと、起動のたびに証明書を取り直し、Let's Encryptの発行数の上限(rate limit)に当たって rateLimited エラーで取得できなくなります。設定を試行錯誤する間は、グローバルオプションに acme_ca https://acme-staging-v02.api.letsencrypt.org/directory を書いてステージング環境を使ってください。

インストール:Dockerを基本にする

Ubuntu 24.04の標準リポジトリ(universe)にもcaddyパッケージはありますが、執筆時点の版は2.6.2で、Docker Hubの公式イメージ(2.11.4)よりかなり古くなっています。ホストに直接入れるなら公式ドキュメントにある配布元のAPTリポジトリを追加し、そうでなければDockerで動かすのが手軽です。この記事はDockerを前提にします。

~/stacks/proxy/compose.yaml
services:
  caddy:
    image: caddy:2.11
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"   # HTTP/3
    volumes:
      - ./conf:/etc/caddy:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [web]

volumes:
  caddy_data:
  caddy_config:

networks:
  web:
    name: web

公式イメージは XDG_DATA_HOME=/data、XDG_CONFIG_HOME=/config に設定されていて、証明書は /data、最後に読み込んだ設定の自動保存は /config に書かれます。振り分け先のアプリは、同じ web ネットワークに参加させればサービス名で届きます。443番をUDPでも公開しているのは、CaddyがHTTP/3を既定で有効にしているためで、応答ヘッダーの Alt-Svc: h3=":443" を見たブラウザが次回からQUICで接続してきます。

Caddyfileを1ファイルだけバインドマウントせず、./conf ディレクトリごとマウントしているのには理由があります。vimなど多くのエディタは保存時にファイルを新しく作り直すため、ファイル単位のマウントだとコンテナ側には古い内容が見え続け、reloadしても変更が反映されません。

Caddyfileの書き方:サイトブロックとreverse_proxy

~/stacks/proxy/conf/Caddyfile に、4つのサービスを振り分ける設定を書きます。インデントはタブで揃えます。スペースで書いても動きますが、読み込むたびに Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies という警告が出ます。

~/stacks/proxy/conf/Caddyfile
{
	email [email protected]
	servers {
		trusted_proxies static private_ranges
	}
}

(lan_only) {
	@outside not client_ip private_ranges
	respond @outside "forbidden" 403
}

app.example.com {
	log
	reverse_proxy app:8080
}

kuma.example.com {
	import lan_only
	reverse_proxy uptime-kuma:3001
}

api.example.com {
	handle_path /v1/* {
		reverse_proxy api:8081
	}
	handle {
		respond "not found" 404
	}
}

pve.example.com {
	import lan_only
	reverse_proxy https://192.168.10.11:8006 {
		transport http {
			tls_insecure_skip_verify
		}
	}
}

先頭の波かっこだけのブロックはグローバルオプションです。email はACMEアカウントの連絡先として登録されます。Let's Encryptは期限切れ前の通知メールを廃止しているので、これを期限の見張り役にはできません。(lan_only) はスニペットで、import lan_only と書いたサイトにだけ「接続元がプライベートIPでなければ403を返す」処理を差し込みます。private_ranges は、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、ループバック、IPv6のユニークローカル(fd00::/8)などをまとめた短縮表記です。

client_ip は、trusted_proxies に書いた範囲から来たリクエストについてだけX-Forwarded-Forの値を信用して、本当の接続元IPを判定します。前段にCloudflareなど別のプロキシを置く場合は、private_ranges の代わりにそのプロキシのIP範囲を書きます。何も置かないなら trusted_proxies の行は不要です。

handle_path /v1/* は、一致した /v1 の部分を取り除いてから中の処理に渡します。/v1/users は api:8081 に /users として届きます。取り除かずに渡したいときは handle /v1/* を使います。どちらにも一致しなかったリクエストを最後の handle で受け、404を返しています。log を書いたサイトだけ、アクセスログがJSONで標準エラー出力に出て、docker compose logs caddy で読めます。

設定を書き換えたら、構文を確かめてから反映します。reloadは無停止で、新しい設定の読み込みに失敗した場合は古い設定のまま動き続けます。

terminal
cd ~/stacks/proxy
docker compose exec caddy caddy fmt --diff /etc/caddy/Caddyfile
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

caddy adapt --config /etc/caddy/Caddyfile --pretty を実行すると、Caddyfileが内部で変換されたJSON設定を表示できます。ディレクティブがどの順番で処理されるかを確かめたいときに役立ちます。

reverse_proxyが自動で付けるヘッダーとWebSocket

nginxと違い、Caddyの reverse_proxy は何も書かなくても必要なヘッダーを揃えます。app.localhost でテストしたとき、バックエンドが受け取ったリクエストは次のとおりでした。Hostは元の名前のまま渡り、X-Forwarded-For・X-Forwarded-Host・X-Forwarded-Protoが追加され、Via: 2.0 Caddy も付いています。

バックエンドが受け取ったリクエスト(Caddy 2.11.4)
GET /hello
Host: app.localhost
User-Agent: curl/8.22.0
Accept: */*
Via: 2.0 Caddy
X-Forwarded-For: ::1
X-Forwarded-Host: app.localhost
X-Forwarded-Proto: https
Accept-Encoding: gzip

WebSocketも設定なしで通ります。kuma の例と同じ形のサイトにハンドシェイクを送ると、101 Switching Protocolsが返りました。Server-Sent Eventsのような text/event-stream の応答も、バッファにためずにそのまま流します。

変えたくなるのはHostだけです。転送先が別のドメイン名でHTTPSを提供している場合など、バックエンド側が自分の名前でのアクセスを要求するときは、reverse_proxy のブロックに header_up Host {upstream_hostport} を書きます。

Proxmox VE(8006番)のように自己署名証明書のHTTPSで待ち受ける機器へ転送すると、既定では証明書の検証に失敗して502になります。ログには tls: failed to verify certificate: x509: cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs のような理由が出ます。上のCaddyfileの pve.example.com のように tls_insecure_skip_verify を書けば通りますが、これはCaddyとバックエンドの間の通信を検証しないという意味です。同じ機器の中や、信頼できるLANの区間だけに限ってください。

LAN内だけで使うサーバーをHTTPSにする:DNS-01とtls internal

家族や社内のメンバーだけが使うNASや監視画面は、インターネットに公開せずにHTTPSにしたいところです。選択肢は2つあります。

1つ目は、公開ドメインのサブドメインでDNS-01チャレンジを使う方法です。認証局はDNSのTXTレコードしか見ないので、サーバー自体は外から届かなくても正規の証明書が取れ、端末側には何も入れずに済みます。ただし公式イメージにはDNSプロバイダ用のモジュールが入っていないため、xcaddyで組み込んだイメージを作ります。Cloudflareで管理しているドメインの場合は次のDockerfileです。

~/stacks/proxy/Dockerfile
FROM caddy:2.11-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare

FROM caddy:2.11
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

このビルドはGoのモジュールを取得してコンパイルするため、検証環境(4コア)では約7分かかりました。完成したイメージで caddy list-modules を実行し、dns.providers.cloudflare が表示されれば組み込まれています。compose.yamlでは image: caddy:2.11 の代わりに build: . と書き、environment: に CF_API_TOKEN: ${CF_API_TOKEN} を足して、トークン本体は chmod 600 にした .env に置きます。

~/stacks/proxy/conf/Caddyfile(DNS-01でワイルドカード証明書を取る例)
*.home.example.com {
	tls {
		dns cloudflare {env.CF_API_TOKEN}
		resolvers 1.1.1.1
	}

	@nas host nas.home.example.com
	handle @nas {
		reverse_proxy 192.168.10.20:5000
	}

	@grafana host grafana.home.example.com
	handle @grafana {
		reverse_proxy 192.168.10.30:3000
	}

	handle {
		abort
	}
}

*.home.example.com のワイルドカード証明書を1枚だけ取り、ホスト名で振り分けます。どれにも一致しない名前は abort で応答せずに切断します。APIトークンは、Cloudflareのダッシュボードで対象ゾーンのDNS編集だけを許可したものを作ります(メニュー名は執筆時点のもので変わることがあります)。アカウント全体のGlobal API Keyは使わないでください。

resolvers 1.1.1.1 は、TXTレコードが反映されたかをCaddyが確かめるときに使うDNSサーバーの指定です。LAN内のDNSで home.example.com を上書きしている(スプリットDNS)と、既定のリゾルバーではTXTレコードが見えず、反映待ちのまま時間切れになります。各名前のAレコードはLAN内のDNSで 192.168.10.5 のようなCaddyのアドレスに向けます。公開DNSにプライベートIPを書く方法もありますが、DNSリバインディング対策をしているリゾルバーはその応答を捨てます。Unboundでこれを許可する設定はUnboundでキャッシュDNSサーバーを構築する記事の private-domain の項にあります。

2つ目は tls internal です。サイトブロックに tls internal と書くと、公開ドメインでなくてもCaddyの内部認証局が証明書を発行します。端末に信頼させるのは /data/caddy/pki/authorities/local/root.crt のルート証明書で、Windowsなら「信頼されたルート証明機関」に、iPhoneならプロファイルとしてインストールしたあと証明書信頼設定で有効にします。Androidではユーザーが追加した証明書を信頼しないアプリが多く、ブラウザ以外では使えないことがあります。

選び方は単純で、家族のスマホやゲストの端末から使うならDNS-01、自分の検証用PCからしか開かない画面なら tls internal で十分です。前者は端末側の作業がゼロ、後者はドメインもDNSのAPIトークンも不要という違いです。

DNSのAPIトークンは、そのゾーンのレコードを書き換えられる強い権限です。漏れると、第三者があなたのドメインで証明書を取ったり、サブドメインを別のサーバーへ向けたりできます。.env をGitに入れない、トークンの権限を1ゾーンのDNS編集に限る、使わなくなったら失効させる、の3点を守ってください。

運用:リロード・ログ・証明書の確認

Caddyの管理APIはコンテナ内の localhost:2019 で待ち受け、caddy reload はこのAPIを通して設定を入れ替えます。compose.yamlで2019番を公開してはいけません。届いた相手が誰でも設定を書き換えられます。

証明書を取れたかどうかは、ログの certificate obtained successfully で確認できます。失敗しているときは同じ名前について obtaining certificate のあとにエラーが続くので、名前ごとに絞り込むと原因を追いやすくなります。実際に配信されている証明書の発行者と期限は、opensslで直接見るのが確実です。

terminal
docker compose logs caddy | grep -E 'obtaining certificate|certificate obtained'
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | openssl x509 -noout -issuer -enddate

Caddyのイメージを更新するときは docker compose pull && docker compose up -d です。/data を残している限り、証明書は引き継がれ、更新の途中で取り直しが走ることはありません。

つまずきポイント

listen tcp :443: bind: address already in use

Error: loading initial config: loading new config: http app module: start: listening on :443: listen tcp :443: bind: address already in use で起動しない場合は、同じホストでnginxやApache、別のコンテナが80番か443番を使っています。sudo ss -ltnp 'sport = :443' で相手を特定し、どちらか一方に寄せてください。

module not registered: dns.providers.cloudflare

公式イメージのまま dns cloudflare を書くと、getting module named 'dns.providers.cloudflare': module not registered で設定の読み込みに失敗します。上のDockerfileでビルドしたイメージに切り替えてください。ビルド済みなのに API token '…' appears invalid; ensure it's correctly entered and not wrapped in braces nor quotes と出る場合は、.env でトークンを引用符で囲んでいるか、環境変数がコンテナに渡っていません。

HTTPSのバックエンドだけ502になる

ログの tls: failed to verify certificate が原因です。自己署名証明書の機器なら、前の章の tls_insecure_skip_verify を、そのサイトのブロックだけに書きます。

「リダイレクトが繰り返し行われました」と表示される

Cloudflareのプロキシ(オレンジの雲)を経由していて、SSL/TLSの暗号化モードが「フレキシブル」になっていると、CloudflareはCaddyにHTTPで接続し、CaddyはHTTPSへリダイレクトし、Cloudflareはまたhttpで接続する、というループになります。オリジン側のCaddyは正規の証明書を持っているので、モードを「フル(厳密)」に変えれば解消します。

動作確認

ドメインを用意する前に、*.localhost の名前で一通りの動きを確かめられます。Caddyは localhost 系の名前に内部認証局の証明書を発行し、curlは *.localhost を常に自分自身(ループバック)として解決するため、DNSの設定もいりません。使い捨てのコンテナを起動し、バックエンドにはnginxの記事の動作確認で使った echo_headers.py を8080番と8081番で動かします。

terminal
docker run --rm -it -v ~/proxy-test:/root/proxy-test caddy:2.11 sh
# ここからコンテナの中(root)
apk add --no-cache curl openssl python3
cd /root/proxy-test && (python3 echo_headers.py 8080 &) && (python3 echo_headers.py 8081 &)
vi /etc/caddy/Caddyfile      # 下のローカル検証用の内容に置き換える
caddy start --config /etc/caddy/Caddyfile
cd /etc/caddy
/etc/caddy/Caddyfile(ローカル検証用)
app.localhost {
	reverse_proxy 127.0.0.1:8080
}

api.localhost {
	handle_path /v1/* {
		reverse_proxy 127.0.0.1:8081
	}
	handle {
		respond "not found" 404
	}
}
terminal
# caddy version
v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=

# curl -sI http://app.localhost/ | head -3
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://app.localhost/

# curl -s --cacert /data/caddy/pki/authorities/local/root.crt https://api.localhost/v1/users | grep -E '^(port|GET|Host|Via|X-)'
port=8081
GET /users
Host: api.localhost
Via: 2.0 Caddy
X-Forwarded-For: ::1
X-Forwarded-Host: api.localhost
X-Forwarded-Proto: https

# echo | openssl s_client -connect 127.0.0.1:443 -servername app.localhost 2>/dev/null | openssl x509 -noout -issuer
issuer=CN=Caddy Local Authority - ECC Intermediate

308でHTTPSへ誘導され、内部認証局のルート証明書を指定したcurlがエラーなしで応答を受け取り、/v1 が取り除かれて port=8081 に /users として届いていれば、自動HTTPSと振り分けは正しく動いています。本番のドメインに切り替えたあとは、同じopensslのコマンドの発行者が O = Let's Encrypt を含む表示に変わることを確認します。

よくある質問

リバースプロキシの設定方法は?

Caddyなら、Caddyfileに app.example.com { と改行し、reverse_proxy 127.0.0.1:8080 と書いて波かっこを閉じるだけです。ドメインのDNSがこのサーバーを指し、80番か443番が外から届けば、証明書の取得とHTTPSへのリダイレクトまで自動で設定されます。反映は caddy reload です。

プロキシとリバースプロキシの違いは何ですか?

フォワードプロキシは利用者側の出口に置き、社内PCなどの通信をまとめて中継・記録します。リバースプロキシはサーバー側の入口に置き、外から来たリクエストを裏のアプリへ配ります。Caddyやnginxは後者の役割で、TLSの終端と証明書の管理も受け持ちます。

CaddyとNginxはどちらを使うべきですか?

新しく作る小規模な構成ならCaddyが楽です。証明書の取得・更新、HTTPSへのリダイレクト、転送ヘッダー、WebSocketが設定なしで揃います。既存のnginx設定がある、細かいキャッシュやrewriteが必要、TCP/UDPのstreamプロキシを使う、といった場合はnginxのままにする方が移行の手間がかかりません。

CaddyはWindowsでも使えますか?

公式にWindows用の実行ファイルが配布されており、Caddyfileの書き方も同じです。caddy run で起動できますが、ログオフしても動かし続けるにはWindowsサービスとして登録する必要があります。また、Windows Defenderファイアウォールで80番と443番の受信を許可しないと、外部からの証明書の確認が届きません。

まとめ

Caddyを使うときに押さえるのは、公開ドメイン名をサイトアドレスに書くこと、80番か443番が外から届くこと(届かなければDNS-01)、/data をボリュームで残すこと、の3点です。この3つが揃えば、証明書の期限を管理する作業そのものがなくなります。LAN専用のサービスは、端末側に何も入れたくないならDNS-01のワイルドカード証明書、検証用なら tls internal と使い分けてください。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。