更新日:2026年5月13日|カテゴリ:NAS応用
結論:SSH 鍵ディレクトリは Docker のボリュームで永続化し、初回だけ所有権を整えるのが最小コスト
Docker コンテナで OpenSSH を動かすとき、SSH 鍵ディレクトリをボリュームマウントせずに再ビルドを回すと、せっかく登録した公開鍵が毎回消えます。これは Dockerfile の作成命令がイメージ側にだけ効くためで、コンテナを作り直すと初期状態に戻るからです。
解決はシンプルで、docker-compose の volumes 宣言に SSH 鍵用の名前付きボリュームを 1 つ足し、SSH 鍵ディレクトリにマウントする。あとは最初の 1 回だけ、ホスト側から root 権限でコンテナ内の所有権を整える。これだけで再ビルドしても公開鍵は維持され、ホスト鍵警告が出ても慌てる必要はなくなります。本記事は、私(yamakashi)が UGREEN DXP2800 NAS 上に Claude Code 用サンドボックスを構築する過程で、公開鍵が消失する罠とボリュームの所有権問題に正面からぶつかった記録です。
1. なぜ普通の mkdir ~/.ssh ではダメなのか
Docker で OpenSSH サーバを立てた経験のある方なら、Dockerfile の中に次のような行を書いたことがあるかもしれません。
RUN mkdir -p /home/yamakashi/.ssh \
&& chmod 700 /home/yamakashi/.ssh \
&& chown -R yamakashi:yamakashi /home/yamakashi/.ssh
これだけでも公開鍵認証は動きます。コンテナを起動して docker exec で authorized_keys を追記すれば、ホスト側から SSH ログインできるようになります。ただし、これは そのコンテナが生きている間限定です。
Dockerfile で作ったディレクトリは「イメージ」の一部
Dockerfile の RUN 命令はビルド時にレイヤとしてイメージに焼き込まれます。mkdir したディレクトリ自体も同じです。ただし、コンテナを起動してから docker exec で書き込んだファイルは、イメージではなく コンテナの書き込み可能レイヤ に置かれます。これがクセものです。
authorized_keys は起動後に追記するファイル → 書き込み可能レイヤに置かれる→
docker compose down でコンテナごと削除される→
docker compose up で再作成しても、新しいコンテナは元のイメージから起動するので中身は 空
Dockerfile に COPY authorized_keys /home/yamakashi/.ssh/ と書けばイメージに焼き込めますが、これは別の意味でアンチパターンです。公開鍵は端末ごとに増減するもので、その都度イメージを再ビルドするのは現実的ではありません。「鍵という運用データ」をイメージに混ぜるべきではないのです。
エピソード:はじめての docker compose down → up で気づいた
私が最初に DXP2800 サンドボックスを組んだとき、まさにこの罠を踏みました。最初の Dockerfile では ~/.ssh を普通に mkdir しただけで、ボリュームマウントは宣言していませんでした。公開鍵をヒアドキュメントで authorized_keys に追記して、SSH ログインも成功。ここまではよかった。
翌日、Dockerfile を修正して docker compose down → docker compose up -d --build でリビルドしたところ、PowerShell から接続しようとして次のエラーが出ました。
yamakashi@dxp2800-sandbox: Permission denied (publickey).
「鍵の権限を間違えたかな」と docker exec で中に入って確認すると、~/.ssh 自体は存在するものの、authorized_keys が 消えていました。一瞬「攻撃者に消されたのか」と心臓が冷えましたが、よく考えれば当たり前で、再ビルド直後のコンテナにはそんなファイルそもそも存在しません。「永続化したいもののリストに SSH 鍵を入れ忘れていた」、それだけのことでした。Claude Code の設定、GitHub CLI の認証情報、Git の config、シークレット、そして SSH 鍵。どれか一つでも忘れると、再ビルドのたびに初期セットアップをやり直す悲劇が待っています。
2. Docker ボリュームでマウントするときの罠:初回所有権が root
SSH 鍵を永続化するには Docker の named volume を使って /home/<user>/.ssh をマウントするのが定石です。ただ、ここにもうひとつ罠があって、新規作成された named volume は初回は root 所有になるのです。
エピソード:chmod が Operation not permitted で蹴られた
「鍵が消えたなら、ボリュームでマウントすればいい」と気づいて、docker-compose.yml に ssh-keys ボリュームを追加し、再ビルドして接続しに行ったら、今度は別のエラーで弾かれました。
# コンテナの中で実行
$ chmod 700 /home/yamakashi/.ssh
chmod: changing permissions of '/home/yamakashi/.ssh': Operation not permitted
yamakashi ユーザーで ls -la してみると、~/.ssh ディレクトリのオーナーが root:root になっていました。Dockerfile では chown を書いていたはずなのに、なぜ。
なぜ起きるのか:ボリューム初期化のタイミング
これは Docker のボリューム機構の仕様で、流れを整理すると次のようになります。
- ビルド中に Dockerfile の
chownが実行され、イメージ側の-R yamakashi:yamakashi /home/yamakashi /home/yamakashi/.sshはyamakashi所有になる - コンテナ起動時、
volumesのssh-keys:/home/yamakashi/.sshが処理される - マウント完了後、コンテナから見える
/home/yamakashi/.sshは ボリューム側の実体 に置き換わり、所有権はroot:root
つまり、Dockerfile での chown は イメージ側のディレクトリには効くが、ボリュームでマウントされた後の中身には届かない。新規ボリュームの初回は root 所有なので、一般ユーザーの yamakashi からは chmod も chown もできない詰みパターンが発生します。
解決パターン:1 度だけ root 権限で chown する
Docker の exec は -u オプションでユーザーを指定できます。コンテナ内に sudo をインストールしていなくても、ホスト側から -u root でコマンドを送り込めば root 権限で実行できるので、ここで所有権を整えてしまいます。
具体的なコマンドは次の 2 行です。
# 1 度だけ実行すれば永続化される
docker exec -u root claude-workspace chown -R yamakashi:yamakashi /home/yamakashi/.ssh
docker exec -u root claude-workspace chmod 700 /home/yamakashi/.ssh
これでボリュームの実体の所有権が yamakashi に切り替わります。一度切り替えれば、ボリュームが消えるまで(docker volume rm されるまで)この状態は維持されるので、再ビルドの都度やり直す必要はないのがポイントです。
3. 正しい設計パターン:永続化前提の docker-compose.yml と Dockerfile
ここまでの罠を踏まえた、私が DXP2800 サンドボックスで実際に動かしている設計を載せます。2026 年 5 月時点で動作確認済みの構成です。
docker-compose.yml のボリューム宣言
SSH 鍵に限らず、永続化したいパスはまとめてボリューム化します。ここではメインの claude-workspace サービス部分だけを抜粋しますが、Tailscale との 2 コンテナ構成や network_mode: "service:tailscale" の詳細は記事 2「Tailscale × Docker で NAS を公開しないリモート開発環境を作る方法」(公開準備中)でまとめます。
services:
claude-workspace:
build: .
container_name: claude-workspace
network_mode: "service:tailscale"
depends_on:
tailscale:
condition: service_healthy
volumes:
- /volume1/sandbox/projects:/workspace
- claude-config:/home/yamakashi/.claude
- gh-config:/home/yamakashi/.config/gh
- git-config:/home/yamakashi/.gitconfig-dir
- secrets:/home/yamakashi/.secrets
- ssh-keys:/home/yamakashi/.ssh
environment:
- TZ=Asia/Tokyo
restart: unless-stopped
command: ["/usr/sbin/sshd", "-D", "-e"]
volumes:
ts-state:
claude-config:
gh-config:
git-config:
secrets:
ssh-keys:
・
ssh-keys を named volume として宣言し、/home/yamakashi/.ssh にマウント・プロジェクトファイル(
/workspace)は NAS のシェアフォルダに bind mount・認証情報系(
.claude、.config/gh、.gitconfig-dir、.secrets、.ssh)は named volume に集約
bind mount と named volume を使い分けるのは意図的です。プロジェクトファイルはホスト側の Windows エクスプローラからも閲覧したいので bind mount、認証情報はホスト側のファイルシステムには出したくないので named volume、という方針です。
Dockerfile での mkdir と chown の順序
Dockerfile 側で ~/.ssh を mkdir する必要があるかと言うと、厳密には不要です。ボリュームをマウントすると、その先のディレクトリは存在しなくてもボリューム実体が割り当てられるためです。ただし、私はホームディレクトリ全体の所有権を一括で整えるために、明示的に mkdir を入れています。
Dockerfile の全文は記事 1「自宅 NAS に Claude Code 専用サンドボックスを作った全記録」に掲載していますが、本記事の趣旨である「~/.ssh の扱い」だけに絞ると、押さえるべきは次の 3 点だけです。
Dockerfile 側で押さえる ~/.ssh 関連の 3 点
~/.sshをmkdirしない(ボリュームマウントで上書きされるので無意味)- ホームディレクトリ全体を
chownでユーザー所有にする(イメージ側の他のファイル用)-R yamakashi:yamakashi /home/yamakashi - SSH サーバの「公開鍵認証のみ」設定は
sedで書き換える形でイメージに焼き込み、ホスト鍵自体は触らない(ボリュームに逃がす場合は別途設計)
逆に言えば、ユーザー作成・apt パッケージのインストール・sudo 設定・ホスト鍵の生成タイミングなどはこの記事のスコープ外で、すべて記事 1 で扱っています。本記事は「ボリュームの中身(公開鍵)が消えない」という 1 点に集中した記録として読んでください。
永続化したいパスのリスト
私の構成で永続化対象にしているのは次の 6 つです。Claude Code を NAS の Docker で運用する場合、最低限これだけ押さえておけば再ビルドが怖くなくなります。
| マウント先(コンテナ内) | ボリューム名 | 用途 |
|---|---|---|
/workspace |
(bind mount)/volume1/sandbox/projects |
プロジェクトファイル(NAS シェアフォルダと共有) |
/home/yamakashi/.claude |
claude-config |
Claude Code の認証・設定 |
/home/yamakashi/.config/gh |
gh-config |
GitHub CLI の認証情報 |
/home/yamakashi/.gitconfig-dir |
git-config |
Git の user.name / user.email など |
/home/yamakashi/.secrets |
secrets |
本番 API キー等のシークレット(700) |
/home/yamakashi/.ssh |
ssh-keys |
SSH 公開鍵・known_hosts |
これに加えて、Tailscale コンテナ側にも ts-state という別ボリュームを持たせています。Tailnet 参加状態(鍵やノード ID)が消えないようにするためで、Tailscale 周りの設計は記事 2 で詳しく扱います。
4. authorized_keys を安全に登録する方法
ボリュームと所有権の準備ができたら、いよいよ公開鍵を authorized_keys に書き込みます。ここでもうひとつ、地味にハマったポイントがあるので共有します。
なぜ nano 直書きより「標準入力リダイレクト」が安全か
最初、私は素直に docker exec 経由でコンテナ内の nano を開き、PowerShell からコピーした公開鍵を貼り付けました。途端に画面が固まり、文字が出たり消えたり、最終的に行が崩壊して保存できなくなりました。原因は、SSH 越しに nano を使うとターミナルが 1 文字ずつエスケープシーケンスを処理しようとして、ed25519 公開鍵のような数百文字の貼り付けに追いつかないからです。Windows の PowerShell から SSH → コンテナ内 nano、というスタックだと貼り付けが化けやすいのです。
解決策は nano を介さず、シェルの標準入力リダイレクトで直接ファイルに書き込むことです。具体的な書式と、貼り付け→Enter→Ctrl+D の手順は、関連記事 3「Claude Desktop で Permission denied (publickey) が出る本当の原因と解決法」で図解しているので、そちらをあわせて参照してください。この方式なら、シェルのリダイレクトがバイト列としてそのままファイルに書き込まれるため、長い行でも貼り付けが壊れません。同じコマンドの中でパーミッション調整も併せて済ませてしまえば、権限ミスも起きません。
登録結果の確認
登録後は、コンテナの中でファイル一覧と中身を確認します。チェックするポイントは 3 つだけで、所有者がユーザー自身、ディレクトリの権限が 700(オーナーのみアクセス)、鍵ファイル自体が 600(オーナーのみ読み書き)になっているかです。これらの権限になっていないと sshd が「危ない鍵」と判断してログインを拒否します。中身の鍵行は ssh-ed25519 ... や ssh-rsa ... で始まる 1 行が、登録した端末数だけ並んでいれば正常です。
5. コンテナ再ビルド時のホスト鍵警告は「想定内」
ここまでで SSH 鍵の永続化が完了し、再ビルドしても authorized_keys が消えなくなりました。ただし、コンテナを再作成すると サーバ側のホスト鍵(/etc/)は新しく生成されるため、別の警告が出ます。これは攻撃ではなく、~/.ssh/known_hosts に記録された古いフィンガープリントと再生成されたホスト鍵が違うために出るだけの警告です。
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
クライアント側で古いエントリを削除すれば直ります。
# 接続側(PowerShell や Mac のターミナル)で実行
ssh-keygen -R dxp2800-sandbox
# Tailscale のホスト名連番がついていれば
ssh-keygen -R dxp2800-sandbox-1
その後もう一度 ssh を叩くと、初回接続と同じくフィンガープリントを聞かれるので yes で受け入れ直します。私は再ビルドを月 1 回程度のペースでやっていますが、慌てるエラーではなく想定内の儀式として受け止めると気が楽になります。逆に、何もしていないのに突然このエラーが出始めた場合は、ホスト名や IP の乗っ取り、DNS スプーフィングなどの可能性もあるので、原因を確認してから ssh しましょう。
6. 永続化したいものリスト(チェックリスト)
SSH 鍵を起点にしましたが、Claude Code を NAS の Docker で運用するなら永続化対象は鍵だけではありません。「再ビルドした瞬間に消えるもの」を全部 named volume に逃がすのが基本方針です。私が守っているチェックリストを共有します。
□
/home/<user>/.ssh … 公開鍵・known_hosts。これがないと再ビルド後ログインできない□
/home/<user>/.claude … Claude Code の認証情報。消えると /login やり直し□
/home/<user>/.config/gh … GitHub CLI の認証。消えると gh auth login やり直し□
/home/<user>/.gitconfig-dir … git config --global 設定。コミット時の user.name / user.email□
/home/<user>/.secrets … 本番 API キー等のシークレット。永続化必須かつ chmod 700□ Tailscale コンテナ側の
/var/lib/tailscale … Tailnet 認証情報。消えると Auth Key 再発行
逆に永続化すべきでないものは /usr/local/lib/node_modules のようなパッケージ系です。これらは Dockerfile でイメージに焼くべきもので、ボリュームに逃がすとバージョン管理が混沌とします。「運用データはボリューム、ソフトウェアはイメージ」という線引きが基本です。
7. よくある質問(FAQ)
ssh-keys ボリュームを後付けで追加できますか?docker-compose.yml の volumes: セクションに行を追加して docker compose up -d を叩けば、コンテナが再作成されて新しいボリュームがマウントされます。ただし再作成のタイミングで既存の ~/.ssh の中身は消えます(書き込み可能レイヤごと破棄されるため)。事前に authorized_keys を docker cp でホスト側に退避しておくか、再登録する想定で進めてください。私は「どうせ再登録」と割り切る運用です。ts-state も同じやり方で永続化できますか?tailscale/tailscale 公式イメージは /var/lib/tailscale に状態ファイルを書くので、ここを named volume にマウントするのがデファクト構成です。TS_STATE_DIR=/var/lib/tailscale も合わせて指定します。所有権は root 運用なので SSH 鍵のような chown 問題は起きません。Tailscale 側のボリューム設計や「state を消すと --reset 地獄になる」話は記事 2 で扱います。docker compose pull && docker compose up -d --build を回しています。これで Tailscale、Claude Code、GitHub CLI、ベースイメージ(node:20-slim)が最新化されます。これ以上頻繁にやるとホスト鍵警告の処理が煩わしくなるので、月 1 を上限にしています。npm install -g @anthropic-ai/claude-code はリビルドのたびに最新版を取りに行くため、別途の更新作業は不要です。8. まとめ:永続化対象を最初に決めて、1 度だけ chown を仕込む
・Dockerfile で SSH 鍵ディレクトリを作るだけだと、コンテナ再作成のたびに鍵が消える
・解決は
docker-compose.yml でボリュームを宣言し、ホーム配下のディレクトリにマウントすること・新規ボリュームは初回 root 所有なので、初回 1 回だけ root 権限でユーザー所有に切り替える
・公開鍵の追記はヒアドキュメント方式が安全(nano は SSH 越しの大きな貼り付けに弱い)
・再ビルド後のホスト鍵警告は想定内、クライアント側で古いエントリを削除すれば直る
・SSH 鍵以外にも、Claude Code 設定・gh・git config・シークレットを named volume に逃がす
Docker × OpenSSH の永続化設計は、「何を永続化したいかを最初に全部リストアップする」ことに尽きると感じています。あとから気づいて追加すると、追加した瞬間に既存の中身が飛ぶジレンマがあるからです。本記事のチェックリストが、これから NAS で開発環境コンテナを組む方の前提整備に役立てば幸いです。
SSH まわりのつまずきポイントはサーバ側だけでなく、クライアント側にも潜んでいます。「PowerShell では繋がるのに Claude Desktop からだけ繋がらない」というクライアント側のハマりは別記事にまとめているので、Code タブ経由で Claude Code を呼びたい方は合わせて確認してみてください。
関連記事:Claude Desktop で Permission denied (publickey) が出る本当の原因と解決法/自宅 NAS 上に Claude Code 専用サンドボックスを構築した全記録(公開準備中)/Tailscale × Docker で NAS を公開しないリモート開発環境を作る方法(公開準備中)/フル権限 Claude Code を安全に運用する 5 つの運用ルール(公開準備中)