Dockerコンテナを作り直したとき、残したいデータまで消えては困ります。そこで使うのが、Dockerが管理するnamed volumeです。ただし、volumeへ移しただけではバックアップにはなりません。
この記事では、Ubuntu Server 26.04 LTS上のNginx静的サイトをnamed volumeへ移し、停止中に.tgzとして保存します。さらに、元volumeを上書きせず別名の新しいvolumeへ復元し、ハッシュ・healthcheck・HTTP表示を確認して元へ切り戻します。
前提は、前回のDocker ComposeでNginxを家庭内LANへ公開する手順を完了し、~/docker/nginx-lanに.env、compose.yaml、html/index.htmlがあることです。
バックアップは、ファイルを作っただけでは完成ではありません。
内容とチェックサムを確認し、別の空volumeへ実際に復元できて初めて、戻せる可能性を確認できます。この記事では元volumeへ直接展開しません。
- この記事のゴールと対象外
- Dockerのデータはどこに保存される?
- 作業前の安全ルール
- 1. .envへvolume名と操作用イメージを追加する
- 2. compose.yamlをnamed volumeへ変更する
- 3. 既存Nginxを停止する
- 4. 主volumeをラベル付きで作成する
- 5. 既存HTMLを空volumeへ初期化する
- 6. Nginxを新しいvolumeで起動する
- 7. 停止状態でvolumeをバックアップする
- 8. バックアップを3段階で検証する
- 9. 別名の新規volumeへ復元テストする
- 10. 元volumeと復元volumeを比較する
- 11. 復元volumeへ一時切り替えする
- 12. 元volumeへ切り戻す
- 13. 復元テストvolumeを安全に削除する
- データベースへ同じtar手順を流用しない
- 同じサーバー内だけではバックアップが完成しない
- トラブルシューティング
- 完了チェックリスト
- まとめ
この記事のゴールと対象外
完了時の状態
- NginxのHTMLが明示名のnamed volumeへ保存されている
- Nginxからvolumeは読み取り専用で見えている
- 停止状態で
.tgzバックアップを作成できる - tar一覧・サイズ・SHA-256を確認できる
- 別名の空volumeへ復元し、元データと比較できる
- 復元volumeへ一時切り替えてHTTP確認できる
- 元volumeへ切り戻せる
今回は扱わないこと
- MySQL・MariaDB・PostgreSQLなどのデータベース実装
- バックアップの暗号化、世代管理、cron自動化
- NAS・クラウド・外付けディスクの構築
- インターネット公開、TLS、ドメイン
Dockerのデータはどこに保存される?

イメージレイヤー
Dockerイメージに含まれる読み取り専用の土台です。Nginxイメージには初期HTMLがありますが、同じ場所へvolumeをマウントすると、イメージ側のファイルはvolumeの下に隠れます。
コンテナの書き込み可能レイヤー
コンテナ内で変更したファイルが入る一時領域です。コンテナを削除すると失われるため、永続データの保存先には向きません。
bind mount
ホスト上の具体的なパスをコンテナへ見せる方式です。前記事の./htmlが該当します。ホストから直接編集しやすい一方、ホストのディレクトリ構造に依存します。
named volume
Dockerが管理する永続領域です。名前で参照でき、コンテナを削除しても残ります。日常的にDocker内部の保存パスへ直接入らず、一時コンテナを介して初期化・バックアップ・復元します。
docker container exportには、コンテナへマウントしたvolumeのデータは含まれません。volumeは別途バックアップが必要です。
作業前の安全ルール
- 元の
htmlディレクトリを削除しない compose.yamlと.envを変更前に退避する- 作成・削除するvolume名を毎回表示して確認する
- 空でないvolumeへ初期化・復元しない
- 元volumeへバックアップを直接展開しない
down -v、各種prune、強制削除を使わない
まず正しい場所とファイルを確認します。1つでもエラーなら止めます。
cd ~/docker/nginx-lan
pwd
test -f compose.yaml
test -f .env
test -f html/index.html
設定を退避します。
cp -p compose.yaml compose.yaml.before-volume
cp -p .env .env.before-volume
ls -l compose.yaml.before-volume .env.before-volume
1. .envへvolume名と操作用イメージを追加する
nano .env
前記事の値を残し、次の4行にします。LAN IPは実機の値を使います。
LAN_IP=192.168.1.50
NGINX_IMAGE=nginx:1.30.4-alpine
UTILITY_IMAGE=alpine:3.24.1
VOLUME_NAME=ouchi_lab_nginx_data
.envはCompose用の構文を持つため、この記事ではsource .envでシェルへ読み込みません。docker runで使う値は後ほど明示的に設定します。
2. compose.yamlをnamed volumeへ変更する
nano compose.yaml
次を保存します。
services:
web:
image: "${NGINX_IMAGE:?NGINX_IMAGEを.envに設定してください}"
restart: unless-stopped
ports:
- target: 80
published: "8080"
host_ip: "${LAN_IP:?LAN_IPを.envに設定してください}"
protocol: tcp
volumes:
- type: volume
source: site_data
target: /usr/share/nginx/html
read_only: true
volume:
nocopy: true
healthcheck:
test:
- CMD
- wget
- -q
- -O
- /dev/null
- http://127.0.0.1/
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
site_data:
external: true
name: "${VOLUME_NAME:?VOLUME_NAMEを.envに設定してください}"
read_only: trueによりNginxは書き込めません。nocopy: trueは、空volumeへNginxイメージ内の初期HTMLが自動コピーされる動作を止めます。初期化はこのあと専用の一時コンテナだけで行います。
external: trueのvolumeはComposeの外で管理します。存在しなければupがエラーで止まり、Composeが空volumeを自動作成しません。
sudo docker compose config --environment
sudo docker compose config
LAN_IP、NGINX_IMAGE、VOLUME_NAMEが意図した値か確認します。シェルに同名の環境変数があると.envより優先されるため、解決後の値を必ず読みます。
3. 既存Nginxを停止する
sudo docker compose stop --timeout 30 web
sudo docker compose ps -a web
exitedを確認します。まだ稼働中、対象が前記事のサービスと違う、退避ファイルがない場合はvolume作成へ進みません。
この時点で元のbind mount構成へ戻す場合は、退避した2ファイルを戻して再作成します。
cp -p compose.yaml.before-volume compose.yaml
cp -p .env.before-volume .env
sudo docker compose up -d web
4. 主volumeをラベル付きで作成する
同名volumeの存在を先に確認します。
if sudo docker volume inspect ouchi_lab_nginx_data >/dev/null 2>&1; then
echo "STOP: ouchi_lab_nginx_data はすでに存在します"
exit 1
fi
存在しない場合だけ作成します。
sudo docker volume create --label com.shinkanhub.ouchilab.role=nginx-site-data --label com.shinkanhub.ouchilab.lifecycle=primary ouchi_lab_nginx_data
sudo docker volume inspect ouchi_lab_nginx_data
Name、Driver、2つのラベルを確認します。表示されるMountpointへ直接sudo cpやsudo rmする方法は使いません。
5. 既存HTMLを空volumeへ初期化する
操作値を明示し、初期化元を確認します。
PRIMARY_VOLUME='ouchi_lab_nginx_data'
UTILITY_IMAGE='alpine:3.24.1'
SOURCE_DIR="$(pwd)/html"
printf 'PRIMARY_VOLUME=%s
' "$PRIMARY_VOLUME"
printf 'UTILITY_IMAGE=%s
' "$UTILITY_IMAGE"
printf 'SOURCE_DIR=%s
' "$SOURCE_DIR"
test -f "$SOURCE_DIR/index.html"
sha256sum "$SOURCE_DIR/index.html"
元のhtmlは読み取り専用、named volumeだけを書き込み可能にしてコピーします。
sudo docker run --rm --mount "type=bind,src=${SOURCE_DIR},dst=/seed,readonly" --mount "type=volume,src=${PRIMARY_VOLUME},dst=/data" "$UTILITY_IMAGE" sh -eu -c '
test -n "$(ls -A /seed)" || exit 1
test -z "$(ls -A /data)" || {
echo "STOP: 初期化先volumeが空ではありません" >&2
exit 1
}
cp -a /seed/. /data/
chown -R 0:0 /data
chmod -R u=rwX,go=rX /data
'
この権限調整は静的HTML・CSS・JavaScript・画像向けです。実行ファイルやアプリケーションデータへ無条件に流用しません。
volume側を読み取り専用で再確認します。
sudo docker run --rm --mount "type=volume,src=${PRIMARY_VOLUME},dst=/data,readonly" "$UTILITY_IMAGE" sh -eu -c 'ls -lna /data; test -f /data/index.html; sha256sum /data/index.html'
ホスト側とvolume側のindex.htmlのSHA-256が一致しなければ、Nginxを起動しません。
6. Nginxを新しいvolumeで起動する
sudo docker compose up -d --force-recreate --wait --wait-timeout 60 web
sudo docker compose ps web
実際のマウント名と読み取り専用状態を確認します。
WEB_ID="$(sudo docker compose ps -q web)"
sudo docker inspect "$WEB_ID" --format '{{range .Mounts}}{{if eq .Destination "/usr/share/nginx/html"}}Name={{.Name}} RW={{.RW}}{{end}}{{end}}'
期待値はName=ouchi_lab_nginx_data RW=falseです。LAN IPを実際の値へ置き換えてHTTPも確認します。
LAN_IP='192.168.1.50'
curl --fail --silent --show-error "http://${LAN_IP}:8080/" >/dev/null
echo "HTTP確認成功"
7. 停止状態でvolumeをバックアップする

同じサーバー上に、所有ユーザーだけが入れる保存先を作ります。
mkdir -p backups
chmod 700 backups
ls -ld backups
静的ファイルの変化を止めるため、Nginxを停止します。
sudo docker compose stop --timeout 30 web
sudo docker compose ps -a web
exitedでなければバックアップを始めません。値とファイル名を準備します。
PRIMARY_VOLUME='ouchi_lab_nginx_data'
UTILITY_IMAGE='alpine:3.24.1'
BACKUP_DIR="$(pwd)/backups"
BACKUP_FILE="nginx-site-$(date '+%Y%m%d-%H%M%S').tgz"
HOST_UID="$(id -u)"
HOST_GID="$(id -g)"
printf 'BACKUP_FILE=%s
' "$BACKUP_FILE"
元volumeは読み取り専用、backupsだけを書き込み可能にします。
sudo docker run --rm --mount "type=volume,src=${PRIMARY_VOLUME},dst=/source,readonly" --mount "type=bind,src=${BACKUP_DIR},dst=/backup" "$UTILITY_IMAGE" sh -eu -c '
umask 077
cd /source
tar -czf "/backup/$1" .
chown "$2:$3" "/backup/$1"
' sh "$BACKUP_FILE" "$HOST_UID" "$HOST_GID"
volume全体を読めるようtar処理はrootで行い、作成後のファイルだけをホストユーザーの所有へ戻します。
8. バックアップを3段階で検証する
存在・サイズ・所有者
ls -lh "backups/$BACKUP_FILE"
test -s "backups/$BACKUP_FILE"
ファイルがない、0バイト、想定外のroot所有なら復元に使いません。
アーカイブの内容
tar -tzf "backups/$BACKUP_FILE"
if tar -tzf "backups/$BACKUP_FILE" | grep -E '(^/|(^|/)..(/|$))'; then
echo "STOP: 危険なパスを検出しました"
exit 1
fi
最低でも./と./index.htmlが含まれ、絶対パスや親ディレクトリ参照がないことを確認します。
SHA-256
(
cd backups
sha256sum "$BACKUP_FILE" > "${BACKUP_FILE}.sha256"
chmod 600 "${BACKUP_FILE}.sha256"
sha256sum -c "${BACKUP_FILE}.sha256"
)
OKにならなければ、そのバックアップは使いません。検証後、Nginxを再開してHTTPまで確認します。
sudo docker compose start web
sudo docker compose ps web
curl --fail --silent --show-error "http://${LAN_IP}:8080/" >/dev/null
失敗しても元volumeは読み取り専用で渡しているため、バックアップ処理からは変更されていません。まずサービスを戻し、空き容量とbackupsの権限を調べます。
9. 別名の新規volumeへ復元テストする

実際に作成されたバックアップ名を指定します。
BACKUP_FILE='nginx-site-20260802-120000.tgz'
BACKUP_DIR="$(pwd)/backups"
RESTORE_VOLUME='ouchi_lab_nginx_data_restore_test'
UTILITY_IMAGE='alpine:3.24.1'
(
cd backups
sha256sum -c "${BACKUP_FILE}.sha256"
)
OKでなければ復元しません。同名volumeがないことを確認し、用途ラベル付きで作ります。
if sudo docker volume inspect "$RESTORE_VOLUME" >/dev/null 2>&1; then
echo "STOP: $RESTORE_VOLUME はすでに存在します"
exit 1
fi
sudo docker volume create --label com.shinkanhub.ouchilab.role=nginx-site-data --label com.shinkanhub.ouchilab.lifecycle=restore-test "$RESTORE_VOLUME"
sudo docker volume inspect "$RESTORE_VOLUME"
バックアップは読み取り専用、復元先だけを書き込み可能にして展開します。元volumeはこのコマンドへマウントしません。
sudo docker run --rm --mount "type=bind,src=${BACKUP_DIR},dst=/backup,readonly" --mount "type=volume,src=${RESTORE_VOLUME},dst=/restore" "$UTILITY_IMAGE" sh -eu -c '
test -f "/backup/$1" || exit 1
test -z "$(ls -A /restore)" || {
echo "STOP: 復元先volumeが空ではありません" >&2
exit 1
}
cd /restore
tar -xzf "/backup/$1"
chown -R 0:0 .
chmod -R u=rwX,go=rX .
' sh "$BACKUP_FILE"
10. 元volumeと復元volumeを比較する
PRIMARY_VOLUME='ouchi_lab_nginx_data'
PRIMARY_HASH="$(
sudo docker run --rm --mount "type=volume,src=${PRIMARY_VOLUME},dst=/data,readonly" "$UTILITY_IMAGE" sha256sum /data/index.html | awk '{print $1}'
)"
RESTORE_HASH="$(
sudo docker run --rm --mount "type=volume,src=${RESTORE_VOLUME},dst=/data,readonly" "$UTILITY_IMAGE" sha256sum /data/index.html | awk '{print $1}'
)"
printf 'PRIMARY_HASH=%s
' "$PRIMARY_HASH"
printf 'RESTORE_HASH=%s
' "$RESTORE_HASH"
test "$PRIMARY_HASH" = "$RESTORE_HASH"
一致しなければ切り替えません。今回は1ファイル構成なのでindex.htmlを比較します。複数ファイルの実サイトでは、全ファイルのハッシュ一覧、ファイル数、構造、所有者、権限も確認が必要です。
11. 復元volumeへ一時切り替えする
cp -p .env .env.before-restore-test
nano .env
VOLUME_NAMEだけを変更します。
VOLUME_NAME=ouchi_lab_nginx_data_restore_test
sudo docker compose config --environment
sudo docker compose config
sudo docker compose up -d --force-recreate --wait --wait-timeout 60 web
restartやstartではマウント先変更が反映されないため、コンテナを再作成します。実マウントを確認します。
WEB_ID="$(sudo docker compose ps -q web)"
sudo docker inspect "$WEB_ID" --format '{{range .Mounts}}{{if eq .Destination "/usr/share/nginx/html"}}Name={{.Name}} RW={{.RW}}{{end}}{{end}}'
sudo docker compose ps web
curl --fail --silent --show-error "http://${LAN_IP}:8080/" >/dev/null
期待値はName=ouchi_lab_nginx_data_restore_test RW=falseです。家庭内LANの別PCでもページを開きます。
12. 元volumeへ切り戻す
cp -p .env.before-restore-test .env
grep '^VOLUME_NAME=' .env
sudo docker compose config --environment
sudo docker compose config
sudo docker compose up -d --force-recreate --wait --wait-timeout 60 web
再び実マウントとHTTPを確認します。
WEB_ID="$(sudo docker compose ps -q web)"
sudo docker inspect "$WEB_ID" --format '{{range .Mounts}}{{if eq .Destination "/usr/share/nginx/html"}}Name={{.Name}} RW={{.RW}}{{end}}{{end}}'
curl --fail --silent --show-error "http://${LAN_IP}:8080/" >/dev/null
Name=ouchi_lab_nginx_data RW=falseへ戻り、別PCでも表示できれば切り戻し成功です。
13. 復元テストvolumeを安全に削除する
削除は任意です。切り戻しとHTTP確認が終わるまでは残します。削除する場合は、停止済みコンテナを含めて参照を確認します。
sudo docker ps -a --filter "volume=ouchi_lab_nginx_data_restore_test" --format 'table {{.ID}} {{.Names}} {{.Status}}'
sudo docker volume inspect ouchi_lab_nginx_data_restore_test
sudo docker volume inspect --format '{{index .Labels "com.shinkanhub.ouchilab.lifecycle"}}' ouchi_lab_nginx_data_restore_test
参照コンテナがなく、名前とラベルがrestore-testで、データが不要だと確認できた場合だけ、名前を限定して削除します。
sudo docker volume rm ouchi_lab_nginx_data_restore_test
-fは付けません。volume is in useなら、原因を確認せず強制削除しません。
データベースへ同じtar手順を流用しない
今回tarで扱えるのは、停止中の静的ファイルだからです。
MySQL、MariaDB、PostgreSQLなどでは、稼働中のデータディレクトリをそのままtarにする方法を基本手順にしません。各データベースの公式dump、バックアップ、スナップショット手順を使います。アプリによってはコンテナ停止だけでは十分でない場合もあります。
同じサーバー内だけではバックアップが完成しない
./backupsは元volumeと同じサーバー上です。ディスク故障、ファイルシステム障害、誤削除、盗難、災害では同時に失う可能性があります。
.tgzと.sha256を別PCまたは外付け媒体へコピーする- コピー先でもSHA-256を確認する
- 元サーバーと同じディスクだけに置かない
暗号化、世代管理、自動化は、保管先と復元手順を決めてから追加します。
トラブルシューティング
external volume not found
sudo docker volume ls
sudo docker compose config --environment
sudo docker compose config
volumeを作っていない、.envの名前が違う、シェル環境変数が上書きしている可能性があります。別名で慌てて作らず、まず照合します。
Nginxが403またはunhealthy
sudo docker compose logs --tail=100 web
sudo docker run --rm --mount type=volume,src=ouchi_lab_nginx_data,dst=/data,readonly alpine:3.24.1 ls -lna /data
nocopy: trueなので、初期化していない空volumeへNginxのデフォルトページは入りません。index.htmlと権限、実際のvolume名を確認します。
.envを変えても古いvolumeが使われる
docker compose restartでは設定変更を反映できません。configで解決値を確認し、up -d --force-recreateで作り直します。
完了チェックリスト
- 変更前の
compose.yamlと.envを退避した - 主volumeを明示名・primaryラベル付きで作成した
external: trueとnocopy: trueを設定した- Nginxの実マウントが
RW=false - 初期HTMLとvolume側のSHA-256が一致した
- Nginx停止後に元volumeを読み取り専用でバックアップした
.tgzのサイズ・一覧・SHA-256を確認した- 別名の空volumeへ復元した
- 元と復元後の
index.htmlが一致した - 復元volumeでhealthcheckとHTTP表示を確認した
- 元volumeへ切り戻した
- バックアップを別媒体・別マシンへ移す予定を決めた
まとめ
named volumeはコンテナとは別に残りますが、それだけではバックアップではありません。安全な基本形は、サービスを止める、元volumeを読み取り専用で保存する、内容とチェックサムを確認する、別volumeへ復元する、実際に動かして元へ戻すです。
Docker volumeのライフサイクル、空volumeへの初期コピー、読み取り専用マウント、バックアップ・復元例はDocker公式Volumes解説、Composeのexternal・name・ラベルはCompose volumeリファレンス、一時コンテナの明示的な--mountはdocker container runリファレンスで確認できます。操作用のAlpineタグはAlpine公式イメージを確認しました。公式情報の確認日は2026年8月2日です。
次は、バックアップをサーバーの外へ持ち出す前に、暗号化・保管先・世代数を決めます。



コメント