Claude Code は認証トークン・設定・会話履歴を、すべて ~/.claude の下(と、すぐ隣の ~/.claude.json)に置きます。Dev Container で使うときは、再ビルドのたびの再ログインを避けるために、このディレクトリをホストから丸ごとマウントするのが定番です。
この構成を複数のプロジェクトで使っていて、気になり始めたことがあります。全部のコンテナが、ホストにある同じ ~/.claude を書き換え合っている、ということです。
手元の ~/.claude.json の中身を数えると、こうなっていました。
$ python3 -c "import json,os; d=json.load(open(os.path.expanduser('~/.claude.json'))); \
print('projects:', len(d['projects']), '/ githubRepoPaths:', len(d['githubRepoPaths']), '/ numStartups:', d['numStartups'])"
projects: 36 / githubRepoPaths: 13 / numStartups: 1278
36プロジェクト分の状態が1ファイルに入っています。projects/ 配下の会話履歴も同じで、いま作業しているリポジトリとは関係のないプロジェクトの履歴が、どのコンテナからも見えます。逆方向も起きます。自分がいるコンテナの外で走った Claude Code の書き込みが、知らないうちに手元のファイルに反映されます。
先に正直に書いておくと、この構成で壊れた経験は一度もありません。直したかった理由は単純で、コンテナに閉じたはずの作業が、コンテナの外と混ざっているのが気持ち悪いからです。コンテナに入れた意味が薄れます。
本記事では、~/.claude の中身を更新頻度で分類して、コンテナをまたいで共有し続けるものと、プロジェクトごとに独立させるものの線を引きます。そのうえで後半では、その線引きを Dev Container(docker-compose 版と素の devcontainer.json 版)で実装します。ディレクトリの中身そのものについては、同じブログに Claude Codeが作成する~/.claudeディレクトリの詳細解析 があります。.credentials.json や projects/ が何をしているファイルなのかはそちらが詳しいので、役割から知りたい場合は先に読むと早いです。
この記事でわかること:
~/.claudeの中身を更新頻度で2つに分ける線引き(共有し続けるもの / コンテナごとに分けるもの).claude.jsonが既定ではどこにあるのか。なぜそれが「全コンテナの共用ノート」になるのか- 履歴と設定をプロジェクトごとに独立させつつ、再ログインは不要に保つ実装(compose ベースと素の
devcontainer.jsonの両方) - named volume の所有者問題(
chownを非再帰にする理由) - 「稀更新なら共有してよい」の例外(
plugins/を共有すると壊れる理由)
検証環境: ホストは Linux / WSL2 です。リポジトリも WSL 上に置きます。macOS は認証情報の保存先が異なり本構成がそのままは成立しません(末尾の「制約・前提」で扱います)。
向き / 不向き: 対象はホストの
~/.claude.jsonまで bind している人です。すでに volume だけで運用しているなら、ここで扱う「混ざる」現象は起きていません。なお切り替えてもホスト側の履歴が消えることはありません。ホストにはそのまま残り、以降の会話がコンテナ側にだけ増えていきます。見えなくなるだけですが、--resumeでホスト側の過去を頻繁に遡る使い方とは正面からぶつかります(一度きりの持ち込み手順は後述します)。
~/.claude は全コンテナの共用ノートになっている
.claude.json は ~/.claude の中に無い
まず位置関係から押さえます。共有すると混ざるファイルの主役は .claude.json ですが、これは既定では ~/.claude の中にありません。~/.claude.json という、~/.claude と並ぶ別のパスに置かれます。
$ ls -la ~/.claude.json ~/.claude/.claude.json
-rw-r--r-- 1 vscode vscode 104786 Jul 30 15:27 /home/vscode/.claude.json
ls: cannot access '/home/vscode/.claude/.claude.json': No such file or directory
だから、以前書いた Claude Code×DevContainer 環境構築ガイド の設定も、この2つを別々に bind していました。
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached",
"source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached"
]
つまりプロジェクトの状態が全コンテナで混ざるのは、正確には2行目のほうを共有しているからです。~/.claude だけを bind して ~/.claude.json を共有していない人は、混ざりの主役をまだ共有していません。
中身を更新頻度で並べ直す
settings.json や skills/ のような user レベルのものは、そもそもどこで作業していても同じものを使う前提で用意されています。共有したいのは自明で、迷いません。
迷うのは .claude.json です。名前も中身も「設定ファイル」の顔をしていて、実際に user レベルの設定も入っています。役割で並べると共有側に落ちます。ところがこのファイルは起動や操作のたびに書き換わり、中身の大半はプロジェクトごとの状態です。書き込み頻度で並べると、ここが状態側に落ちます。
| 中身 | 役割 | 書き込み頻度 |
|---|---|---|
.credentials.json | 認証トークン | 稀(ログイン時・トークン更新時のみ) |
settings.json | user レベル設定(権限の許可リストなど) | 稀(設定を変えたときだけ) |
CLAUDE.md | user レベルのメモリ | 稀(書いたときだけ) |
skills/ | user レベルの skill | 稀 |
.claude.json(既定では ~/.claude.json) | 設定・プロジェクトの信頼状態など | 高(起動・操作のたびに書き換わる) |
projects/・sessions/・history.jsonl | 会話履歴・プロンプト履歴・実行中セッションの検出 | 高(操作のたびに書き換わる) |
この表で上下がきれいに分かれます。コンテナをまたいで共有して嬉しいのは上の稀更新グループだけです。認証トークンは共有したいから共有している。user レベルの設定・メモリ・skill も、どのプロジェクトで作業していても同じものを使いたい種類のものです。
混ざって困るのは下の高頻度グループです。ここには2つの向きの困りかたがあります。
- 他のプロジェクトの状態が見えてくる:
.claude.jsonのprojectsは全プロジェクト分の信頼状態を1ファイルに持ちます。projects/配下の会話履歴も、いま開いているリポジトリと無関係なものまで並びます - 自分のいるコンテナの外からの書き込みが入ってくる: 別のコンテナ(あるいはホスト)で走った Claude Code が、同じファイルを更新します。手元では何もしていないのに中身が変わります
再ログイン不要のために本当に欲しいのはトークン1つなのに、丸ごと共有はその巻き添えで、混ざって困るものまで全部共有してしまう。これが直したかった状態です。
この下側の分け方は、こちらで発明したものではありません。公式の claude project purge(v2.1.124 以降)は、プロジェクト1つぶんの状態を消すコマンドです。その削除対象が projects/ の会話ログ、history.jsonl の該当行、そして ~/.claude.json のそのプロジェクトのエントリ——本記事が隔離するものとほぼ同じです。Anthropic 自身も、この3つを「プロジェクト固有の状態」として数えています。ただし purge は溜まったものを後から消すコマンドなので、作業している最中に他プロジェクトの状態が見えることは変わりません。
それでも「丸ごと共有」が広まったのは、そのほうが手軽だから
この分類を無視して丸ごと扱う方式が広く使われているのには理由があります。Dev Container で再ビルドのたびに再ログインしたくない、という要望に対して、~/.claude をまとめてコンテナに渡すのが一番手数が少ないからです。
以降は bind と volume を、この記事の軸で読み分けてください。bind はホスト側の実体を指すので共有される。named volume はコンテナ側の実体なので共有されない。この記事がやろうとしているのは、その2つを1つのディレクトリの中で使い分けることです。
定番の方式は、大きく2つに分かれます。
| 定番方式 | 何をするか | 達成できること | 抱える問題 |
|---|---|---|---|
| 丸ごと named volume(公式リファレンス実装がこれ) | ~/.claude 全体を named volume で永続化する | 再ビルドを跨いで設定・履歴・認証を保持でき、コンテナごとに独立する | ホストとログインを共有しない=コンテナ側で別途ログインが要る |
| 丸ごと bind(ホスト共有系) | ホストの ~/.claude をそのまま bind する | ホストのログインをそのまま使え、再ログインが不要 | 全コンテナが同じファイルを書き換え合う |
前者は Anthropic 自身のリファレンス実装が採っている形です。
"source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
後者はさきほどの入門編で採ったものです。表を見ると、2つの方式は「再ログインの手間」と「コンテナごとの独立」をトレードオフにしています。どちらも捨てたくない、というのが本記事の出発点です。
なお公式のこの実装は、CLAUDE_CONFIG_DIR にも同じ /home/node/.claude を指定しています。後述するとおり本記事も同じことをします。環境変数のリファレンスに項目が無い変数ですが、公式のリファレンス実装自身が使っている、という位置づけのものです。
分離設計:共有し続けるものと、プロジェクトごとに分けるもの
ここからが本記事の主張の中心です。方針は 稀更新のものはホストと共有し続け、頻繁更新のものは named volume でコンテナごとに独立させること。前述の二択(丸ごと volume / 丸ごと bind)のどちらでもない、第3の中間設計です。
先に性格を断っておきます。これは公式のリファレンス実装が示す形から外れた回避策です。Docker がマウントを深さ順に並べ替えること、CLAUDE_CONFIG_DIR が集約先を動かすこと——この2つの挙動に乗って成立しています。どちらも後で実物で確かめますが、公式が保証した組み合わせではないので、どちらかが変われば追従が要る前提で採ってください。

| 対象 | 扱い | なぜ |
|---|---|---|
.credentials.json(トークン) | ホストと bind 共有 | 再ログインを不要にしたい。更新は稀 |
settings.json・user CLAUDE.md・skills/ | ホストと bind 共有 | 稀更新。どのプロジェクトでも同じものを使いたい。分けるとホスト側で育てた許可設定や user 資産がコンテナで使えなくなる |
.claude.json・projects/・sessions/・history.jsonl | named volume でコンテナごとに独立 | 頻繁に更新される。他プロジェクトの状態が見えず、外からの書き込みも入らない。volume は再ビルドで消えず永続する |
plugins/ | 共有しない(コンテナごと) | 稀更新だが例外。理由は「ハマり所」で述べます |
バイナリ(~/.local/) | コンテナ固有(共有しない) | 再ビルド時に入れ直す。代償として再ビルドまで版が固定される |
volume 名をプロジェクトごとに変えるのが肝です。同じ名前を使い回すと、せっかく volume にしてもプロジェクト間で中身を共有してしまいます。リポジトリ名を含めた名前(例: myrepo-claude-config)にしておけば、履歴と .claude.json はプロジェクトごとに完全に独立します。
この設計は次の2段構えで成立します。
1段目: CLAUDE_CONFIG_DIR で集約先を $HOME/.claude に固定する。 ポイントは、これが既定と同じパスの明示指定であることです。効果は「外にあった .claude.json が ~/.claude の中に入る」ことです。これで .claude.json が volume の内側に入り、リビルドを跨いで残せるようになります。指定しなくてもコンテナごとに独立はしますが、その場合は volume の外=リビルドで消える場所に書かれるので、独立と永続の両方を取るにはこの1段が要ります。認証情報についても、Linux / Windows では CLAUDE_CONFIG_DIR を設定すると .credentials.json がそのディレクトリ配下に置かれることが認証ドキュメントに明記されています。
公式のリファレンス実装が現に使っている変数で、.claude ディレクトリの解説にも「これを設定すれば ~/.claude 配下のパスはそちらの下に移る」と書かれています。それでも環境変数の公式リファレンスには項目として載っていません。前者は後者へリンクを張っているのに、飛んだ先にその項目が無い、という状態です(2026-08-02 時点)。ドキュメント化を求める Issue #33430 は not planned として close されました。載せてほしいという要望も「この変数が効いていない」というバグ報告も上がっているのに項目は無い——動くから使われている、という位置づけの変数です。
さらに、この公式の記述は ~/.claude 配下のパスについてのものです。本記事が頼っているのは、その外にある ~/.claude.json を配下へ引き込む挙動のほうで、そちらはどの公式ドキュメントにも書かれていません(後述の「動作確認」で実物を見ます)。機能はしますが、リファレンスに裏付けられた公式仕様ではない点は承知の上で採ってください。
既定と同じパスを指定することには副産物もあります。CLAUDE_CONFIG_DIR を尊重せず ~/.claude を見にいってしまう既知バグ(#4739 は /ide 連携のロックファイル、後継の #30538 は VS Code 拡張機能。後者は現在も open)がありますが、フォールバック先と指定先が同じディレクトリになるため、この一族のバグは実質的に無効化されます。
なおどちらも IDE 連携側の不具合で、CLI 本体の資格情報の集約には及びません。
2段目: 集約先を named volume にし、その上に稀更新のものだけを深いパスで bind して重ねる。 ディレクトリ全体を指す volume に対して、より深いパスを名指しした bind を重ねます。後者が前者の上に乗るので、ディレクトリ全体は volume(コンテナ固有)のまま、名指しした少数のファイルだけがホスト共有になります。
この重なりは書く順番に依存しません。わざと bind 4本を volume より先に書いたコンテナを起動して、中から実際のマウント順を見るとこうなります。
$ grep -i claude /proc/self/mountinfo | awk '{print $5}'
/home/vscode/.claude
/home/vscode/.claude/.credentials.json
/home/vscode/.claude/settings.json
/home/vscode/.claude/CLAUDE.md
/home/vscode/.claude/skills
指定した順ではなく、浅いものから順に並び直っています。volume を先・後・真ん中に置いた3通りで試しましたが、どれも同じ並びになり、共有した4つの中身もホスト側のものが見えました。
並べ替えているのは Docker デーモンです(上の対照は docker run で直接組みましたが、compose も devcontainer.json も最後は同じデーモンを通ります)。moby の daemon/volumes.go に、target のパス区切りの数でマウントを整列する sortMounts があります。コメントは「マウントが他のマウントを覆い隠さないようにするため。たとえば /etc と /etc/resolv.conf をマウントするなら、/etc/resolv.conf を先にマウントしてはならない」と、まさにこの用途を書いています。
書く順番は気にしなくて構いません。深さの関係さえ作れていれば狙った重なりになります。実際に重なったかどうかは、後述の「動作確認」で自分の環境で目視できます。
つまり「コンテナ固有の volume の中に、共有したいものだけホストへの窓を開ける」構成です。これで「再ログイン不要」と「履歴や状態がプロジェクトごとに独立する」を同時に満たせます。正確には、導入時の1回だけはログインが必要で、そこで書かれたトークンがホスト側に残るため2回目以降のリビルドで不要になります(実測は後述の「動作確認」)。
引き換えに、履歴の置き場所が弱くなる
得るものだけ書くのは不誠実なので、先に払うものを1つ出しておきます。この設計は、会話履歴の耐久性を確実に下げます。
丸ごと bind の構成では、履歴はホストの ~/.claude/projects/ にありました。ホームディレクトリごとバックアップを取っていればその中に入りますし、コンテナを何回作り直しても残ります。本構成に移すと、履歴は named volume の中へ移ります。リビルドでは消えませんが、ホームのバックアップ対象からは外れます。そして docker volume prune や docker system prune -a --volumes で消えます。ディスクが逼迫したときに反射で叩くコマンドです。
「再ビルドで消えない」ことと「消えない」ことは別だ、と理解したうえで採ってください。履歴を資産として扱っているなら、volume を対象にしたバックアップを別途組む必要があります(本記事ではそこまで踏み込みません)。混ざらないことと引き換えに何を差し出すのかは、末尾の「制約・前提」にも一覧で置いています。
Dev Container で実装する
ここからは、上の線引きを実際の設定ファイルに落とします。読者の環境がどちらでも動くように、compose ベースと素の devcontainer.json の両方の完成コードを載せます。
検証状況: compose 版は別のリポジトリで運用中の構成です。素の
devcontainer.json版はこの記事を書いているリポジトリに適用し、リビルドを2回跨いだ実測を後述の「動作確認」に載せています(Claude Code v2.1.220 / WSL2)。
compose ベースの場合
workspace サービスに、環境変数・named volume・重ね bind を定義します。
services:
workspace:
environment:
CLAUDE_CONFIG_DIR: /home/vscode/.claude
DISABLE_AUTOUPDATER: "1"
volumes:
# 頻繁更新分(.claude.json / projects / sessions / history.jsonl)はコンテナ隔離
- type: volume
source: claude_config
target: /home/vscode/.claude
# 稀更新分だけ、深いパスの bind を重ねてホスト共有する
- type: bind
source: ${HOME}/.claude/.credentials.json
target: /home/vscode/.claude/.credentials.json
- type: bind
source: ${HOME}/.claude/settings.json
target: /home/vscode/.claude/settings.json
volumes:
claude_config:
CLAUDE_CONFIG_DIRは集約先を固定します。前述のとおり、外にある.claude.jsonを~/.claude配下に引き込むのが狙いです。DISABLE_AUTOUPDATER: "1"はバージョンを決定的にします。バイナリは~/.local/(コンテナ固有)にあるため、更新は再ビルド時に入れ直す形になります。裏を返せば再ビルドするまで古い版に留まるということなので、更新を取り込みたいタイミングで再ビルドしてください。- named volume を
/home/vscode/.claudeにマウントし、設定・履歴・.claude.jsonをコンテナに隔離して再ビルドを跨いで永続させます。 - 稀更新のファイルを同名パスへ重ね bind します。より深いパスの bind が volume の上に重なるため、そのファイルだけがホスト共有になります。
consistency: cached は付けていません。Docker Desktop for Mac(osxfs)時代のオプションで、Linux では無視されるためです。既存の設定に残っている場合は機能上無害ですが、意味があるように見えるぶん紛らわしいので外しておくのが親切です。
環境変数をもう1つ入れるなら CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1" も候補で、共有と隔離の線引きとは独立した話題になりますが、チームで使うときの考え方は Claude Code Auto Memory をチームでは使わない理由 に書いています。
素の devcontainer.json の場合
compose を使わない構成では、マウントは devcontainer.json の mounts 配列に書きます。Claude Code に関係する部分だけ抜き出すと次の形です。
{
"remoteUser": "vscode",
// ホストとコンテナで uid が食い違う場合に揃える。重ね bind したファイルの
// 権限調整をこれに委ねるため、本構成では明示的に有効化しておく
"updateRemoteUserUID": true,
"remoteEnv": {
// 既定の $HOME/.claude と同一パスを明示指定する(.claude.json を配下に引き込む)
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
},
// bind のソースはコンテナ生成前にホスト側へ実体を作っておく(ホスト上で実行される)
"initializeCommand": "mkdir -p ~/.claude/skills && touch -a ~/.claude/.credentials.json ~/.claude/CLAUDE.md && chmod 600 ~/.claude/.credentials.json && { [ -s ~/.claude/settings.json ] || echo '{}' > ~/.claude/settings.json; }",
// 先頭の chown に -R を付けてはいけない(理由は後述)
"postCreateCommand": "sudo chown vscode:vscode /home/vscode/.claude && curl -fsSL https://claude.ai/install.sh | bash",
"mounts": [
// 頻繁更新分はコンテナ隔離。volume なのでリビルドで消えない。
// volume 名にはリポジトリ名を入れる(理由は直後の「3点目」を参照)
"source=myrepo-claude-config,target=/home/vscode/.claude,type=volume",
// 稀更新分だけ深いパスの bind を重ねる
"source=${localEnv:HOME}/.claude/.credentials.json,target=/home/vscode/.claude/.credentials.json,type=bind",
"source=${localEnv:HOME}/.claude/settings.json,target=/home/vscode/.claude/settings.json,type=bind",
"source=${localEnv:HOME}/.claude/CLAUDE.md,target=/home/vscode/.claude/CLAUDE.md,type=bind",
// ディレクトリでも同じように重ねられる
"source=${localEnv:HOME}/.claude/skills,target=/home/vscode/.claude/skills,type=bind"
]
}
compose 版との違いは3点です。マウントを書く場所(各サービスの volumes: か、devcontainer.json の mounts 配列か)、HOME の変数記法(${HOME} か ${localEnv:HOME} か)、そして volume 名の扱い。前の2つは書き換えるだけですが、3点目は設計の肝に関わるので単独で説明します。
compose の top-level volumes: に書いた名前は、compose プロジェクト名でプレフィックスされます。Compose Specification が既定動作として定めていて、例外は name: を明示したときだけです。だから claude_config という汎用名のままでも、プロジェクトごとに別の volume になります。
一方 devcontainer.json の mounts は compose を経由せず、Docker CLI のマウント構文へそのまま渡ります。プレフィックスの仕組みが挟まらないので、書いた名前がそのまま volume 名になります。これは後述の確認2で実際に見えます。手元の devcontainer.json には source=sios-claude-config と書いてあり、findmnt が返す実体も /var/lib/docker/volumes/sios-claude-config/_data です。リポジトリ名は付いていません。
つまり汎用名のままだと全プロジェクトが同じ volume を掴み、前述の「プロジェクトごとに独立させる」が成立しなくなります。
ただ、名前を手で付け分けるのは規律に頼る運用です。忘れれば静かに壊れます。ここは変数で自動化できます。
// リポジトリのフォルダ名から導出する(読める名前になる)
"source=claude-config-${localWorkspaceFolderBasename},target=/home/vscode/.claude,type=volume"
${localWorkspaceFolderBasename} は devcontainer 仕様の変数で、json_reference が mounts での利用を認めています。
ただしこれはフォルダ名から導出するので、別の場所にある同名リポジトリとは衝突します。~/work/app と ~/oss/app を両方 Dev Container で開いていれば、どちらも claude-config-app を掴みます。手で命名するよりは安全ですが、衝突が消えるわけではありません。
そこまで潰すなら ${devcontainerId} を使います。Anthropic 自身のリファレンス実装がこれです。上と同じ json_reference が「その dev container に固有で、リビルドを跨いで安定する識別子」と定めているので、同名フォルダでも衝突しません。代わりに名前が不透明になり、docker volume ls で目視できなくなります。同名リポジトリを複数開く可能性があるなら ${devcontainerId}、読める名前を優先するならフォルダ名、という選び方になります。
initializeCommand について補足します。ファイル bind はソースが存在しないと、Docker がそれを root 所有のディレクトリとして誤生成してしまいます。これを防ぐため、ホスト側に空ファイルを先に作ります。initializeCommand はコンテナ生成前にホスト上で実行されるので、ここが適切な置き場所です。
3点、細かいが外せない注意があります。
chmod 600を付ける。公式ドキュメントは Linux の.credentials.jsonを “file mode0600” と明記しています。touchだけだと既定の umask で 644 のトークンファイルができあがるため、明示的に絞ります。settings.jsonは空ファイルにしない。{}で初期化します。.credentials.jsonは空でも “Not logged in” として扱われますが、settings.jsonが空だと JSON パースに失敗しうるためです。mkdir -p/touch -aは冪等なので、すでにファイルを持っているメンバーの環境では何もしません。
postCreate での所有者修正とインストール
コンテナ生成後は、所有者修正 → インストーラの順で実行します。
# 新規 named volume はマウントポイント(~/.claude)が root 所有で初期化される。
# 非再帰で「ルートだけ」を vscode へ。-R は使わない(重ね bind したホスト側の
# 実体を巻き込むため。理由は後述)
if [ -d "$HOME/.claude" ]; then
echo "[postCreate] Fixing ownership of .claude volume root (non-recursive)"
sudo chown vscode:vscode "$HOME/.claude"
fi
# 非ブロッキング: ネットワーク/プロキシ起因の失敗で postCreate 全体を止めない
echo "[postCreate] Installing Claude Code (native installer)"
if curl -fsSL https://claude.ai/install.sh | bash; then
echo "[postCreate] Claude Code installed"
else
echo "[postCreate][WARN] Claude Code install failed (network/proxy?); continuing"
fi
echo "[postCreate] Completed"
インストーラを if で包んでいるのは、企業プロキシなどで到達できなかったときに postCreate 全体を止めないためです。
既存の履歴を持ち込む
この構成に移ると projects/ は空から始まります。ホストに残った過去の会話を引き継ぎたい場合は、手でコピーできます。ただし cp だけでは足りず、ディレクトリ名の付け替えが要ります。
projects/ 配下のディレクトリ名は、開いていたワークスペースのパスから機械的に導かれます。ホストで /home/ryu/product/blog/sios-tech-blog-with-claude を開いていたなら -home-ryu-product-blog-sios-tech-blog-with-claude、コンテナで /workspaces/sios-tech-blog-with-claude を開けば -workspaces-sios-tech-blog-with-claude です。名前が違うので、そのままコピーしてもコンテナ側は「このプロジェクトの会話は無い」と言います。実際に名前を揃えずに入れてみたところ、claude --resume は空のままでした。
ホスト側で ls ~/.claude/projects/ を見て自分のディレクトリ名を確かめてから、名前を付け替えつつ volume へ流し込みます。
# ホスト側で実行。<volume> は devcontainer.json に書いた volume 名
docker run --rm \
-v <volume>:/dst \
-v ~/.claude/projects/-home-ryu-product-blog-sios-tech-blog-with-claude:/src:ro \
alpine sh -c 'mkdir -p "/dst/projects/-workspaces-sios-tech-blog-with-claude" \
&& cp -a /src/. "/dst/projects/-workspaces-sios-tech-blog-with-claude/" \
&& chown -R 1000:1000 /dst/projects'
chown の 1000 はコンテナ側 vscode の uid です。root 所有のまま置くと Claude Code が書き込めません。
必要なのはここまでで、.jsonl の中身を書き換える必要はありません。各行は cwd にホスト側の絶対パス(/home/ryu/...)を持ったままですし、sessions-index.json の originalPath もホストのパスのままですが、コンテナ内の claude --resume はディレクトリ名だけを見てセッションを拾います。持ち込んだセッションを実際に開くと、リビルド前のやり取りがそのまま復元されて続きから会話できました。
ただし持ち込めるのはその時点の断面だけです。これ以降ホスト側で増えた会話は入ってきません。あと、リポジトリによっては projects/ 配下が数百MBあるので(手元の1リポジトリで885MB)、流し込む前にサイズは見ておいてください。
ハマり所
ここからは実装上の落とし穴です。本構成を実際に組んだときに踏んだものだけを並べます。
named volume の初期所有者は root なので chown は非再帰で
named volume を新規作成すると、マウントポイント(~/.claude)は root(uid 0)所有で初期化されます。このままだと vscode ユーザーが配下に書き込めず、設定の保存が失敗します。そのため postCreate で ~/.claude の所有者を vscode に直します。volume は再ビルドで消えないため、初回作成後はすでに vscode 所有となり、この処理は冪等です。
ここで重要なのが、chown に -R(再帰)を付けてはいけないことです。
この時点の ~/.claude は、新規 volume(空)に重ね bind したファイルが乗っているだけの状態です。root 所有なのはルートディレクトリ自身だけで、サブディレクトリは Claude 初回起動時に vscode 所有として作られます。ルートだけ直せば十分です。
-R を付けると、重ね bind したファイルまで再帰の対象に入ります。この bind 元はホスト側のファイルそのもの(コンテナとホストで同じ実体を指しています)なので、コンテナ内での再帰 chown がホスト側の所有権まで書き換えます。ホストの uid が 1000 でない環境では、書き換えられた結果ホスト側の Claude Code がトークンを読めなくなります。共有するファイルを増やすほど、この事故の影響範囲は広がります。
非再帰なら共有ファイルには一切触れません。権限調整は initializeCommand(ホスト側で実行)と updateRemoteUserUID: true に委ねます。
plugins/ は稀更新でも共有できない
「稀更新なら共有してよい」の例外です。plugins/ は更新頻度こそ低いのですが、レジストリの中身がコンテナの絶対パスを持っています。
known_marketplaces.jsonのinstallLocationinstalled_plugins.jsonのinstallPath/projectPath
これらに /home/vscode/.claude/plugins/... や /workspaces/... といったコンテナ側のパスが記録されます。ここまでは実物を開いて確認した事実です。
ホストと共有すると、両者が互いに解決できないパスを見ることになります。片方が「存在しないパスを指している=壊れている」と判断してレジストリを自分のパスで上書きし、次はもう片方が同じことをする——というピンポンが起きるはずです。ただしこれは記録されるパスから導いた推測で、実際に往復させて確かめたわけではありません。確かめる価値より断つコストのほうが安いので、構成で切っています。
共有をやめた場合の復旧コストは /plugin install を1回やり直すだけです。共有した settings.json に marketplace の情報が残るので、そこから復元できます。
未導入メンバーへの配布では空ファイルを先に作る
これはチームへ配布したときに実際に踏んだ罠です。ホストに ~/.claude/.credentials.json が無いと、Docker が bind ソースを root 所有のディレクトリとして誤生成します。Claude Code をまだ使っていないメンバーの環境では bind ソースが存在せず、root 所有で生成された結果、vscode から書き込めなくなって壊れました。
対応は前述の initializeCommand です。空の .credentials.json は “Not logged in” として扱われるためクラッシュせず、初回に claude /login するとトークンがこのファイルに書き込まれ、以降は永続します。
壊れてしまった状態からの復旧
すでに root 所有のディレクトリとして誤生成されている場合は、それを消してからやり直します。
# ホスト側で実行(root 所有ディレクトリになっているものを削除)
sudo rm -rf ~/.claude/.credentials.json
このとき注意したいのが、initializeCommand の中で権限を直そうとして chown を書く場合です。ホスト上で sudo なしに実行されるため、root 所有のものに対しては失敗します。そして initializeCommand が失敗するとコンテナの作成自体が止まります(json_reference はライフサイクルスクリプトの失敗について「後続は実行されない」とだけ書いており、ホスト側で走る initializeCommand の失敗時にどうなるかは明記していません。ここは仕様の裏を取れていない挙動です)。「壊れた」状態で検索して来た読者がまず踏むのはここなので、先にホスト側で消しておくのが確実です。
volume 側が壊れた場合は docker volume rm で作り直せますが、中の履歴と設定も一緒に消えます(後述の制約を参照)。
動作確認
本構成の中核は、CLAUDE_CONFIG_DIR による集約先の移動と、volume の上への bind の重なりです。どちらも設定ファイルを読んだだけでは効いたかどうか分かりません。組んだあとに確認してください。以下は本記事の素の devcontainer.json 版をこのリポジトリに適用し、リビルドを2回跨いだうえで、さらに3日そのまま使って取った実測です。
4本あります。どれもコンテナの中でコマンド1つなので、順に叩けば数分で終わります。1と2で設計の2段が効いたかを見て、3で看板(再ログイン不要)を、4でその結果として何が変わったかを確かめる流れです。
先に、記事の主張を正確な形に直しておきます。「リビルドしても再ログイン不要」ではありません。導入時に1回だけログインが必要で、そのとき書かれたトークンがホスト側に残るため、2回目以降のリビルドで不要になります。 initializeCommand が作るのは0バイトの空ファイルなので、導入時点でホストにトークンを持っていなければ初回の1回は必ずログインが要ります。実際、1回目のリビルド直後はログインを求められました。ここを飛ばして読むと、導入直後に「効いていない」と誤解します。
1. .claude.json が ~/.claude の中に入ったか
まず1段目からです。記事の冒頭で、既定では .claude.json が ~/.claude の外にあることを ls で見ました。同じコマンドを新構成のコンテナ内で叩くと、そっくり反転します。
$ ls -la ~/.claude.json ~/.claude/.claude.json
ls: cannot access '/home/vscode/.claude.json': No such file or directory
-rw------- 1 vscode vscode 42482 Aug 1 12:56 /home/vscode/.claude/.claude.json
外にあったはずのファイルが消え、~/.claude の中に現れています。CLAUDE_CONFIG_DIR が効いた証拠がこれです。ここが反転していなければ、.claude.json は volume の外に残っています。その場合、コンテナ固有ではあるもののリビルドで消える場所に書かれているので、隔離できたように見えて永続しません。
2. bind が volume の上に乗っているか
2段目です。findmnt を階層表示のまま .claude で絞ると、重なり方が1画面で見えます。
$ findmnt -o TARGET,SOURCE | grep -i claude
├─/home/vscode/.claude /dev/sdc[/var/lib/docker/volumes/sios-claude-config/_data]
│ ├─/home/vscode/.claude/CLAUDE.md /dev/sdc[/home/ryu/.claude/CLAUDE.md]
│ ├─/home/vscode/.claude/skills /dev/sdc[/home/ryu/.claude/skills]
│ ├─/home/vscode/.claude/settings.json /dev/sdc[/home/ryu/.claude/settings.json]
│ └─/home/vscode/.claude/.credentials.json /dev/sdc[/home/ryu/.claude/.credentials.json]
ディレクトリ自体は volume(/var/lib/docker/volumes/...)を指し、その内側にぶら下がった4つだけがホストのパス(/home/ryu/.claude/...)を指しています。狙った重なり方です。共有すると決めたものが4つとも子として並んでいることも、ここで同時に確認できます。ぶら下がりが1つも出ないなら bind が効いておらず、以降の確認は通りません。
なお skills はファイルではなくディレクトリですが、扱いは同じです。volume 側の同名ディレクトリを丸ごと覆い隠して、ホストの中身が見えます。
出力はこのリポジトリの実測そのままなので、volume 名がサンプルの myrepo-claude-config ではなく sios-claude-config になっています。前述のとおりリポジトリ名を入れた名前にしているためで、読み替えてください。
ここまで出ていれば、トークンがホストと同じ実体であることも同時に決まります。source がホストのパスを指している時点で、そのパスでは volume 側のコピーが隠れているからです。コンテナから読める中身は、ホストのファイル以外にありません。逆に .credentials.json の行だけホスト以外を指しているなら、${localEnv:HOME} の解決先がずれています(Windows のファイルシステム上でリポジトリを開いた場合に起こりえます。末尾の「制約・前提」も参照)。
3. 再ログインが要らないか
claude auth status がログイン状態を JSON で返します。対話セッションを起こす前に確かめられるので、これが一番早い確認です。
$ claude auth status
{
"loggedIn": true,
"authMethod": "claude.ai",
"apiProvider": "firstParty",
...
}
ただし「ログイン画面が出なかった」だけでは、裏で新しくログインし直していないことの証明になりません。判別できるのは .credentials.json の中身です。リフレッシュトークンの有効期限が変わっていなければ、新規ログインは起きていません。新規ログインならリフレッシュトークンが再発行され、期限もそれに合わせて先へ動くためです。
この推論は片道でだけ使ってください。「動いていない → ログインしていない」は言えますが、逆の「動いた → ログインした」は言えません。トークン更新のたびにリフレッシュトークン自体を再発行する実装なら、ログインなしでもここは動きうるからです(手元の v2.1.220 では、後述のとおり更新をまたいでも動きませんでした)。
$ python3 -c "import json,os,datetime as dt; \
d=json.load(open(os.path.expanduser('~/.claude/.credentials.json')))['claudeAiOauth']; \
[print(k, dt.datetime.fromtimestamp(d[k]/1000).strftime('%Y-%m-%d %H:%M:%S')) \
for k in ('expiresAt','refreshTokenExpiresAt')]"
expiresAt 2026-07-31 00:20:05
refreshTokenExpiresAt 2026-08-27 19:01:56
リビルドの前後と、さらに3日そのまま使ったあとで並べるとこうなりました。3日後の列は挙動を見極めるためにこちらで取った追試です。読者が3日待つ必要はありません。
| 項目 | リビルド前(7/30) | リビルド後(7/30) | 3日後(8/1) |
|---|---|---|---|
| inode | 1396334 | 1396334 | 1396334 |
| サイズ | 509 | 509 | 509 |
| mtime | 07-30 16:20:05.958 | 07-30 16:20:05.958 | 08-01 11:21:35.319 |
expiresAt(アクセストークン) | 2026-07-31 00:20:05 | 2026-07-31 00:20:05 | 2026-08-01 19:21:35 |
refreshTokenExpiresAt | 2026-08-27 19:01:56 | 2026-08-27 19:01:56 | 2026-08-27 19:01:56 |
refreshTokenExpiresAt が動いていないので、2回目のリビルドで新規ログインは発生していません。
リビルドの直後だけを見ると、アクセストークンの期限(7/31 00:20)がまだ生きていたため mtime すら動いていません。1回目が書いたファイルをそのまま読んだだけです。3日後の列で、その先が見えます。アクセストークンは期限切れを迎えて更新され、mtime と expiresAt は動きました。それはログイン不要のまま起こる正常系です。一方で refreshTokenExpiresAt は動いていません。判定に使うのがこちらでよい理由が、この1列で確かめられます。
同じ列がもう1つ示しているのが inode の不変です。トークンが書き換わっても番号が変わっていない=Claude Code はこのファイルをその場で上書きしており、一時ファイルを作って rename で差し替えてはいません。単一ファイルの bind が切れる典型的な原因がこの rename なので、ここが動かないことは本構成が長期的に保つかどうかの目安になります(後述の制約も参照)。
4. 他のプロジェクトが見えなくなったか
ここまでの3つは「設計が効いたか」を見てきました。最後は、効いた結果として何が変わったかです。記事の冒頭で .claude.json を数えたのと同じコマンドを、新構成のコンテナ内で叩きます。
$ python3 -c "import json,os; d=json.load(open(os.path.expanduser('~/.claude/.claude.json'))); \
print('projects:', len(d['projects']), '/ githubRepoPaths:', len(d['githubRepoPaths']), '/ numStartups:', d['numStartups']); \
print('project keys:', list(d['projects'].keys()))"
projects: 1 / githubRepoPaths: 1 / numStartups: 10
project keys: ['/workspaces/sios-tech-blog-with-claude']
36プロジェクトが1つになりました。しかもその1つは、いま開いているリポジトリそのものです。numStartups も 1278 から 10 に落ちていて、このカウンタがコンテナの中だけで数え直されていることが分かります。他プロジェクトの信頼状態は、もうここからは見えません。
会話履歴も同じです。
$ ls ~/.claude/projects/
-workspaces-sios-tech-blog-with-claude
ディレクトリが1つだけ。ここに無関係なリポジトリの名前が並んでいたら、.claude.json か projects/ のどちらかが volume の外に残っています。逆に中を覗いてリビルド前のセッションの .jsonl がそのまま読めれば、隔離した側が消えずに永続していることも同時に確認できます。ここが空なら、意図した named volume ではなく匿名ボリュームやコンテナのレイヤに書いている可能性があります。
制約・前提
本構成を採る前に知っておくべき制約を整理します。
| 項目 | 内容 |
|---|---|
| 認証トークンの露出 | .credentials.json はコンテナから読み書きできます。コンテナ内の任意のコード・MCP・エージェントがトークンを取得しうる、という共有の本質的なトレードオフです。許容できない場合は共有をやめ、初回都度ログインする構成(volume のみ・bind なし)にします |
| volume を消すと設定・履歴も消える | docker compose down -v / docker volume rm / docker volume prune を実行すると、隔離した .claude.json と会話履歴は失われます。ホームディレクトリごとバックアップしている場合も、隔離した分はその対象から外れます。「再ビルドで消えない」ことと「消せない」ことは別です |
| 分けたことの代償 | プロジェクトの信頼状態・projects/ の会話履歴・history.jsonl はコンテナ側で空から始まります。--resume がホスト側の過去セッションを自動で拾うことはありません(ディレクトリ名を付け替えて volume へコピーすれば、一度きりの持ち込みはできます。「既存の履歴を持ち込む」を参照)。初回にプロジェクトの信頼プロンプトが出ます。**「混ざらない」の裏返しは「手を動かさないと入ってこない」**なので、過去の履歴を頻繁に遡る使い方をしている場合は、この構成は向きません |
| user スコープの MCP サーバが引き継がれない | claude mcp add --scope user で登録した MCP サーバは .mcp.json ではなく ~/.claude.json に書き込まれることが公式ドキュメントに明記されています。このファイルを隔離するので、user スコープで登録した MCP サーバはコンテナごとに登録し直しになります。リポジトリに置いた .mcp.json(プロジェクトスコープ)は bind の対象外なので影響を受けません。なお手元は user スコープの登録が0件で、この不便自体は踏んでいません(ドキュメントからの指摘です) |
| 断てるのはホスト↔コンテナ経路だけ | 混ざりを断てるのはホストと各コンテナのあいだです。同一コンテナ内で claude を複数起動すれば、それらは同じ volume 上の同じ .claude.json を共有します |
| トークン更新をまたいだ長期挙動 | 単一ファイルの bind は、書き手が「一時ファイルに書いて rename で差し替える」方式を採ると実体の対応が切れることがあります。v2.1.220 の実測では、初回ログインでコンテナ内から書かれたトークンが inode を変えずにホストへ貫通し、次のリビルドで読み出せました。さらに3日後、アクセストークンの期限切れによる更新が実際に走ったあとも inode は同じままでした(=上書き方式)。ただしこれは実装の観察であって保証された仕様ではないので、版が上がったら上記「動作確認」で確かめ直してください |
| ホスト前提 | Linux / WSL2 で検証しています。macOS では認証情報が Keychain に保存され .credentials.json としてファイル化されないことが認証ドキュメントに明記されているため、トークンの bind 共有は成立しません(コンテナ側で claude /login し、volume で以降永続させる形になります)。保存先はおそらくログインキーチェーン内のアプリケーションパスワード相当の項目だと思われますが、macOS 実機では未検証なので、場所の特定は各自で確認してください |
$HOME の食い違い | Windows のファイルシステム上でリポジトリを開くと、initializeCommand の $HOME と compose の ${HOME} がずれる恐れがあります。リポジトリは WSL 上に置いてください |
CLAUDE_CONFIG_DIR の位置づけ | 機能しますが、環境変数の公式リファレンスには項目として載っていません(認証ドキュメントと.claude ディレクトリの解説で挙動が言及されるのみ。後者はリファレンスへリンクを張っていますが、飛んだ先に項目はありません=2026-08-02 時点)。ドキュメント化を求める Issue #33430 は not planned で close されており、リファレンス化を待つ前提では組まないほうが安全です |
なお、認証情報をホストに保持してコンテナから参照する考え方そのものは、Claude Code に限った話ではありません。Azure CLI と gh をチームで統一する構成 や Gemini CLI の認証共有 でも同じ発想を採っています。本記事はそこに「何を共有し、何をプロジェクトごとに分けるか」という線引きを一段持ち込んだ形です。
まとめ
~/.claudeを丸ごとマウントすると、全コンテナがホストの同じファイルを書き換え合う状態になります。手元の.claude.jsonには36プロジェクト分の状態が入っていました。他プロジェクトの履歴が見え、コンテナの外からの書き込みが入ってくる。壊れなくても、コンテナに閉じたはずの作業が外と混ざっているのは気持ち悪いです。~/.claudeの中身は更新頻度の違うものの寄せ集めで、上下できれいに分かれます。共有したいのは稀にしか更新されないもの(認証トークン・user 設定・user メモリ・user skill)だけ。混ざって困るのは起動や操作のたびに書き換わる.claude.jsonと履歴です。しかも.claude.jsonは既定では~/.claudeの外にあるため、「丸ごと」で扱おうとすると取りこぼしやすい位置にいます。- 頻繁更新分を named volume でコンテナごとに独立させ、稀更新分だけを bind 共有すれば、履歴と状態をプロジェクトごとに分けたまま、再ログインの手間を導入時の1回で済ませられます(そこで書かれたトークンがホストに残るため、2回目以降のリビルドでは不要)。実測では、36プロジェクト分あった
.claude.jsonの状態がいま開いているリポジトリ1つだけになりました。volume 名はプロジェクトごとに変えてください。素のdevcontainer.jsonの volume 名は compose と違ってプレフィックスされないので、ここを汎用名のままにすると分けたつもりで分かれません。 - ただしタダではありません。履歴はホストのホームから named volume へ移るので、ホームのバックアップから外れ、
docker volume pruneで消えます。過去のセッションを頻繁に遡る使い方をしているなら、この構成は向きません。「混ざらない」の裏返しは「手を動かさないと入ってこない」です(導入時の持ち込みだけは、ディレクトリ名を付け替えて volume へコピーすれば通せます)。 - 「稀更新なら共有してよい」にも例外があります。
plugins/はレジストリがコンテナの絶対パスを持つため、共有するとホストとコンテナで壊し合います。 - これは公式のリファレンス実装が採る「丸ごと volume」でも、多くの記事がやる「丸ごと bind」でもない中間設計です。更新頻度で線を引くと、再ログインの手間とコンテナごとの独立を両方取れます。


