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 で終端する |
server.tls.cert_file / key_file に運用者の証明書を指すやり方です。ただし MAESTRO は起動時に 1 回しか読みません。
自動更新した証明書は再起動するまで反映されず、期限が来るとそこで繋がらなくなります。
プロキシを前に置けば MAESTRO は証明書を持たなくなるので、この問題ごと消えます。
docker compose version で確認)。
証明書はドメイン単位で取ります。手元の機械が外から見えている必要はありません。
maestro.example.com の A レコードを 10.0.0.10 のような内側のアドレスに向ける使い方で問題ありません。
Cloudflare のダッシュボードで My Profile → API Tokens → Create Token を開き、 Custom token を選んで次のとおり作ります。
| 設定 | 値 |
|---|---|
| Permissions(1つ目) | Zone / Zone / Read |
| Permissions(2つ目) | Zone / DNS / Edit |
| Zone Resources | Include / Specific zone / 対象のゾーン 1 つだけ |
Cloudflare の DNS 設定に、MAESTRO を出したいホスト名の A レコードを追加します。
| Type | Name | Content | Proxy status |
|---|---|---|---|
| A | maestro | Caddy を動かす機械のアドレス | DNS only(オレンジの雲を消す) |
リポジトリのルートで見本をコピーし、値を埋めます。
cp .env.caddy.example .env.caddy
| キー | 意味 | 例 |
|---|---|---|
CLOUDFLARE_API_TOKEN | ステップ1で作ったトークン | — |
MAESTRO_BASE_DOMAIN | ワイルドカード証明書の対象。*. を付けた形で発行される。必ず書き換える | example.com |
MAESTRO_HOSTNAME | MAESTRO を出すホスト名。上の 1 つ下の階層にすること。必ず書き換える | maestro.example.com |
GATEWAY_HOSTNAME | ゲートウェイも出す場合だけ(後述)。使わないなら行ごとコメントアウトのまま | gateway.example.com |
GATEWAY_UPSTREAM_PORT | ゲートウェイの待ち受けポートを既定から変えている場合だけ。既定のままなら行ごとコメントアウトのまま | 4000 |
GATEWAY_HOSTNAME= のように等号だけ書いた空の行を残さないでください。
見本ファイルは該当行を # で始めた状態で配っているので、コピーしただけなら問題ありません。
{$NAME:既定値})は、その変数が未設定のときだけ既定値に落ちます。
空文字を設定すると空文字がそのまま入り、次のようになります。
GATEWAY_HOSTNAME= → Caddy が module value cannot be null で起動しない(スタック全体が上がらない)GATEWAY_UPSTREAM_PORT= → エラーも警告も出ないまま maestro:80 へ中継され、502 になるMAESTRO_BASE_DOMAIN= → 証明書の対象が *. という成立しない名前になり、黙って発行を要求し続けるCHANGE-ME.example は穴埋め用の値です。書き換えずに起動すると、存在しないドメインで発行を試み続けます。
.env に書かないこと.env は MAESTRO のコンテナの環境変数として読み込まれます。エージェントは Bash ツールを持っているので、
そこに置いたトークンはエージェントから読めます。
プロキシ向けの値だけを .env.caddy に分けてあるのはこのためで、こちらは Caddy のコンテナしか読み込みません。
ワイルドカードが覆うのは 1 階層分だけです。maestro.home.example.com を使いたいなら
MAESTRO_BASE_DOMAIN=home.example.com(証明書は *.home.example.com)にします。
config.yaml をホスト側に置く(起動より先)
次のステップで config.yaml を書き換えます。その前に、設定ファイルがホスト側に残る形にしておく必要があります。
Docker の既定では config.yaml はイメージの中にあり、どのボリュームにも入っていません。
重ねがけを始めた最初の起動で MAESTRO のコンテナが作り直されるので、コンテナの中で書いた設定はそこで消えます。
config.yaml が初期状態に戻ると、認証の設定ごと消えます。
自動で復旧する仕組み(src/bridge/initial-admin.ts)はパスワードを持つローカル管理者がいる前提なので、
OAuth だけで運用している環境では働きません。認証設定が空のまま「意図的に認証なし」と解釈され、
認証がかかっていない状態で立ち上がります。
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 の警告と同じ結末になります。
config.yaml を用意する./config.yaml が無いまま起動すると、Docker が config.yaml という名前のディレクトリを作ります。
MAESTRO はそれを設定ファイルとして読もうとして失敗しますが、この失敗では止まりません。
設定は認証設定を持たない状態で読み込まれます。
すでに 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 に対して行います。
コンテナの中に入って書く必要はありません。
ステップ4で用意したホスト側の config.yaml を編集します。
この 2 つを外すと、動かないか、危ない状態になります。
Caddy が TLS を終端するので、MAESTRO 側でも終端すると二重になって噛み合いません。
config.yaml の server.tls ブロックを書かないか、enabled: false にします。
# config.yaml — プロキシ構成では server ブロックごと省くのが確実
# server:
# tls:
# enabled: false
Docker のイメージが同梱する初期 config.yaml には server ブロックが入っていないため、
Docker で始めた環境は既定で TLS が切れています。自分で有効にした覚えがなければ、この項目の作業は不要です。
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 のコードにありません。 「入れないと繋がらない」ではなく「入れないと、全員が道連れで締め出される」と考えてください。
http://<host>:9876 へ直接繋いで確かめる、という使い方はできなくなります(そもそもステップ6でポートを閉じます)。
先に .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 に書いても効きません。)
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 を開くだけです。
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 [] と書いても同じ結果になります)。
!reset 自体は v2.24 より前から使えます。v2.24 以上を要求しているのは
ベースの docker-compose.yml が使っている env_file の新しい書き方(path: / required:)のほうです。
そのため v2.24 未満の環境では、重ねる以前にベースのファイルが読めません。Compose を上げてください。
ゲートウェイを使っていないなら、この節は読み飛ばして構いません。
ゲートウェイ(この MAESTRO 自身を OpenAI 互換の中継口として公開する仕組み)は、 MAESTRO 本体とは別のポートで待ち受けます。同じプロセスで動かしていても口は別です(既定 4000)。 プロキシを通すなら、そちら向けの設定も要ります。
gateway の A レコードを追加する(ステップ2と同じ要領。同じアドレスでよい)。.env.caddy の GATEWAY_HOSTNAME のコメントを外して値を入れる。コメントを外したら必ず値を書くこと(等号だけの空の行は、Caddy を起動しなくします)。config.yaml の gateway.listen_port を既定(4000)から変えている場合だけ、GATEWAY_UPSTREAM_PORT のコメントを外して同じ値を書く。既定のままならコメントアウトのままにする(外して空にすると、黙って 80 番へ中継されます)。up -d をもう一度)。証明書は本体と同じワイルドカード 1 枚で覆われるので、追加の発行はありません。
MAESTRO_BASE_DOMAIN の 1 つ下の階層にすること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 件。原因はふつう次のどれかです。
|
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 を使わず、ホストに直接 Caddy を入れる場合も考え方は同じです。違いは 2 点だけです。
xcaddy build --with github.com/caddy-dns/cloudflare で作るか、
公式のダウンロードページで caddy-dns/cloudflare を選んで取得します。
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/Dockerfile | Cloudflare 対応を組み込んだ Caddy を作る 2 段ビルド |
deploy/caddy/Caddyfile | 証明書の取り方と中継先の定義 |
.env.caddy.example | トークンとホスト名の見本。実物(.env.caddy)は追跡していない |