How to Relocate Docker Data Directory the Right Way
This article introduces an enhanced Docker data migration script.
When Docker's root disk starts sending low-space alerts, the gut reaction is usually to delete images or expand storage — neither of which solves the problem long-term. At n1wd.com, the reliable fix is migrating Docker's data directory to a larger partition.
Despite sounding straightforward, this operation has steps where things can go wrong. This article provides a battle-tested procedure with an automation script covering everything from stopping the service to verifying data integrity after the move.
This article provides an enhanced Docker data migration script with strict pre-checks, safety validations, and automatic dependency installation.
Recommended Method: Run Online Script
The easiest way is to run the script online without downloading manually:
bash <(curl -sSL https://reshub.cn/data/sh/docker-move.sh)
Migration Steps
- Stop the Docker service
- Perform pre-checks and validate the new path
- Migrate data with rsync while preserving attributes
- Backup the old directory
- Update
/etc/docker/daemon.jsonconfiguration - Restart Docker and verify the migration
Full Script (for reference)
#!/bin/bash
# Docker data migration script (universal version, supports CentOS7/8/9, Debian/Ubuntu, Alpine)
# Usage: sudo ./docker-move.sh /data1/docker
#
# Source (GitHub): https://github.com/reshub-cn/docker-data-move.sh
# Author: reshub-cn
# Usage example: sudo ./docker-move.sh /data1/docker
set -euo pipefail
NEW_PATH=${1:-}
DOCKER_SERVICE="docker"
DOCKER_DIR="/var/lib/docker"
CONFIG_FILE="/etc/docker/daemon.json"
# Allow migration to a non-empty directory (default 0 = not allowed).
# To override temporarily:
# ALLOW_NONEMPTY=1 sudo ./docker-move.sh /path
ALLOW_NONEMPTY="${ALLOW_NONEMPTY:-0}"
# ----------- Output functions -----------
die() { echo -e "\n[ERROR] $*\n" >&2; exit 1; }
info() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*"; }
# ----------- Validation functions -----------
require_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Please run as root (sudo)."
}
require_new_path() {
[[ -n "$NEW_PATH" ]] || die "Please provide the new Docker data directory. Usage: sudo $0 /data1/docker"
[[ "$NEW_PATH" == /* ]] || die "New path must be an absolute path: $NEW_PATH"
[[ -d "$DOCKER_DIR" ]] || die "Old directory does not exist: $DOCKER_DIR. No standard Docker installation detected."
if [[ "$NEW_PATH" == "$DOCKER_DIR" ]]; then
die "New directory cannot be the same as the current one: $NEW_PATH"
fi
if [[ "$NEW_PATH" == "$DOCKER_DIR"* ]]; then
die "New directory cannot be inside the old one: $NEW_PATH is within $DOCKER_DIR"
fi
if [[ "$DOCKER_DIR" == "$NEW_PATH"* ]]; then
die "Old directory cannot be inside the new one: $DOCKER_DIR is within $NEW_PATH"
fi
mkdir -p "$NEW_PATH" || die "Failed to create new directory: $NEW_PATH"
chown root:root "$NEW_PATH" || die "Failed to set owner for new directory: $NEW_PATH"
if [[ "$ALLOW_NONEMPTY" != "1" ]]; then
if [[ -d "$NEW_PATH" ]] && [[ -n "$(ls -A "$NEW_PATH" 2>/dev/null || true)" ]]; then
die "New directory must be empty (or set ALLOW_NONEMPTY=1 to override): $NEW_PATH"
fi
fi
}
require_cmds() {
command -v docker >/dev/null 2>&1 || die "Docker command not found. Please install Docker first."
if ! command -v rsync >/dev/null 2>&1; then
warn "rsync not found, attempting to install..."
if [[ -f /etc/debian_version ]]; then
apt update && apt install -y rsync || true
elif [[ -f /etc/redhat-release ]]; then
yum install -y rsync || dnf install -y rsync || true
elif [[ -f /etc/alpine-release ]]; then
apk add --no-cache rsync || true
fi
fi
command -v rsync >/dev/null 2>&1 || die "Failed to install rsync, please install it manually and retry."
}
check_space() {
local used avail need parent
used=$(du -sb "$DOCKER_DIR" 2>/dev/null | awk '{print $1}')
[[ -n "$used" && "$used" -gt 0 ]] || die "Failed to determine disk usage for $DOCKER_DIR."
parent="$NEW_PATH"
[[ -d "$parent" ]] || parent="$(dirname "$NEW_PATH")"
avail=$(df -P -B1 "$parent" 2>/dev/null | awk 'NR==2{print $4}')
[[ -n "$avail" && "$avail" -gt 0 ]] || die "Failed to determine available space on $parent."
local GiB2=$((2*1024*1024*1024))
local need1=$(( (used * 110 + 99) / 100 )) # 110% rounded up
local need2=$(( used + GiB2 ))
need=$(( need1 > need2 ? need1 : need2 ))
info "Old directory usage: $used bytes; Target available: $avail bytes; Required: $need bytes"
[[ "$avail" -ge "$need" ]] || die "Insufficient disk space (required: $need, available: $avail)."
}
check_selinux() {
if command -v getenforce >/dev/null 2>&1; then
local mode
mode=$(getenforce 2>/dev/null || echo "")
if [[ "$mode" == "Enforcing" ]]; then
cat >&2 </dev/null 2>&1; then
if ! jq -e '.' "$CONFIG_FILE" >/dev/null 2>&1; then
local bak="${CONFIG_FILE}.bak.$(date +%Y%m%d%H%M%S)"
cp -a "$CONFIG_FILE" "$bak" || true
die "Invalid JSON detected in $CONFIG_FILE. Backup saved to: $bak"
fi
else
warn "jq not installed, cannot validate JSON format of $CONFIG_FILE."
fi
fi
}
preflight_checks() {
info "Running preflight checks..."
require_root
require_cmds
require_new_path
check_space
check_selinux
check_daemon_json
info "Preflight checks passed ✅"
}
# ----------- Docker control functions -----------
stop_docker() {
if command -v systemctl &>/dev/null; then
systemctl stop "$DOCKER_SERVICE" || true
systemctl stop "${DOCKER_SERVICE}.socket" || true
elif command -v service &>/dev/null; then
service "$DOCKER_SERVICE" stop || true
else
die "Neither systemctl nor service found, cannot stop Docker automatically."
fi
}
start_docker() {
if command -v systemctl &>/dev/null; then
systemctl daemon-reexec || true
systemctl start "$DOCKER_SERVICE"
elif command -v service &>/dev/null; then
service "$DOCKER_SERVICE" start
else
die "Neither systemctl nor service found, cannot start Docker automatically."
fi
}
# ----------- Main workflow -----------
echo "Starting Docker data migration to: $NEW_PATH"
# Auto-install jq (fix for CentOS7)
if ! command -v jq &>/dev/null; then
echo "jq not installed, attempting to install..."
if [[ -f /etc/debian_version ]]; then
apt update && apt install -y jq || true
elif [[ -f /etc/redhat-release ]]; then
yum install -y epel-release || true
rpm --import https://archive.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-7 || true
yum install -y jq oniguruma || dnf install -y jq oniguruma || true
elif [[ -f /etc/alpine-release ]]; then
apk add --no-cache jq || true
fi
fi
# 0. Preflight checks
preflight_checks
# 1. Stop Docker
echo "Stopping Docker service..."
stop_docker
# 2. Ensure new path exists
echo "Ensuring new directory exists..."
mkdir -p "$NEW_PATH"
chown root:root "$NEW_PATH"
# 3. Migrate data
echo "Migrating data..."
rsync -aHAX --numeric-ids --delete --info=progress2 "$DOCKER_DIR/" "$NEW_PATH/"
# 4. Backup old directory
if [[ -d "$DOCKER_DIR" ]]; then
echo "Backing up old directory..."
mv "$DOCKER_DIR" "${DOCKER_DIR}.bak.$(date +%Y%m%d%H%M%S)"
fi
# 5. Update Docker config
echo "Updating Docker config..."
mkdir -p "$(dirname "$CONFIG_FILE")"
if [[ -f "$CONFIG_FILE" && command -v jq >/dev/null 2>&1 ]]; then
tmp="${CONFIG_FILE}.tmp"
# Use --arg to safely pass path (handles spaces/special chars)
if ! jq --arg path "$NEW_PATH" '.["data-root"]=$path' "$CONFIG_FILE" > "$tmp" 2>/dev/null; then
bak="${CONFIG_FILE}.bak.$(date +%Y%m%d%H%M%S)"
cp -a "$CONFIG_FILE" "$bak" || true
echo '{"data-root":"'$NEW_PATH'"}' > "$tmp"
warn "Failed to update $CONFIG_FILE, backup saved to $bak. Overwritten with minimal config."
fi
mv "$tmp" "$CONFIG_FILE"
else
echo '{"data-root":"'$NEW_PATH'"}' > "$CONFIG_FILE"
fi
# 6. Start Docker
echo "Starting Docker..."
start_docker
# 7. Verify
echo "Verifying Docker data directory..."
if docker info >/dev/null 2>&1; then
docker info | grep -E "Docker Root Dir:\s+$NEW_PATH" >/dev/null 2>&1 \
&& echo "Verification successful: Docker Root Dir is $NEW_PATH" \
|| die "Verification failed: Docker Root Dir not switched to $NEW_PATH, please check."
else
die "docker info failed, please check if Docker is running correctly."
fi
echo "Migration completed! Old data (if any) backed up to: ${DOCKER_DIR}.bak.*"
At n1wd.com, Docker disk alerts are a trap most teams keep stepping into repeatedly. Instead of buying time by deleting images, a one-time migration to a dedicated high-capacity partition is the better investment — which is exactly why the script here is built to be idempotent and safe to re-run.