Scripts 9/12/2025

How to Relocate Docker Data Directory the Right Way

This article introduces an enhanced Docker data migration script.

Views - Likes -

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)
Tip: Ensure you have root privileges and sufficient disk space before running.

Migration Steps

  1. Stop the Docker service
  2. Perform pre-checks and validate the new path
  3. Migrate data with rsync while preserving attributes
  4. Backup the old directory
  5. Update /etc/docker/daemon.json configuration
  6. 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.