| Current Path : /var/www/cesa.co.za/scripts/wordpress/ |
| Current File : /var/www/cesa.co.za/scripts/wordpress/wp-files-backup.sh |
#!/usr/bin/env bash
# Backup WordPress application files (core, themes, plugins, mu-plugins, drop-ins).
# Does NOT include uploaded media (wp-content/uploads) or the database.
#
# Purpose: roll back a broken plugin/theme/core upgrade without restoring media.
# Database backups are handled separately.
#
# =============================================================================
# HOW TO RUN THE BACKUP
# =============================================================================
# Run on the WordPress server (SSH). Archives go to /var/backups/wp-files by default.
#
# One-liner (recommended):
#
# bash /var/www/cesa.co.za/scripts/wordpress/wp-files-backup.sh backup
#
# Same thing with explicit paths (must be on the SAME line as bash, or use export):
#
# WP_PATH=/var/www/cesa.co.za BACKUP_DIR=/var/backups/wp-files \
# bash /var/www/cesa.co.za/scripts/wordpress/wp-files-backup.sh backup
#
# Or export first, then run:
#
# export WP_PATH=/var/www/cesa.co.za
# export BACKUP_DIR=/var/backups/wp-files
# bash /var/www/cesa.co.za/scripts/wordpress/wp-files-backup.sh backup
#
# NOTE: These do NOT pass variables into the script (child process never sees them):
# WP_PATH=/var/www/cesa.co.za # assignment only — not exported
# BACKUP_DIR=/var/backups/wp-files # assignment only — not exported
# bash .../wp-files-backup.sh backup # script ignores the vars above
#
# First-time setup (once, as root if needed):
#
# sudo mkdir -p /var/backups/wp-files
# sudo chown otto:otto /var/backups/wp-files # or whatever deploy user
# sudo chmod 750 /var/backups/wp-files
#
# Output:
# - Archive: /var/backups/wp-files/cesa-wp-files-<host>-<YYYYMMDD-HHMMSS>.tar.gz
# - Manifest: same name with .txt (paths, WP version, sha256)
# - Keeps the newest KEEP archives (default 10; set KEEP=0 to keep all)
#
# =============================================================================
# HOW TO RESTORE (after a bad plugin upgrade)
# =============================================================================
# Overwrites core / themes / plugins / mu-plugins from the archive.
# Does NOT touch wp-content/uploads or the database.
#
# bash /var/www/cesa.co.za/scripts/wordpress/wp-files-backup.sh restore \
# /var/backups/wp-files/cesa-wp-files-HOST-20260720-144800.tar.gz
#
# Non-interactive:
#
# FORCE=1 bash /var/www/cesa.co.za/scripts/wordpress/wp-files-backup.sh restore \
# /var/backups/wp-files/cesa-wp-files-HOST-20260720-144800.tar.gz
#
# Env:
# WP_PATH WordPress root (default: /var/www/cesa.co.za if present, else cwd)
# BACKUP_DIR Where archives are written (default: /var/backups/wp-files)
# KEEP Number of archives to retain (default: 10; 0 = keep all)
# LABEL Optional label in the archive name (default: short hostname)
# FORCE=1 Skip restore confirmation prompt
set -euo pipefail
CMD="${1:-backup}"
if [[ -z "${WP_PATH:-}" ]]; then
if [[ -f /var/www/cesa.co.za/wp-config.php ]]; then
WP_PATH=/var/www/cesa.co.za
else
WP_PATH="$(pwd)"
fi
fi
BACKUP_DIR="${BACKUP_DIR:-/var/backups/wp-files}"
KEEP="${KEEP:-10}"
LABEL="${LABEL:-$(hostname -s 2>/dev/null || echo wp)}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
die() {
echo "ERROR: $*" >&2
exit 1
}
require_wp_root() {
[[ -f "$WP_PATH/wp-config.php" ]] || die "wp-config.php not found in WP_PATH=$WP_PATH"
[[ -d "$WP_PATH/wp-admin" && -d "$WP_PATH/wp-includes" ]] || die "Does not look like a WordPress root: $WP_PATH"
}
resolve_backup_dir() {
if ! mkdir -p "$BACKUP_DIR" 2>/dev/null; then
die "Cannot create BACKUP_DIR=$BACKUP_DIR (permission denied). Run once:
sudo mkdir -p $BACKUP_DIR
sudo chown $(id -un):$(id -gn) $BACKUP_DIR
sudo chmod 750 $BACKUP_DIR"
fi
[[ -w "$BACKUP_DIR" ]] || die "BACKUP_DIR=$BACKUP_DIR is not writable by $(id -un)"
BACKUP_DIR="$(cd "$BACKUP_DIR" && pwd)"
}
wp_version() {
if [[ -f "$WP_PATH/wp-includes/version.php" ]]; then
sed -n "s/^\\\$wp_version *= *'\\([^']*\\)'.*/\\1/p" "$WP_PATH/wp-includes/version.php" | head -1
fi
}
prune_old_backups() {
local keep="$1"
[[ "$keep" =~ ^[0-9]+$ ]] || return 0
[[ "$keep" -eq 0 ]] && return 0
mapfile -t archives < <(ls -1t "$BACKUP_DIR"/cesa-wp-files-*.tar.gz 2>/dev/null || true)
local i
for ((i = keep; i < ${#archives[@]}; i++)); do
echo " prune: ${archives[$i]}"
rm -f "${archives[$i]}"
rm -f "${archives[$i]%.tar.gz}.txt" 2>/dev/null || true
done
}
# Optional root files — only add those that exist so tar does not fail.
collect_root_files() {
local -a candidates=(
index.php
wp-activate.php
wp-blog-header.php
wp-comments-post.php
wp-config.php
wp-config-sample.php
wp-cron.php
wp-links-opml.php
wp-load.php
wp-login.php
wp-mail.php
wp-settings.php
wp-signup.php
wp-trackback.php
xmlrpc.php
license.txt
readme.html
.htaccess
)
ROOT_FILES=()
local f
for f in "${candidates[@]}"; do
[[ -e "$WP_PATH/$f" ]] && ROOT_FILES+=("$f")
done
}
do_backup() {
require_wp_root
WP_PATH="$(cd "$WP_PATH" && pwd)"
resolve_backup_dir
collect_root_files
local archive="$BACKUP_DIR/cesa-wp-files-${LABEL}-${TIMESTAMP}.tar.gz"
local manifest="$BACKUP_DIR/cesa-wp-files-${LABEL}-${TIMESTAMP}.txt"
local ver
ver="$(wp_version || true)"
echo "==> WordPress path : $WP_PATH"
echo "==> Backup dir : $BACKUP_DIR"
echo "==> Archive : $archive"
echo "==> WP version : ${ver:-unknown}"
echo "==> Excluding : uploads, cache, upgrade/temp, logs, SQL/zip dumps"
local -a excludes=(
--exclude='wp-content/uploads'
--exclude='wp-content/Uploads'
--exclude='wp-content/cache'
--exclude='wp-content/upgrade'
--exclude='wp-content/upgrade-temp-backup'
--exclude='wp-content/temp'
--exclude='wp-content/tmp'
--exclude='wp-content/backups'
--exclude='wp-content/backup*'
--exclude='wp-content/updraft'
--exclude='wp-content/ai1wm-backups'
--exclude='wp-content/wflogs'
--exclude='wp-content/debug.log'
--exclude='wp-content/**/*.log'
--exclude='*.sql'
--exclude='*.sql.gz'
--exclude='node_modules'
--exclude='.git'
)
# If BACKUP_DIR lives under WP_PATH, exclude it from the archive
if [[ "$BACKUP_DIR" == "$WP_PATH"/* ]]; then
excludes+=(--exclude="${BACKUP_DIR#"$WP_PATH"/}")
fi
local -a tar_extra=()
if tar --help 2>&1 | grep -q -- '--warning='; then
tar_extra+=(--warning=no-file-changed)
fi
# Core + full wp-content tree with uploads/caches excluded above.
# Exit 1 from tar (file changed while reading) is tolerated on busy sites.
set +e
tar "${tar_extra[@]}" -czf "$archive" \
"${excludes[@]}" \
-C "$WP_PATH" \
wp-admin \
wp-includes \
wp-content \
"${ROOT_FILES[@]}"
local tar_rc=$?
set -e
# GNU tar: 0 ok, 1 some files changed/differed while reading
if [[ "$tar_rc" -ne 0 && "$tar_rc" -ne 1 ]]; then
die "tar failed with exit code $tar_rc"
fi
[[ -s "$archive" ]] || die "Archive was not created or is empty: $archive"
{
echo "CESA WordPress file backup (no uploads, no database)"
echo "created: $(date -Is)"
echo "host: $(hostname -f 2>/dev/null || hostname)"
echo "wp_path: $WP_PATH"
echo "wp_version: ${ver:-unknown}"
echo "archive: $archive"
echo "sha256: $(sha256sum "$archive" | awk '{print $1}')"
echo "size_bytes: $(wc -c <"$archive" | tr -d ' ')"
echo ""
echo "Included: WordPress core, wp-config.php, .htaccess (if present),"
echo " wp-content/themes, plugins, mu-plugins, languages, drop-ins"
echo "Excluded: wp-content/uploads, caches, upgrade temps, logs, SQL dumps"
echo ""
echo "Restore:"
echo " WP_PATH=$WP_PATH bash $0 restore $archive"
} >"$manifest"
prune_old_backups "$KEEP"
echo ""
echo "Done."
echo " archive : $archive"
echo " manifest: $manifest"
echo " size : $(du -h "$archive" | awk '{print $1}')"
}
do_restore() {
local archive="${2:-}"
[[ -n "$archive" ]] || die "Usage: $0 restore /path/to/cesa-wp-files-....tar.gz"
[[ -f "$archive" ]] || die "Archive not found: $archive"
require_wp_root
WP_PATH="$(cd "$WP_PATH" && pwd)"
archive="$(cd "$(dirname "$archive")" && pwd)/$(basename "$archive")"
echo "==> Restore WordPress files (core / themes / plugins) from:"
echo " $archive"
echo "==> Target WP_PATH: $WP_PATH"
echo "==> Uploads and database are NOT touched."
echo ""
echo "This will overwrite matching files under WP_PATH."
if [[ -t 0 && "${FORCE:-}" != "1" ]]; then
read -r -p "Type YES to continue: " confirm
[[ "$confirm" == "YES" ]] || die "Aborted."
fi
tar -xzf "$archive" -C "$WP_PATH"
echo ""
echo "Restore complete."
echo " Next: clear object/page caches; verify Plugins / Themes."
echo " Database was not restored — use your DB backup if needed."
}
usage() {
# Print the leading comment block (instructions) until the first non-comment line.
awk '
NR == 1 { next }
/^#/ { sub(/^# ?/, ""); print; next }
{ exit }
' "$0"
}
case "$CMD" in
backup)
do_backup
;;
restore)
do_restore "$@"
;;
-h|--help|help)
usage
;;
*)
die "Unknown command: $CMD (use backup|restore|help)"
;;
esac