WordPress 自动备份脚本:定时打包 + 异地备份到另一台 VPS
脚本功能
- 一键打包:数据库(mysqldump,
--single-transaction不锁表)+ WordPress 文件(docker 数据卷里的 wp-content、主题、插件)+ compose 配置(docker-compose.yml、.env),合成一个blog-full-YYYY-MM-DD.tgz - 异地备份:scp 传到另一台 VPS,传完对比两端文件大小,确认无损坏
- 自动清理:本地和远程各保留最近 N 天(默认 14 天),旧包自动删除
- 交互菜单:安装 / 定时 / 状态查看 / 立即备份 / 卸载
- cron 定时:设好后每天凌晨自动跑
安装
- 把下面脚本保存为
blog-backup.sh传到博客 VPS(例如~/blog-backup.sh),执行chmod +x ~/blog-backup.sh - 运行
bash ~/blog-backup.sh,选1) 安装/初始化 - 按提示填写:compose 目录、保留天数、目标 VPS 地址 / 用户名 / SSH 端口 / 存放目录
- 登录方式(二选一):
- 密钥登录:粘贴对方 VPS 的私钥全文
- 密码登录:输一次对方密码,脚本自动生成密钥对并复制公钥,之后全程走密钥
- 选
4) 立即备份一次验证全流程 - 选
2) 定时备份设置,例如每天 3:00
发文后自动备份(可选)
菜单 → 2) 定时备份设置 → 5) 开启发文触发备份:每 5 分钟检查一次数据库,发文 15 分钟后自动备份一次(15 分钟缓冲是给你改错别字的时间)。和每天定时备份不冲突,可以同时开。
完整脚本
#!/bin/bash
# blog-backup.sh — WordPress 博客备份管理(含交互菜单)
#
# 用法:
# bash ~/blog-backup.sh # 打开管理菜单(无参数时:终端里进菜单,cron 里自动跑备份)
# bash ~/blog-backup.sh backup # 直接备份一次(定时任务用这个)
# bash ~/blog-backup.sh status # 查看状态
#
# 首次使用:打开菜单选"1) 安装/初始化",按提示配置备份目标 VPS。
# 前提:用户已知远端 VPS 的密码或私钥,安装时自己选一种登录方式。
# 备份内容:数据库 + WordPress 文件 + compose 配置,打包成一个 tgz 传到目标 VPS。
# 密钥只存在本机 600 权限的文件里,绝不发送到别处。
set -euo pipefail
SCRIPT_PATH="$HOME/blog-backup.sh"
CONF_FILE="$HOME/.blog-backup.conf"
CRON_MARK="blog-backup.sh backup"
WATCH_MARK="blog-backup.sh check-publish"
PUBLISH_STATE="$HOME/.blog-backup-publish-state" # 记录上次已备份的文章时间戳
# ---- 默认配置(安装后会存到 ~/.blog-backup.conf,下次自动加载) ----
BLOG_DIR="$HOME/my-wordpress"
BACKUP_LOCAL_DIR="$HOME/blog-backups"
RETENTION_DAYS=14
LOG_FILE="$HOME/blog-backup.log"
REMOTE_USER="root"
REMOTE_HOST="__BACKUP_VPS_IP__" # ← 占位符,安装时填写备份目标 VPS 的真实地址
REMOTE_PORT="__SSH_PORT__" # ← 占位符,安装时填写目标 VPS 的 SSH 端口
REMOTE_DIR="/home/wp-back"
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ -f "$CONF_FILE" ]; then
# shellcheck disable=SC1090
source "$CONF_FILE"
fi
# 注意:ssh 的端口参数是小写 -p,scp 的是大写 -P
SSH_OPTS="-i $SSH_KEY -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new"
SCP_OPTS="-i $SSH_KEY -P $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new"
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG_FILE"; }
fail() { log "ERROR: $*"; exit 1; }
save_conf() {
{
echo "BLOG_DIR=$(printf %q "$BLOG_DIR")"
echo "BACKUP_LOCAL_DIR=$(printf %q "$BACKUP_LOCAL_DIR")"
echo "RETENTION_DAYS=$RETENTION_DAYS"
echo "LOG_FILE=$(printf %q "$LOG_FILE")"
echo "REMOTE_USER=$(printf %q "$REMOTE_USER")"
echo "REMOTE_HOST=$(printf %q "$REMOTE_HOST")"
echo "REMOTE_PORT=$REMOTE_PORT"
echo "REMOTE_DIR=$(printf %q "$REMOTE_DIR")"
echo "SSH_KEY=$(printf %q "$SSH_KEY")"
} > "$CONF_FILE"
chmod 600 "$CONF_FILE"
echo "配置已保存到 $CONF_FILE(600 权限)"
}
# ================= 备份逻辑 =================
do_backup() {
DATE=$(date +%F)
TMPDIR=$(mktemp -d)
BACKUP_NAME="blog-full-${DATE}.tgz"
mkdir -p "$BACKUP_LOCAL_DIR"
cd "$BLOG_DIR" || fail "找不到博客目录 $BLOG_DIR"
log "开始备份 ..."
# 1. 数据库(--single-transaction 保证 InnoDB 一致性,不锁表)
log "导出数据库..."
docker compose exec -T db sh -c 'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction wordpress' > "$TMPDIR/blog-db.sql" \
|| fail "数据库导出失败"
# 2. 文件卷(wp-content、主题、插件都在里面)
# 直接从 wordpress 容器查挂到 /var/www/html 的卷/目录,不依赖卷名
CID=$(docker compose ps -q wordpress 2>/dev/null | head -1 || true)
MNT=""
if [ -n "$CID" ]; then
MNT=$(docker inspect "$CID" --format '{{range .Mounts}}{{if eq .Destination "/var/www/html"}}{{.Type}}|{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null || true)
fi
if [ -z "$MNT" ]; then
GUESS=$(docker volume ls --format '{{.Name}}' | grep -i wp_data | head -1 || true)
if [ -n "$GUESS" ]; then MNT="volume|$GUESS"; fi
fi
MTYPE="${MNT%%|*}"; MSRC="${MNT#*|}"
if [ -z "$MSRC" ]; then fail "找不到 wordpress 挂载到 /var/www/html 的数据(卷或目录)"; fi
log "打包文件数据 $MTYPE:$MSRC ..."
docker run --rm -v "$MSRC:/data:ro" -v "$TMPDIR:/backup" alpine tar -czf /backup/blog-files.tgz -C /data . \
|| fail "文件打包失败"
# 3. compose 配置(.env 含数据库密码和 tunnel token,妥善保管)
tar -czf "$TMPDIR/blog-compose.tgz" -C "$BLOG_DIR" .env docker-compose.yml \
|| fail "配置打包失败"
# 4. 合成一个总包
tar -czf "$BACKUP_LOCAL_DIR/$BACKUP_NAME" -C "$TMPDIR" blog-db.sql blog-files.tgz blog-compose.tgz
rm -rf "$TMPDIR"
log "本地打包完成: $BACKUP_LOCAL_DIR/$BACKUP_NAME ($(du -h "$BACKUP_LOCAL_DIR/$BACKUP_NAME" | cut -f1))"
# 5. 传到备份目标 VPS(占位符表示还没配置)
if [ "$REMOTE_HOST" = "__BACKUP_VPS_IP__" ] || [ "$REMOTE_PORT" = "__SSH_PORT__" ]; then
fail "还没配置备份目标 VPS,请先运行管理菜单做「安装/初始化」"
fi
log "传输到 $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR ..."
# shellcheck disable=SC2086
ssh $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "mkdir -p $REMOTE_DIR" \
|| fail "SSH 连接目标 VPS 失败(检查地址/端口/密钥)"
# shellcheck disable=SC2086
scp $SCP_OPTS "$BACKUP_LOCAL_DIR/$BACKUP_NAME" "$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/" \
|| fail "scp 传输失败"
LOCAL_SIZE=$(stat -c%s "$BACKUP_LOCAL_DIR/$BACKUP_NAME")
# shellcheck disable=SC2086
REMOTE_SIZE=$(ssh $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "stat -c%s $REMOTE_DIR/$BACKUP_NAME")
if [ "$LOCAL_SIZE" != "$REMOTE_SIZE" ]; then
fail "远程文件大小不一致($LOCAL_SIZE vs $REMOTE_SIZE),传输可能损坏"
fi
log "远程校验通过,大小一致"
find "$BACKUP_LOCAL_DIR" -name 'blog-full-*.tgz' -mtime +"$RETENTION_DAYS" -delete
# shellcheck disable=SC2086
ssh $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "find $REMOTE_DIR -name 'blog-full-*.tgz' -mtime +$RETENTION_DAYS -delete"
log "过期备份已清理(本地 + 远程各保留最近 $RETENTION_DAYS 天)"
log "备份完成"
}
# ================= 1. 安装/初始化 =================
do_install() {
echo "---- 安装/初始化 ----"
v=""; read -rp "博客 compose 目录 [$BLOG_DIR]: " v || v=""
if [ -n "$v" ]; then BLOG_DIR="$v"; fi
v=""; read -rp "保留天数 [$RETENTION_DAYS]: " v || v=""
if [ -n "$v" ]; then RETENTION_DAYS="$v"; fi
v=""; read -rp "备份目标 VPS 地址 [$REMOTE_HOST]: " v || v=""
if [ -n "$v" ]; then REMOTE_HOST="$v"; fi
v=""; read -rp "目标用户名 [$REMOTE_USER]: " v || v=""
if [ -n "$v" ]; then REMOTE_USER="$v"; fi
v=""; read -rp "目标端口 [$REMOTE_PORT]: " v || v=""
if [ -n "$v" ]; then REMOTE_PORT="$v"; fi
v=""; read -rp "目标存放目录 [$REMOTE_DIR]: " v || v=""
if [ -n "$v" ]; then REMOTE_DIR="$v"; fi
# ---- 登录方式:用户已知远端密码或密钥,自己选一种 ----
# 1) 密钥登录:粘贴对方 VPS 的私钥,之后全程走密钥
# 2) 密码登录:输一次对方密码,脚本自动生成密钥对并把公钥拷过去,之后全程走密钥
echo "登录方式:1) 密钥登录(粘贴对方 VPS 私钥) 2) 密码登录(输一次对方密码,自动配置密钥)"
lm=""; read -rp "请选择 [1-2]: " lm || lm=""
SSH_KEY="$HOME/.ssh/backup_remote_key"
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
case "$lm" in
2)
if [ ! -f "$SSH_KEY" ]; then
echo "正在生成密钥对..."
ssh-keygen -t ed25519 -f "$SSH_KEY" -N "" || { echo "密钥生成失败"; return 1; }
fi
echo "正在复制公钥到 $REMOTE_USER@$REMOTE_HOST(需要输入一次对方密码)..."
if ! ssh-copy-id -i "$SSH_KEY.pub" -p "$REMOTE_PORT" -o ConnectTimeout=15 "$REMOTE_USER@$REMOTE_HOST"; then
echo "公钥复制失败:请检查地址/端口/密码,或对方是否禁用了密码登录"
return 1
fi
echo "公钥已复制,之后备份全程走密钥,不再需要密码"
;;
*)
echo "粘贴对方 VPS 的私钥全文(包括 BEGIN / END 行),结束后单独一行输入 END:"
tmp=$(mktemp)
while IFS= read -r line; do
if [ "$line" = "END" ]; then break; fi
printf '%s\n' "$line" >> "$tmp"
done
if ! grep -q "PRIVATE KEY" "$tmp"; then
echo "内容不像私钥,已取消"; rm -f "$tmp"; return 1
fi
mv "$tmp" "$SSH_KEY"; chmod 600 "$SSH_KEY"
echo "私钥已保存到 $SSH_KEY(600 权限),走密钥登录"
;;
esac
SSH_OPTS="-i $SSH_KEY -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new"
SCP_OPTS="-i $SSH_KEY -P $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new"
# 清掉该主机的旧 host key 记录(机器重装后 key 会变,否则校验失败连不上)
ssh-keygen -R "$REMOTE_HOST" 2>/dev/null || true
ssh-keygen -R "[$REMOTE_HOST]:$REMOTE_PORT" 2>/dev/null || true
echo "测试连接..."
# shellcheck disable=SC2086
if ! ssh $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "mkdir -p $REMOTE_DIR && echo OK"; then
echo "连接失败:检查地址/端口/用户名/私钥是否正确(私钥若有密码,定时任务无法自动使用,请换无密码私钥)"
return 1
fi
save_conf
echo
echo "初始化完成。建议选「4) 立即备份一次」验证全流程,再设定时。"
}
# ================= 发文触发备份(哨兵) =================
# 查最新已发布文章的时间戳(UTC 秒)。$0 技巧:把带单引号的 SQL
# 当作 sh 的 $0 参数传进容器,避免宿主机/容器两层引号打架。
newest_post_time() {
local sql="SELECT IFNULL(UNIX_TIMESTAMP(MAX(post_date_gmt)),0) FROM wp_posts WHERE post_type='post' AND post_status='publish';"
( cd "$BLOG_DIR" && docker compose exec -T db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B wordpress -e "$0"' "$sql" 2>/dev/null | tr -d ' \r\n' ) || echo 0
}
# cron 每 5 分钟调一次:有 15 分钟前发表的新文章 → 备份一次
do_check_publish() {
[ -f "$CONF_FILE" ] || exit 0
P=$(newest_post_time)
[[ "$P" =~ ^[0-9]+$ ]] || exit 0
[ "$P" = "0" ] && exit 0
LAST=$(cat "$PUBLISH_STATE" 2>/dev/null || echo 0)
[[ "$LAST" =~ ^[0-9]+$ ]] || LAST=0
NOW=$(date +%s)
if [ "$P" -gt "$LAST" ] && [ $((NOW - P)) -ge 900 ]; then
log "检测到 15 分钟前有新文章发表,开始备份..."
if ( do_backup ); then
echo "$P" > "$PUBLISH_STATE"
fi
fi
}
watch_current() { crontab -l 2>/dev/null | grep -F "$WATCH_MARK" || true; }
watch_remove() {
crontab -l 2>/dev/null | grep -v -F "$WATCH_MARK" | crontab - 2>/dev/null || true
}
watch_enable() {
# 先记下当前最新文章时间,避免把旧文章当成新文章触发一次
P=$(newest_post_time)
if [[ "$P" =~ ^[0-9]+$ ]] && [ "$P" != "0" ]; then
echo "$P" > "$PUBLISH_STATE"
fi
watch_remove
( crontab -l 2>/dev/null; echo "*/5 * * * * /bin/bash $SCRIPT_PATH check-publish # $WATCH_MARK" ) | crontab -
echo "已开启:每 5 分钟检查一次,发文 15 分钟后自动备份"
}
# ================= 2. 定时备份设置 =================
cron_current() { crontab -l 2>/dev/null | grep -F "$CRON_MARK" || true; }
cron_remove() {
crontab -l 2>/dev/null | grep -v -F "$CRON_MARK" | crontab - 2>/dev/null || true
}
cron_set() {
local exp="$1"
cron_remove
( crontab -l 2>/dev/null; echo "$exp /bin/bash $SCRIPT_PATH backup # $CRON_MARK" ) | crontab -
echo "已设置:$exp"
}
do_schedule() {
echo "---- 定时备份设置 ----"
cur=$(cron_current)
if [ -n "$cur" ]; then echo "当前定时:$cur"; else echo "当前:未设置定时备份"; fi
wc=$(watch_current)
if [ -n "$wc" ]; then echo "发文触发:已开启(每5分钟检查,发文15分钟后备份)"; else echo "发文触发:未开启"; fi
echo "1) 每天 3:00 定时备份"
echo "2) 每周日 3:30 定时备份"
echo "3) 自定义 cron 表达式"
echo "4) 关闭定时备份"
echo "5) 开启发文触发备份"
echo "6) 关闭发文触发备份"
echo "0) 返回"
c=""; read -rp "请选择: " c || c=""
case "$c" in
1) cron_set "0 3 * * *" ;;
2) cron_set "30 3 * * 0" ;;
3) exp=""; read -rp "输入 cron 表达式(如 0 4 * * *): " exp || exp=""
if [ -z "$exp" ]; then echo "未输入,已取消"; else cron_set "$exp"; fi ;;
4) cron_remove; echo "已关闭定时备份" ;;
5) watch_enable ;;
6) watch_remove; echo "已关闭发文触发备份" ;;
*) echo "已取消" ;;
esac
}
# ================= 3. 查看状态 =================
do_status() {
echo "---- 状态 ----"
echo "博客目录:$BLOG_DIR"
echo "保留天数:$RETENTION_DAYS 天"
echo "备份目标:$REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT:$REMOTE_DIR"
echo
cur=$(cron_current)
if [ -n "$cur" ]; then echo "定时任务:$cur"; else echo "定时任务:未设置"; fi
wc=$(watch_current)
if [ -n "$wc" ]; then echo "发文触发:已开启"; else echo "发文触发:未开启"; fi
echo
echo "本地备份:"
if [ -d "$BACKUP_LOCAL_DIR" ] && ls "$BACKUP_LOCAL_DIR"/blog-full-*.tgz >/dev/null 2>&1; then
ls -lh "$BACKUP_LOCAL_DIR"/blog-full-*.tgz | awk '{print " " $9 " (" $5 ")"}'
echo " 共占用:$(du -sh "$BACKUP_LOCAL_DIR" | cut -f1)"
else
echo " (暂无)"
fi
echo
echo -n "SSH 连通性:"
# shellcheck disable=SC2086
if ssh $SSH_OPTS -o ConnectTimeout=8 "$REMOTE_USER@$REMOTE_HOST" "echo OK" 2>/dev/null | grep -q OK; then
echo "正常"
echo "远程备份:"
# shellcheck disable=SC2086
ssh $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "ls -lh $REMOTE_DIR/blog-full-*.tgz 2>/dev/null | awk '{print \" \" \$9 \" (\" \$5 \")\"}' || echo ' (暂无)'"
else
echo "连接失败(检查地址/端口/密钥)"
fi
echo
echo "最近日志:"
tail -n 12 "$LOG_FILE" 2>/dev/null || echo " (暂无日志)"
}
# ================= 5. 卸载 =================
do_uninstall() {
echo "---- 卸载 ----"
echo "将执行:删除定时任务、删除配置文件"
a=""; read -rp "同时删除本地备份文件吗?(y/N): " a || a=""
b=""; read -rp "同时删除远程备份文件吗?(y/N): " b || b=""
c=""; read -rp "确认卸载?(y/N): " c || c=""
if [ "$c" != "y" ] && [ "$c" != "Y" ]; then echo "已取消"; return 0; fi
cron_remove
rm -f "$CONF_FILE"
if [ "$a" = "y" ] || [ "$a" = "Y" ]; then rm -rf "$BACKUP_LOCAL_DIR"; echo "本地备份已删除"; fi
if [ "$b" = "y" ] || [ "$b" = "Y" ]; then
# shellcheck disable=SC2086
ssh $SSH_OPTS "$REMOTE_USER@$REMOTE_HOST" "rm -rf $REMOTE_DIR" 2>/dev/null \
&& echo "远程备份已删除" || echo "远程清理失败或无法连接,请手动处理"
fi
echo "卸载完成。如需彻底删除脚本本身,请执行:rm $SCRIPT_PATH"
}
# ================= 菜单 =================
show_menu() {
while true; do
echo
echo "==== 博客备份管理 ===="
echo "1) 安装/初始化"
echo "2) 定时备份设置"
echo "3) 查看状态"
echo "4) 立即备份一次"
echo "5) 卸载"
echo "0) 退出"
choice=""; read -rp "请选择 [0-5]: " choice || { echo; break; }
case "$choice" in
1) do_install || true ;;
2) do_schedule || true ;;
3) do_status || true ;;
4) ( do_backup ) || true ;; # 子 shell 跑,失败返回菜单不退出
5) do_uninstall || true ;;
0) break ;;
*) echo "无效选择" ;;
esac
done
}
case "${1:-}" in
backup) do_backup ;;
check-publish) do_check_publish ;;
status) do_status ;;
menu) show_menu ;;
"")
if [ -t 0 ]; then show_menu; else do_backup; fi ;; # cron 无参数调用也兼容
*) echo "用法: $0 [menu|backup|status]"; exit 1 ;;
esac恢复(灾难时用,尽量详细)
以下在新机器(root)上执行,假设备份包名是 blog-full-2026-10-08.tgz,实际用哪个日期就换哪个。
步骤 1:取回备份包并解压
从备份 VPS 把备份包取回来,解压后得到三个文件:blog-db.sql(数据库)、blog-files.tgz(网站文件)、blog-compose.tgz(compose 配置)。
# 从备份 VPS 取回(把 <SSH端口> <备份VPS地址> 换成实际值)
scp -P <SSH端口> root@<备份VPS地址>:/home/wp-back/blog-full-2026-10-08.tgz ~/
cd ~
# 解压总包
tar -xzf blog-full-2026-10-08.tgz
ls -lh blog-db.sql blog-files.tgz blog-compose.tgz
步骤 2:恢复 compose 配置并启动服务
先恢复 .env 和 docker-compose.yml,把数据库跑起来。注意:如果换了新环境,.env 里的 tunnel token 要按新隧道的改。
mkdir -p ~/my-wordpress
tar -xzf ~/blog-compose.tgz -C ~/my-wordpress
cd ~/my-wordpress
docker compose up -d
步骤 3:导入数据库
等数据库就绪后再导入(看到 db ready 再执行下一行)。
# 等待数据库就绪
docker compose exec -T db sh -c 'while ! mysqladmin -u root -p"$MYSQL_ROOT_PASSWORD" ping --silent 2>/dev/null; do sleep 2; done; echo "db ready"'
# 导入到 wordpress 库
cd ~
docker compose -f ~/my-wordpress/docker-compose.yml exec -T db sh -c 'mysql -u root -p"$MYSQL_ROOT_PASSWORD" wordpress' < ~/blog-db.sql
步骤 4:恢复网站文件到数据卷
把 blog-files.tgz 解压覆盖到 wordpress 的数据卷(/var/www/html,即 wp-content、主题、插件)。
# 查数据卷名(compose 会给卷名加项目前缀,直接查最稳)
VOL=$(docker volume ls --format '{{.Name}}' | grep -i wp_data | head -1)
echo "数据卷:$VOL"
# 解压覆盖进去(只读挂载数据卷,安全)
docker run --rm -v "$VOL:/data" -v "$HOME:/backup" alpine tar -xzf /backup/blog-files.tgz -C /data
步骤 5:重启并验证
重启 wordpress 让它加载恢复的数据,然后验证首页。
cd ~/my-wordpress && docker compose restart wordpress
sleep 10
curl -s -o /dev/null -w "%{http_code}\n" http://localhost
# 返回 200 即正常;如果是 302 跳转到域名也算正常