docker-compose.prod.yml を書いたのに、本番が開発モードで動いていた
本番用の設定ファイルを用意していたのに、実際には開発用の起動コマンドで動いていました。サイトは正常に見えていて、誰も困っていませんでした。原因は「書かなかった設定は、消えずに引き継がれる」という Docker Compose のマージ規則です。気づいた経緯と、直す前に見つけたもう1つの罠まで書きます。

動いていた。でも本番ではなかった
本番用に docker-compose.prod.yml を用意していました。開発用とは別の設定で動かすためのファイルです。
ある日、まったく別の調べものをしていて、本番のコンテナが何のコマンドで動いているかをたまたま見ました。
npm run dev
開発サーバーでした。
サイトは動いていました。記事も表示されていて、検索にも出ていました。誰も困っていませんでした。それでも、本番ではありませんでした。
この記事は、なぜそうなったかと、なぜ何か月も気づかなかったかの話です。
何を書いていて、何が起きていたか
Docker Compose では、共通の設定ファイルと、環境ごとの設定ファイルを重ねて使えます。
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
左が土台、右が本番用の上書き。そう思っていました。
土台のファイル(開発用)には、こう書いてありました。
# docker-compose.yml
services:
frontend:
command: npm run dev # 開発サーバーで起動
volumes:
- ./frontend:/app # 手元のファイルをコンテナに直接見せる
そして本番用のファイルには、この2つを書いていませんでした。
# docker-compose.prod.yml
services:
frontend:
environment:
- SITE_URL=https://example.com
# command と volumes は書いていない
「書かなければ、本番用のイメージの設定が使われる」と思っていました。
実際には逆でした。
書かなかったキーは引き継がれる — Docker Compose のマージ規則
Docker Compose のマージは、サービス単位で、キーごとに行われます。そして——
キーを省略すると、前のファイルの値がそのまま引き継がれます。
つまり、こうなっていました。
| 項目 | 本番用ファイル | 実際に使われた値 |
|---|---|---|
environment | 一部だけ書いた | ⚠️ 書いたキーだけ本番値。書かなかったキーは開発用のまま |
command | 書かなかった | ❌ 開発用の npm run dev |
volumes | 書かなかった | ❌ 開発用の bind mount |
書いた設定だけが正しく、書かなかった設定は開発用のままでした。
1行目がいちばん厄介です。 environment はキーごとにマージされるので、「書いた」「書かない」がファイル単位ではなく変数ひとつずつで決まります。
# 土台(開発用)
environment:
- NODE_ENV=development
- SITE_URL=http://localhost
- DEBUG=true
# 本番用ファイル
environment:
- SITE_URL=https://example.com
この結果はこうなります。
NODE_ENV: development # ← 残る。本番なのに開発用
DEBUG: "true" # ← 残る
SITE_URL: https://example.com
SITE_URL を直したので直った気になっていて、隣の NODE_ENV が開発用のまま残ります。
そして NODE_ENV は、多くの道具が「開発中かどうか」を判断するために見ている変数です。つまり——
起動コマンドを本番用に直しても、
NODE_ENVが開発用のままなら、一部は開発の動きをし続けます。
この記事の話(本番が開発モードだった)には、入口が2つあります。 起動コマンドと、この変数です。
「上書きファイル」という呼び方から、書いていない部分は無効になると思い込んでいました。そうではなく、書いていない部分は、土台がそのまま生き続けます。
マージの仕方は、項目の種類で違います。
| 種類 | どうマージされるか |
|---|---|
command のような単一の値 | 書けば置き換わる |
environment のようなキーと値の集まり | キーごとに置き換わる。書かなかったキーは残る |
volumes のような並び | コンテナ側の同じ場所を指すものは置き換わる。違う場所なら追加される |
なぜ何か月も気づかなかったか(本番が開発モードのままだった)
全部うまくいっているように見えたからです。
- サイトは表示されていた
- 記事も画像も正しく出ていた
- エラーも警告も出ていなかった
それどころか、便利ですらありました。
開発サーバーはファイルの変更を見つけて自動で作り直します。そして開発用の設定は手元のファイルをコンテナに直接見せていました。つまり——
サーバーで git pull する → それだけで本番の見た目が変わる
再ビルドが要りませんでした。 私はそれを「よくできた仕組み」だと思っていました。実際には、本番が開発モードだったからそうなっていただけです。
壊れていることの証拠が、便利さとして現れていました。 これがいちばん気づきにくい形だと思います。
直し方:volumes: !reset で、土台を打ち切る
command は別の値を書けば置き換わります。
command: npm run start
問題は volumes のほうでした。
volumes は「コンテナ側の同じ場所を指すものは置き換わり、違う場所なら追加される」という形でマージされます。つまり ./frontend:/app を別のものに差し替えたいだけなら、同じ /app を書けば置き換わります。
volumes:
- prodvol:/app # 同じ /app なので、土台の bind mount と置き換わる
ただし「何もマウントしない」状態にしたいときは、書くものがありません。そのための指定が用意されています。
volumes: !reset []
!reset は「土台の値を引き継がず、ここで打ち切る」という意味です。
⚠️ これだけでは、本番が落ちます
command を書き換えただけでは足りません。
npm run start は「先に作っておいたもの」を出すコマンドです。作る工程を通っていないと、出すものがありません。
そして bind mount を外した瞬間、手元のファイルで覆い隠されていた部分が、イメージの中身に切り替わります。そこに作った結果が入っていなければ——
Error: Could not find a production build in the '.next' directory
起動に失敗して、そのまま再起動を繰り返します。サイトが落ちます。
必要なのは3つです。
1. 先に作る工程を通したイメージを用意する
2. up ではなく build → up の順で上げ直す(up だけでは前のイメージが使い回される)
3. NODE_ENV が本番になっているか確かめる(次の節)
そもそも、こうしておけば起きません
開発用の設定を土台のファイルに書いていたのが、この問題の入り口でした。
Compose には docker-compose.override.yml というファイル名の決まりがあって、何も指定せずに docker compose を実行すると、これを自動で読みます。逆に、ファイルを明示すれば読みません。
土台を本番の形にして、開発用をそちらに移します。
# docker-compose.yml(土台=本番の形)
command: npm run start
# docker-compose.override.yml(開発時だけ自動で読まれる)
command: npm run dev
volumes:
- ./frontend:/app
docker compose up -d # 開発。override が自動で読まれる
docker compose -f docker-compose.yml up -d # 本番。override を読まない
本番では引き継ぐものがそもそもありません。 !reset も要らず、バージョンも気にしなくて済みます。
私は「消し方」を調べて直しましたが、本当は「置き方」の問題でした。 開発用を土台に書いた時点で、本番のたびに打ち消す作業が必要になります。 打ち消し忘れが起きる構造を、自分で作っていました。
⚠️ 直す前に、もう1つ罠が見つかりました
これは実際には踏んでいません。 直す手順を調べている途中で気づいたものです。
開発モードをやめると、先に全部作っておいて、あとはそれを出すだけの形になります。ここに落とし穴があります。
「先に作る」ときに、環境変数がファイルの中に焼き込まれます。
私のサイトでは、robots.txt というファイルが自動で作られます。中身にサイトのアドレスが入ります。
Sitemap: https://example.com/sitemap.xml
このアドレスは環境変数から取っています。そして開発中の初期値は http://localhost でした。
作るときにその環境変数を渡していなければ、こうなります。
Sitemap: http://localhost/sitemap.xml
これが検索エンジンに読まれます。 サイトは正常に表示され、HTTPも200を返し、見た目には何の問題もありません。
開発モードのままなら、この問題は起きませんでした。毎回その場で作っていたので、実行時の値が使われていたからです。
直す作業そのものが、新しい壊し方を持ち込みます。 私は運良く、直す前の調べもので気づきました。気づかなければ、「本番モードにした日から検索が壊れた」という形で出ていたはずです。
対処は、作るときにも環境変数を渡すことでした。実行するときに渡すだけでは足りません。
# docker-compose.yml
services:
frontend:
build:
context: ./frontend
args: # ← 作るときに渡す
- SITE_URL=https://example.com
environment: # ← 実行するときに渡す(両方要る)
- SITE_URL=https://example.com
# Dockerfile
ARG SITE_URL # ← 受け取る
ENV SITE_URL=$SITE_URL # ← ビルド中のコマンドから見えるようにする
RUN npm run build
「設定を書いた」と「設定が効いている」は違います
この記事の教訓はこれだけです。
設定ファイルを書いても、それがどう解釈されたかは分かりません。 ファイルが複数あって重なるなら、なおさらです。
確かめる方法があります。
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
これは何も起動しません。 「複数のファイルを重ねた結果、最終的にどういう設定になったか」をそのまま出力するだけです。
services:
frontend:
command: npm run dev # ← ここで気づける
volumes:
- ./frontend:/app # ← ここでも気づける
私はこれを、壊れていた期間に一度も実行していませんでした。
そして、いま動いているコンテナが実際に何で動いているかも見られます。
docker compose top
引数なしで全サービス分が出ます。 サービス名(frontend など)を付けて絞ることもできます。
マウントの状態はこちらで見られます。
docker compose ps -q frontend | xargs docker inspect --format '{{json .Mounts}}'
設定ファイルではなく、動いているものを見るのがいちばん確実です。
まとめ
書かなかったキーは、消えずに土台から引き継がれる
消したいときは !reset と書く(v2.24以降)
重ねた結果は docker compose config で読める
動いているものは docker top / docker inspect で見る
いちばん怖かったのは、壊れていた期間、何ひとつ困らなかったことです。エラーも出ず、サイトは動き、むしろ更新が楽でした。
「動いている」は「正しい」の証拠になりません。
次にやること
いま本番を動かしているなら、1つだけ実行してみてください。
docker compose -f docker-compose.yml -f docker-compose.prod.yml config \
--no-interpolate --no-env-resolution
何も起動しません。 出てきた command と volumes が、自分の思っているとおりか見てください。違っていたら、この記事と同じ状態です。
後ろの2つの指定を付けているのは、パスワードやAPIキーを出さないためです。 付けずに実行すると中身が平文で出るので、その出力は人に見せられません。
同じ「表示と実態がずれる」話を、もう1つ書いています。 そちらは逆で、正常なのに壊れて表示されていたという話です。
→ docker ps が5日間 unhealthy だった。でもサイトは正常に動いていた
サーバーを借りるところから始める場合は、先に読んでおくと詰まりにくい記事があります。