はじめに
Rails + Tailwind CSS v4 で開発しているWebアプリで、複数のユーザーから
▎ 「OSをダークモードにしてるとサイトの色が崩れて読めない」
という声をもらい、対応した話です。
「メタタグ1行で済むかな〜」と思って取り掛かったら、最終的に本番のDockerデプロイ周りまで作り直すことになり、想定の3倍ハマりました。
同じ構成(Rails + Tailwind v4 + Docker +
Nginx)の人が同じ罠にハマらないように、フロント側の対処と本番デプロイの罠の両方を共有します。
- フロント側:Tailwind v4の @custom-variant で dark: の発火条件をクラスベースに変更 + でブラウザUI(フォーム要素・スクロールバー)もライト固定
- 本番側:./public をホストにバインドマウントしている構成では、Dockerfile内の RUN bundle exec rails assets:precompile
はボリュームマウントで上書きされて意味がない。entrypointスクリプトに移すと解決
環境
- Ruby 3.1.4 / Rails 7.0.10
- Tailwind CSS v4.2.0(tailwindcss-rails gem)
- Hotwire (Turbo + Stimulus) / ImportMap
- 本番: さくらVPS + Docker Compose + ホスト側Nginxで静的ファイル配信
発端:ユーザーからの声
ベータテストでアプリを触ってもらったところ、5人中2人くらいから「画面の色がおかしい」と報告が。
確認すると共通点はOSがダークモード設定 でした。
開発時は誰もダークモードにしていなかったので、誰も気づかなかったやつです 🫠
第1幕:原因を探る
grep -rn 'dark:' app/views/
してみると、80箇所以上 に dark: 系のTailwindクラスが散らばっていました。
レイアウトの抜粋:
<body class="min-h-screen flex flex-col
bg-slate-50 text-slate-900
dark:bg-slate-900 dark:text-slate-100">
問題はこれが bodyにだけ 設定されていて、内側のカード・ヘッダー・記事一覧などには dark:
クラスがあったり無かったりでバラバラだったこと。
結果:
- bodyだけ濃いグレー+白文字
- 子要素は白背景のまま
- 文字が読めなくなる箇所が出る
OS の prefers-color-scheme: dark をブラウザが拾って、未対応の半端なダークスタイルが発火してしまっている状態でした。
第2幕:方針を決める
選択肢は3つ:
┌─────┬───────────────────────────────────────────────────────┬──────────────────┐
│ 案 │ 内容 │ 工数 │
├─────┼───────────────────────────────────────────────────────┼──────────────────┤
│ A │ 全コンポーネントに dark: を整備して完全対応 │ 大 │
├─────┼───────────────────────────────────────────────────────┼──────────────────┤
│ B │ dark: を全部削除してライト固定 │ 中(80箇所編集) │
├─────┼───────────────────────────────────────────────────────┼──────────────────┤
│ C │ Tailwind側で dark: の発火条件を変えて、実質ライト固定 │ 小 │
└─────┴───────────────────────────────────────────────────────┴──────────────────┘
ユーザーから「ライトモードで表示されてほしい」という要望だったので、Aは却下。BとCではコード変更が圧倒的に少ないCを選択しました。
第3幕:Tailwind v4でライト固定
*** @custom-variant で dark: の発火条件を変える***
Tailwind v4 では、@custom-variant ディレクティブで既存のバリアントを再定義できます。デフォルトでは dark: は @media(prefers-color-scheme: dark) で発火しますが、これを .dark クラスを持つ祖先要素配下でのみ発火 するように変更します。つまり dark: 系クラスが効くスイッチが「OS」から「.darkクラスの有無」に切り替わった わけです。
@import "tailwindcss";
/* dark: バリアントを .dark クラスベースに変更 */
@custom-variant dark (&:where(.dark, .dark *));
&:where(.dark, .dark *) の意味:
- &:where(.dark, ...) … 自身が .dark クラスを持つ
- &:where(..., .dark *) … 祖先のどこかに .dark クラスを持つ要素がある
:where() を使う理由は 詳細度を0に保つため。Tailwindの他のバリアントと詳細度が衝突しないようにします。
これで誰も .dark クラスを付けない限り、dark:bg-slate-900 のようなクラスは永遠に発火しません。既存の dark:
クラスは触らずに残せるので、80箇所編集する必要がありません。
でブラウザUIもライト固定
実はTailwindだけでは不十分でした。ブラウザはダークモード時、フォーム要素やスクロールバーなどの組み込みUIを自動的に暗く描画
します。これはCSSでは制御しきれません。
これを抑えるには color-scheme プロパティを使います。
content の値の違い:
┌────────────┬──────────────────────────────────────────────────┐
│ 値 │ 挙動 │
├────────────┼──────────────────────────────────────────────────┤
│ light │ ライト推奨だが、ユーザー設定が優先される場合あり │
├────────────┼──────────────────────────────────────────────────┤
│ only light │ 強制的にライト固定。最強 │
├────────────┼──────────────────────────────────────────────────┤
│ dark light │ 両方サポート │
└────────────┴──────────────────────────────────────────────────┘
今回は完全にライト固定したいので only light 一択です。
ローカル動作確認
OS をダークモードにして、ブラウザの DevTools を開き、
Rendering > Emulate CSS media feature prefers-color-scheme: dark
を有効化。各ページが白背景のまま崩れないことを確認しました。input や select の背景も白いまま。完璧。
git commit -m "fix: ダークモードでも常にライトモードで表示されるように修正"
git push
CI通った!マージしてデプロイ!
完璧な仕事をした気持ちで本番にデプロイ。しかし……
第4幕:本番に反映されない
本番にデプロイしたあと、確認のためにOSをダークにして本番URLを開いたら、まだ画面が黒い。
git pull
docker compose down
docker compose up -d --build # ← 普通に再ビルドしたはず
ビルドは通っている。コードはmainの最新。ローカルでは確かにライトになっていた。なのに本番だけ反映されない。
DevToolsでNetworkタブから配信されているCSSを開いて検索…
@media (prefers-color-scheme:dark){.dark:border-slate-700{...}}
古いCSSがそのまま配信されていました。
第5幕:Docker volume mountの罠
原因は docker-compose.yml のこの一行でした。
services:
web:
volumes:
- ./public:/bookshare/public
Nginxがホスト側で動いて静的ファイルを配信する構成なので、コンテナ内で生成された assets
をホストから見えるようにするためのバインドマウント。これ自体は意図的に必要です。
問題は Dockerfile 側でした。
修正前
ENV SECRET_KEY_BASE=dummy
RUN bundle exec rails assets:precompile # ← ビルド時に実行
ここで何が起きているか整理すると:
[ docker compose build 時 ]
Dockerfile の RUN assets:precompile が実行される
→ イメージの /bookshare/public/assets/ に新しいCSSが書き込まれる
→ ※このときボリュームマウントは効いていない
[ docker compose up 時 ]
ボリュームマウント(./public:/bookshare/public)が有効化
→ ホストの ./public/ がコンテナの /bookshare/public/ を上書き
→ イメージに焼き込まれた新しい assets が完全に隠される
→ ホストの ./public/assets/ にある「前回デプロイ時の古いassets」が配信される
つまり、
▎ Dockerfile内の assets:precompile は、ボリュームマウントを使うこの構成では完全に無意味だった
…ということ。今までは初回デプロイ時にうまくいっていただけで、2回目以降は古い assets が居座り続けていた、という状態です。
ボリュームマウントは「ホスト側でコンテナ側を上書きする」性質があるので、ビルド時に焼き込んだものは、ランタイム時のマウントによって完全に隠されることを今回学びました。
一旦の応急処置
コンテナを起動した状態で precompile を再実行すれば、ボリュームマウント経由でホスト側に新しい assets が書き込まれます。
コンテナ起動後の状態で
docker compose exec web rm -rf public/assets
docker compose exec web bundle exec rails assets:precompile
docker compose restart web
rm をホスト側で打つとコンテナのrootユーザー所有のファイルで Permission denied
になるので、コンテナ内から実行するのがポイントです。
これで一時的にライトモードが本番反映されました 🎉
…でも毎回のデプロイでこのコマンドを打つのは絶対に忘れる。ということで恒久対策。
第6幕:entrypointで起動時にprecompileする
Dockerfile を修正して、assets:precompile
をビルド時ではなくコンテナ起動時に走らせるようにします。起動時はボリュームマウントが有効なので、書き込んだ assets
はそのままホストに反映されます。
FROM ruby:3.1.4
RUN apt-get update -qq && \
apt-get install -y build-essential default-libmysqlclient-dev nodejs yarn
ENV RAILS_ENV=production
ENV RACK_ENV=production
ENV BUNDLE_PATH=/bookshare/vendor/bundle \
BUNDLE_BIN=/bookshare/vendor/bundle/bin \
GEM_HOME=/bookshare/vendor/bundle \
PATH=/bookshare/vendor/bundle/bin:$PATH
WORKDIR /bookshare
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test
COPY . .
# entrypointスクリプトを設置
COPY entrypoint.sh /usr/bin/entrypoint.sh
RUN chmod +x /usr/bin/entrypoint.sh
EXPOSE 3000
# 起動時にassets:precompile → puma起動
ENTRYPOINT ["entrypoint.sh"]
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
ポイント:
- RUN bundle exec rails assets:precompile を削除
- ENV SECRET_KEY_BASE=dummy も削除(ランタイムでは本物のSECRET_KEY_BASEがcompose経由で渡る)
- ENTRYPOINT を追加して起動時スクリプトを呼ぶ
#!/bin/bash
set -e
echo "==> Precompiling assets..."
bundle exec rails assets:precompile
# CMDで指定されたコマンドを実行
exec "$@"
exec "$@" がポイントで、Dockerfile の CMD で指定した bundle exec puma -C config/puma.rb
をシェルプロセスを置き換えて実行します。これでPID 1 が puma になり、docker stop 時のシグナル伝達も正しく行われます。
これで何が変わるか
[ docker compose up 時 ]
ボリュームマウントが有効化
↓
entrypoint.sh が起動
↓
bundle exec rails assets:precompile が実行される
→ 出力先 /bookshare/public/assets は ホストの ./public/assets と同じ実体
→ ホストに新しいCSSが書き込まれる
↓
exec で puma に置き換わって起動
↓
ホスト側Nginxが新しいCSSを配信
これで本番デプロイは
git pull
docker compose down
docker compose up -d --build
の3行で完結。手動 assets:precompile は不要になりました 🎉
注意点
- 起動時に毎回 precompile が走るので、コンテナ起動が数秒〜十数秒遅くなります(許容範囲)
- Sprockets/Tailwind は manifest を見て差分があるファイルだけ再ビルドするので、変更がなければほぼ no-op
- precompile が DB を要求するアプリの場合、depends_on だけだと DB の起動完了を待たないので、wait-for-it.sh
などで待機ロジックが必要かも(今回は precompile が DB を必要としないので不要)
まとめ / 学び
今回学んだことを5つにまとめます。
- dark: クラスを使うなら全コンポーネントに揃える。中途半端な対応は破綻するので「やる」or「やらない」を決め切る
- Tailwind v4 の @custom-variant を使えば、dark:
の発火条件をクラスベースに変更できる。既存コードを触らずに実質ライト固定にできる - meta name="color-scheme" content="only light"
でブラウザ組み込みUIもライト固定。フォーム要素やスクロールバーまでケアできる - Dockerのバインドマウントは「ホスト側がコンテナ側を上書きする」。ビルド時に焼き込んだ成果物はランタイム時に隠される
- ビルド時に生成すべきもの ≠
起動時に生成すべきもの。ボリュームマウント先に書き出すassetsは、entrypointで起動時に生成するのが正解
「ローカルで確認した、CIも通った、本番に反映した、終わり」と思っても、インフラ側の事情で表に出ないケースがある。
という良い学びになりました。同じ構成でハマっている人の助けになれば嬉しいです。
AIに聞きながらやったので、もっといいやり方があるかもしれませんが、もし、間違いやより良い方法があれば教えていただけると幸いです。
参考リンク