運用ガイド / Caddy + Cloudflare でワイルドカード証明書

Caddy を前に置いて HTTPS 化する 完成

対象: 手元やイントラの環境で MAESTRO を正式な証明書つきの HTTPS で使いたい人 / 更新日: 2026-08-10 / 由来: issue #1094

このガイドで作るもの

Caddy をリバースプロキシとして MAESTRO の前に置き、Cloudflare の DNS を使って Let's Encrypt からワイルドカード証明書を取ります。証明書の取得も 60 日ごとの更新も Caddy が自分でやるので、 運用中に手を入れる作業は残りません。

項目内容
証明書の取り方Let's Encrypt の DNS-01 チャレンジ。Cloudflare の API で一時的な TXT レコードを置いて検証する
ポート 80 の要否発行には不要。80 番が塞がれている回線でも取れる(ここが HTTP-01 との違い)
ワイルドカード取れる。DNS-01 でしか取れない
更新Caddy が自動で行い、自分で差し替える。再起動は不要
MAESTRO 側の証明書持たせない。TLS は Caddy で終端する
MAESTRO 自身に証明書を持たせる道もあります
server.tls.cert_file / key_file に運用者の証明書を指すやり方です。ただし MAESTRO は起動時に 1 回しか読みません。 自動更新した証明書は再起動するまで反映されず、期限が来るとそこで繋がらなくなります。 プロキシを前に置けば MAESTRO は証明書を持たなくなるので、この問題ごと消えます。

前提

証明書はドメイン単位で取ります。手元の機械が外から見えている必要はありません。 maestro.example.com の A レコードを 10.0.0.10 のような内側のアドレスに向ける使い方で問題ありません。

ステップ 1 — Cloudflare の API トークンを作る

Cloudflare のダッシュボードで My Profile → API Tokens → Create Token を開き、 Custom token を選んで次のとおり作ります。

設定値
Permissions(1つ目)Zone / Zone / Read
Permissions(2つ目)Zone / DNS / Edit
Zone ResourcesInclude / Specific zone / 対象のゾーン 1 つだけ
Global API Key は使わないこと
アカウント全体を操作できる資格情報です。上のトークンは「そのゾーンの DNS を読み書きする」だけに限られており、漏れたときの被害範囲がまるで違います。

権限が 2 つ必要な点に注意してください。DNS の編集だけでは足りず、ゾーンの読み取りも要ります(Caddy 側のプラグインがゾーン ID を引くため)。

ステップ 2 — DNS レコードを置く

Cloudflare の DNS 設定に、MAESTRO を出したいホスト名の A レコードを追加します。

TypeNameContentProxy status
AmaestroCaddy を動かす機械のアドレスDNS only(オレンジの雲を消す)
Proxied(オレンジの雲)にしない
内側のアドレスを指す場合、Cloudflare のプロキシを通すと届きません。証明書の発行自体は TXT レコードで行うので、A レコードは DNS only のままで足ります。

ステップ 3 — 設定ファイルを用意する

リポジトリのルートで見本をコピーし、値を埋めます。

cp .env.caddy.example .env.caddy
キー意味例
CLOUDFLARE_API_TOKENステップ1で作ったトークン—
MAESTRO_BASE_DOMAINワイルドカード証明書の対象。*. を付けた形で発行される。必ず書き換えるexample.com
MAESTRO_HOSTNAMEMAESTRO を出すホスト名。上の 1 つ下の階層にすること。必ず書き換えるmaestro.example.com
GATEWAY_HOSTNAMEゲートウェイも出す場合だけ(後述)。使わないなら行ごとコメントアウトのままgateway.example.com
GATEWAY_UPSTREAM_PORTゲートウェイの待ち受けポートを既定から変えている場合だけ。既定のままなら行ごとコメントアウトのまま4000
使わないキーは「行ごとコメントアウトしたまま」にすること
GATEWAY_HOSTNAME= のように等号だけ書いた空の行を残さないでください。 見本ファイルは該当行を # で始めた状態で配っているので、コピーしただけなら問題ありません。

Caddy の設定で使っている既定値つきの置換({$NAME:既定値})は、その変数が未設定のときだけ既定値に落ちます。 空文字を設定すると空文字がそのまま入り、次のようになります。 見本の CHANGE-ME.example は穴埋め用の値です。書き換えずに起動すると、存在しないドメインで発行を試み続けます。
Cloudflare のトークンは .env に書かないこと
.env は MAESTRO のコンテナの環境変数として読み込まれます。エージェントは Bash ツールを持っているので、 そこに置いたトークンはエージェントから読めます。 プロキシ向けの値だけを .env.caddy に分けてあるのはこのためで、こちらは Caddy のコンテナしか読み込みません。

ワイルドカードが覆うのは 1 階層分だけです。maestro.home.example.com を使いたいなら MAESTRO_BASE_DOMAIN=home.example.com(証明書は *.home.example.com)にします。

ステップ 4 — config.yaml をホスト側に置く(起動より先)

次のステップで config.yaml を書き換えます。その前に、設定ファイルがホスト側に残る形にしておく必要があります。 Docker の既定では config.yaml はイメージの中にあり、どのボリュームにも入っていません。 重ねがけを始めた最初の起動で MAESTRO のコンテナが作り直されるので、コンテナの中で書いた設定はそこで消えます。

OAuth(Google / Gitea)でログインしている環境は、必ずここを先にやること
config.yaml が初期状態に戻ると、認証の設定ごと消えます。 自動で復旧する仕組み(src/bridge/initial-admin.ts)はパスワードを持つローカル管理者がいる前提なので、 OAuth だけで運用している環境では働きません。認証設定が空のまま「意図的に認証なし」と解釈され、 認証がかかっていない状態で立ち上がります。

そのとき Caddy はもう HTTPS の口を開けています。過去のデータをすべて抱えたまま、誰でも入れる状態になり、 エージェントは Bash ツールを持っているので、実質的に遠隔からの任意コード実行になります。 重ねる前に、必ず設定ファイルをホストへ退避してください。

この先の 4-2 を飛ばした場合も、行き着く先は同じです。ホスト側にファイルを置かずに起動すると Docker が同名のディレクトリを作り、MAESTRO はそれを読もうとして失敗しますが、 その失敗は起動を止めません(設定を読めなかったという警告が 1 行出るだけで、設定が空のまま先へ進みます)。 OAuth だけの環境では、そこから上と同じ経路に落ちます。

4-1. 上書きファイル側の指定(確認だけ・作業なし)

docker-compose.caddy.yml には、設定ファイルをホストに置くための指定がすでに入っています。書き足す作業はありません。

services:
  maestro:
    volumes:
      - ./config.yaml:/app/config.yaml

リポジトリのルートの config.yaml がコンテナの中の設定ファイルになります。 以降は設定画面からの保存も、手で書いた変更も、コンテナを作り直しても残ります。 :ro は付けないでください(設定画面からの保存が書き込むため)。

ベースの docker-compose.yml で設定ファイルを別のパスから渡している場合は、その指定が捨てられます
受け渡しはコンテナ側のパス(/app/config.yaml)を鍵に合成されるため、 ホスト側に ./config-prod.yaml のような別名を書いていると、上書きファイル側の ./config.yaml が勝ちます。 警告もエラーも出ないまま、いままで使っていた設定が読まれなくなります。 自分のパスに揃えるか、docker-compose.caddy.yml の該当行を自分のパスに書き換えてください。 そのまま重ねると、標準の名前のファイルがホストに無い限り、下の 4-2 の警告と同じ結末になります。

4-2. ホスト側の config.yaml を用意する

起動より先に、実体のファイルを置くこと
ホストに ./config.yaml が無いまま起動すると、Docker が config.yaml という名前のディレクトリを作ります。 MAESTRO はそれを設定ファイルとして読もうとして失敗しますが、この失敗では止まりません。 設定は認証設定を持たない状態で読み込まれます。

そこから先は環境で割れます。パスワードを持つローカル管理者がいる環境と、新規インストールは起動を拒否します (認証を書き戻そうとしてディレクトリに書けず、その旨を名指しして止まります)。 しかし OAuth(Google / Gitea)だけで運用している環境は、認証なしで起動してしまいます。 書き戻しの分岐がどれも成立せず、空の設定が「意図的に認証なし」と解釈されるためです。 この節をやってから起動してください。やり直しの手当てではなく、これが唯一の防ぎ方です。

すでに MAESTRO を動かしている場合 — 重ねる前に、動いているコンテナから取り出します。

docker cp maestro:/app/config.yaml ./config.yaml

これから始める場合 — イメージが同梱する初期状態と同じ 1 行だけのファイルを作ります。

printf 'config_version: 2\n' > ./config.yaml

持ち主と書き込み権限 — コンテナの中の MAESTRO は root ではなく node(UID 1000)で動くので、 ホスト側のファイルはその UID から書ける状態にしておきます。上の 2 通りはどちらも、 実行した人の持ち物になります。ホストの利用者が UID 1000 でないなら、所有者を合わせてください。

sudo chown 1000:1000 ./config.yaml

食い違ったままだと、すでに動いている環境では設定画面からの保存が静かに失敗します(起動は通るので気づきにくい)。 新規インストールでは起動を拒否し、書き込めない旨を名指しするメッセージが出ます。

配布用のドキュメントとの食い違いについて — 配布版の docs/docker.md(この repo では oss/overlay/docs/docker.md)には cp config.yaml.example config.yaml で作る案内がありますが、そちらは完成済みの見本なので、 インストール済みと判定されてセットアップウィザードが出ません(さらに、動いていない localhost の LLM を指した状態になります)。 イメージが同梱する初期状態は 1 行だけのファイルなので、ここに書いたほうに従ってください。

どちらの場合も、この先の編集はすべてこの、ホスト側の config.yaml に対して行います。 コンテナの中に入って書く必要はありません。

ステップ 5 — MAESTRO 側の設定を 2 か所直す

ステップ4で用意したホスト側の config.yaml を編集します。 この 2 つを外すと、動かないか、危ない状態になります。

5-1. MAESTRO 自身の TLS は切る

Caddy が TLS を終端するので、MAESTRO 側でも終端すると二重になって噛み合いません。 config.yaml の server.tls ブロックを書かないか、enabled: false にします。

# config.yaml — プロキシ構成では server ブロックごと省くのが確実
# server:
#   tls:
#     enabled: false

Docker のイメージが同梱する初期 config.yaml には server ブロックが入っていないため、 Docker で始めた環境は既定で TLS が切れています。自分で有効にした覚えがなければ、この項目の作業は不要です。

5-2. 安全なクッキーを有効にする

auth.secure_cookie: true にします。効き目は 2 つです。

auth:
  secure_cookie: true
入れると起きること入れないとどうなるか
セッションのクッキーに secure 属性が付く クッキーが平文の HTTP でも送られる状態のままになります
転送元の情報を信頼する設定(trust proxy)が連動して入り、接続元アドレスの判定が転送ヘッダを見るようになる これが本題です。Caddy を挟むと、MAESTRO からは全利用者が同じ接続元アドレスに見えます。 ログイン試行の制限は接続元アドレスごとに数えているので、全員がひとつの枠を共有することになり、 全員合わせて 15 分間に 10 回失敗すると、15 分間まとめて締め出されます。 1 人が 10 回間違える必要はありません。別々の 10 人が 1 回ずつ打ち間違えても同じ枠に積まれます。 発動する前なら、誰か 1 人がログインに成功した時点で積んだ回数は消えます。 発動したあとは成功では戻らず(締め出しの判定はパスワードの照合より手前で打ち切ります)、15 分が明けるのを待つことになります。

この締め出しは、メールとパスワードでログインする構成のときに起きます(OAuth だけの環境には、この制限そのものがありません)。 なお この設定が無くても HTTPS 越しのログイン自体は通ります。プロトコルによって行き先が変わるリダイレクトは MAESTRO のコードにありません。 「入れないと繋がらない」ではなく「入れないと、全員が道連れで締め出される」と考えてください。

この設定を入れると、Caddy を通さない直接アクセスではログインできなくなります
secure 属性の付いたクッキーは平文の HTTP では送られません。切り分けのために http://<host>:9876 へ直接繋いで確かめる、という使い方はできなくなります(そもそもステップ6でポートを閉じます)。

ステップ 6 — 起動する

6-1. 2 つのファイルを既定にする(推奨)

先に .env(.env.caddy ではありません)に次の 1 行を足してください。 以降は docker compose のどのコマンドでも 2 つのファイルが読まれます。

COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml
これを設定しないと、-f を付け忘れた起動が 9876 番を開き直します
重ねがけを始めたあとに、習慣で docker compose up -d とだけ打つと、Compose はベースのファイルだけを見ます。 MAESTRO のコンテナが作り直され、平文の 9876 番がホストに再び公開されます。 Caddy のコンテナは孤児として検出されるだけで止まらないので、HTTPS の口と平文の口が同時に開いた状態になります。 ブラウザから見ると何も変わっていないので、気づきません。

COMPOSE_FILE は .env に書いて構いません。ステップ3で「トークンは .env に書くな」と書いたのは、 .env が MAESTRO のコンテナの環境変数として読み込まれ、エージェントから読めてしまうからです。 COMPOSE_FILE は秘密ではなく、Compose 自身がどのファイルを読むかの指定なので、読まれても実害がありません。
(COMPOSE_FILE は Compose の実行時に解釈されるため、.env に置く必要があります。.env.caddy に書いても効きません。)

6-2. 起動する

docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build

(6-1 を設定済みなら docker compose up -d --build だけで同じです。)

初回は Caddy のイメージをビルドします。公式の配布物には Cloudflare の DNS 対応が入っていないため、 xcaddy で組み込んだものを作ります(deploy/caddy/Dockerfile)。数分かかりますが、初回だけです。

ログで発行を確認します。

docker compose -f docker-compose.yml -f docker-compose.caddy.yml logs -f caddy

certificate obtained successfully が出れば取れています。 あとは https://maestro.example.com を開くだけです。

初回は検証用の発行先で試し撃ちしておくと安全です
Let's Encrypt は発行の失敗にも回数制限があり、設定を直したあとしばらく取れない状態に入ることがあります。 deploy/caddy/Caddyfile の先頭に置く global options block に acme_ca https://acme-staging-v02.api.letsencrypt.org/directory を 1 行足すと、検証用の発行先に向きます。 通ることを確かめたらこの行を消して起動し直せば、本番の発行先から取り直します(検証用の証明書はブラウザに信頼されないので、そのままでは使えません)。 検証用と本番の証明書は caddy-data の中で別々に置かれるので、ボリュームを消す必要はありません。

何が変わったか

前後
外に出ているポートMAESTRO の 9876(全ネットワーク面)Caddy の 80 / 443 のみ
MAESTRO の 9876ホストに公開公開しない。Docker の内側から Caddy だけが届く
TLS の終端MAESTRO 自身(自己署名)または平文Caddy(正式な証明書)
config.yamlイメージの中。コンテナを作り直すと初期状態に戻るホスト側の ./config.yaml。作り直しても残る

ポートを閉じているのは docker-compose.caddy.yml の ports: !reset [] という 1 行です。 Compose の profiles ではこれができません。ports は {ip, target, published, protocol} を一意キーとして追記されるだけで、 元のファイルが書いた公開を取り消す手段が無いためです。 重ねたファイルから元の値を消すには !reset を使います(!override [] と書いても同じ結果になります)。

Compose の版について
!reset 自体は v2.24 より前から使えます。v2.24 以上を要求しているのは ベースの docker-compose.yml が使っている env_file の新しい書き方(path: / required:)のほうです。 そのため v2.24 未満の環境では、重ねる以前にベースのファイルが読めません。Compose を上げてください。

ゲートウェイ(AAO Gateway)も HTTPS で出す場合

ゲートウェイを使っていないなら、この節は読み飛ばして構いません。

ゲートウェイ(この MAESTRO 自身を OpenAI 互換の中継口として公開する仕組み)は、 MAESTRO 本体とは別のポートで待ち受けます。同じプロセスで動かしていても口は別です(既定 4000)。 プロキシを通すなら、そちら向けの設定も要ります。

  1. Cloudflare に gateway の A レコードを追加する(ステップ2と同じ要領。同じアドレスでよい)。
  2. .env.caddy の GATEWAY_HOSTNAME のコメントを外して値を入れる。コメントを外したら必ず値を書くこと(等号だけの空の行は、Caddy を起動しなくします)。
  3. config.yaml の gateway.listen_port を既定(4000)から変えている場合だけ、GATEWAY_UPSTREAM_PORT のコメントを外して同じ値を書く。既定のままならコメントアウトのままにする(外して空にすると、黙って 80 番へ中継されます)。
  4. 再起動する(up -d をもう一度)。

証明書は本体と同じワイルドカード 1 枚で覆われるので、追加の発行はありません。

ゲートウェイのホスト名も MAESTRO_BASE_DOMAIN の 1 つ下の階層にすること
別のドメインにしたり、2 階層下(gw.sub.example.com のような形)にしたりすると、 ワイルドカード証明書もサイトの照合も覆いません。そのホスト名は無反応になり、Caddy は何も言いません。 本体のホスト名と同じ制約です。
気にしなくてよいこと理由
応答の溜め込み ゲートウェイは生成されたトークンを 1 つずつ流します。Caddy は中継する応答を溜め込まないので、そのまま届きます(nginx は既定で溜め込むため、そちらを選んだ場合は明示的に切る必要がありました)。
長い生成の時間切れ ゲートウェイは 1 リクエストに最大 600 秒(gateway.request_timeout_sec)を認めています。Caddy は中継に時間制限を設けないので、途中で切られません。
流量制限が一緒くたになる ゲートウェイの制限は発行した鍵ごとに数えており、接続元のアドレスは見ていません。プロキシを挟んで全員が同じアドレスに見えても、混ざりません。
これはゲートウェイの流量制限の話です。ログイン試行の制限は別物で、そちらは接続元のアドレスで数えます(ステップ5-2 の安全なクッキーを入れること)。
ゲートウェイのポートを自分でホストに公開していた場合
docker-compose.yml に 4000:4000 のような行を足していたなら、その行は消してください。 Caddy 経由にしたのにポートも出したままだと、平文の口が残ります。

よくある詰まり

症状原因 / 対処
env file .env.caddy not found で起動しない 見本をコピーしていない。cp .env.caddy.example .env.caddy を実行する。意図的に失敗させています(トークン無しで黙って起動しても証明書は取れないため)。
API token ... appears invalid CLOUDFLARE_API_TOKEN の値の形式が合っていない——空、引用符や波括弧で囲んでいる、貼り付けが途中で欠けている、など。値だけを裸で書く。 この検査は設定の読み込みの早い段階で走るので、これが出ている間はほかの設定の誤りは表示されません。 トークンを直して初めて、下の行のようなエラーが出てくることがあります。
module value cannot be null で Caddy が起動しない MAESTRO_HOSTNAME / GATEWAY_HOSTNAME のどれかが空文字になっている。 とくに GATEWAY_HOSTNAME= と等号だけ書いた行が原因になりがちです(使わないなら行ごとコメントアウトに戻す)。 MAESTRO_BASE_DOMAIN を空にしてもこの文言は出ません(下の行を参照)。
証明書が取れず、ログに *. という対象が出る MAESTRO_BASE_DOMAIN が空文字。Caddy の設定検証は通ってしまうため、エラーは出ません。 成立しない名前で発行を要求し続けるので、気づかないと Let's Encrypt の失敗回数の制限に当たります。値を書く。
ゲートウェイのホスト名だけ 502 になる GATEWAY_UPSTREAM_PORT= と等号だけ書いていて、中継先が maestro:80 になっている。 Caddy は何も言いません。既定(4000)のままなら行ごとコメントアウトに戻す。変えているなら値を書く。
ゲートウェイのホスト名が何も返さない/繋がらない GATEWAY_HOSTNAME が MAESTRO_BASE_DOMAIN の 1 つ下の階層になっていない。別ドメインや 2 階層下は覆われません。
expected 1 zone, got 0 Cloudflare にゾーンを問い合わせた結果が 0 件。原因はふつう次のどれかです。
  • トークンの Zone Resources を別のゾーンに絞っている
  • トークンに Zone / Zone / Read を付けていない
  • そのアカウントにゾーンとして載っていない(ネームサーバーが Cloudflare を向いていない)
timed out waiting for record to fully propagate 手元の名前解決が古い答えを返している。deploy/caddy/Caddyfile の resolvers 1.1.1.1 1.0.0.1 が効くはずですが、外向きの DNS が塞がれている環境では通りません。
bind: address already in use ホストの 80 / 443 を他のものが使っている。そちらを止めるか、docker-compose.caddy.yml の ports を空いている番号に変える(443 を変えると URL にポート番号が必要になります)。
全員が急にログインできなくなった(15 分ほどで戻る) auth.secure_cookie: true を入れていない。Caddy を挟むと全利用者が同じ接続元アドレスに見えるため、 ログイン試行の制限がひとつの枠を共有します。全員合わせて 15 分間に 10 回失敗すると、15 分間まとめて締め出されます。 発動する前なら誰か 1 人の成功で積んだ回数は消えますが、発動したあとは成功では戻りません。 ステップ5-2 を確認する(メールとパスワードでログインする構成のときに起きます)。
ログインしても弾かれる/ログイン画面に戻る auth.secure_cookie: true を入れた状態で、平文の口に直接繋いでいる。secure 属性の付いたクッキーは HTTP では送られません。https:// のホスト名で開く。
設定を書いたのに、起動したら元に戻っている ホスト側の config.yaml を用意せずに重ねがけを始めた。ステップ4 をやり直す。OAuth だけで運用している場合は認証ごと消えているので、まずポートを閉じてから復旧すること。
設定ファイルが読めないという警告が出る/MAESTRO が起動を拒否する ホストに ./config.yaml が無いまま起動し、Docker が同名のディレクトリを作っている。 そのディレクトリを消し、ステップ4-2 のとおりファイルを置き直す。 読めなかったこと自体では起動は止まりません——設定は認証設定を持たない状態で読み込まれ、 そのあと環境によって割れます。パスワードを持つローカル管理者がいる環境と新規インストールは起動を拒否しますが、 OAuth だけの環境は認証なしで立ち上がっています。その場合はまずポートを閉じてから復旧すること。
OAuth(Google / Gitea)でログインできない コールバック先が古いまま。auth.providers.*.callback_url と、プロバイダ側に登録した URL を https://maestro.example.com/... に直す。
証明書を取り直してばかりで制限に当たった caddy-data ボリュームを消している。ここに証明書と ACME アカウント鍵が入るので、消さないこと。

手動で組む場合(Docker を使わない)

Docker を使わず、ホストに直接 Caddy を入れる場合も考え方は同じです。違いは 2 点だけです。

  1. Caddy を自分でビルドする。公式の配布物には Cloudflare 対応が入っていません。 xcaddy build --with github.com/caddy-dns/cloudflare で作るか、 公式のダウンロードページで caddy-dns/cloudflare を選んで取得します。
  2. 中継先をコンテナ名からホストの口に変える。deploy/caddy/Caddyfile の reverse_proxy maestro:9876 を reverse_proxy 127.0.0.1:9876 にします。 合わせて MAESTRO 側は HOST=127.0.0.1 で起動し、外から直接届かないようにします。

環境変数(CLOUDFLARE_API_TOKEN など)は、systemd で動かすなら systemctl edit caddy で Environment= として渡します。Caddyfile に直接書かないでください。 ステップ5 の MAESTRO 側の設定(TLS を切る・安全なクッキー)は Docker の場合と同じです。 ステップ4(config.yaml をホスト側に置く)は不要です。Docker を使わない構成では設定ファイルは最初からホストにあり、消えません。

関連ファイル

パス役割
docker-compose.caddy.ymlベースに重ねる上書きファイル。Caddy を足し、MAESTRO のポート公開を消す
deploy/caddy/DockerfileCloudflare 対応を組み込んだ Caddy を作る 2 段ビルド
deploy/caddy/Caddyfile証明書の取り方と中継先の定義
.env.caddy.exampleトークンとホスト名の見本。実物(.env.caddy)は追跡していない