Бэкап сайта на шаред-хостинге: bash + lftp + cron с чужого VPS
Год назад клиент написал в 23:47: «Сайт не открывается, хостинг говорит аккаунт заблокирован». Пока разбирались — выяснилось, что хостер сделал резервную копию, но пятидневной давности. Потеряли пять дней заказов интернет-магазина.
Самое обидное: у клиента стояло «резервное копирование» через галочку в панели хостинга. Просто никто не проверял куда. Оказалось — в папку на том же аккаунте. Том самом, который заблокировали.
После этого мы написали скрипт. Сейчас он с небольшими правками крутится на нескольких десятках клиентских сайтов.
Что имеется в распоряжении
На хостингах обычно доступно: SSH с ключом (chroot, без sudo), mysqldump, tar, gzip, lftp, curl. rsync — через раз, и об этом ниже.
Чего точно нет: root, apt install, /var/log в привычном виде, systemd. crontab через консоль — на части хостингов разрешён, на части только через веб-панель с ограниченным числом задач.
Почему не rsync
Честно — пробовали. rsync теоретически умеет работать через SSH, но без rsync-демона на принимающей стороне это значит «rsync к себе на тот же сервер», что бессмысленно. Если принимающая сторона — ваш VPS, то нужно настраивать rsync-демон на нём, пробрасывать порты, и внезапно это уже не «быстрое решение».
lftp с обычным FTP работает с первого раза и есть на любом хостинге, который я встречал за последние 8 лет.
Панельные бэкапы — отдельная история. Либо «скачать архив вручную», либо «сохранить на тот же аккаунт». Смысл второго варианта я так и не понял, но кнопка выглядит внушительно.
Как это устроено
[ shared-хостинг ] [ внешний FTP ] public_html/ ──► tar.gz ──┐ ├──► site-backups/ mysqldump ──► .sql ───┘ site-20260501-040015.tar.gz ▲ │ триггер по SSH └──── [ VPS с cron ] 15 4 * * * ssh user@host '~/scripts/backup.sh'
Скрипт живёт на хостинге, запускается снаружи. Это позволяет писать лог с двух сторон и видеть «бэкап не запускался 25 часов» без дополнительных инструментов. Если своего VPS нет — cron из панели хостинга тоже работает, скрипт не меняется.
Скрипт
Кладём в ~/scripts/backup.sh, строго выше public_html. Права chmod 700 — иначе другие пользователи хостинга прочитают пароли в открытом виде.
#!/bin/bash set -u SITE_DIR="/home/user/site/public_html" WORK_DIR="/home/user/scripts" LOG="$WORK_DIR/backup.log" TMP_DIR="$(mktemp -d /tmp/bk.XXXXXX)" DB_HOST="localhost" DB_NAME="mydb" DB_USER="mydb" DB_PASS='secret-here' FTP_HOST="ftp.example.com" FTP_USER="ftpuser" FTP_PASS='ftpsecret' FTP_DIR="site-backups" KEEP=30 TS="$(date +%Y%m%d-%H%M%S)" ARCHIVE="site-${TS}.tar.gz" ARCHIVE_PATH="$TMP_DIR/$ARCHIVE" log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; } trap 'rm -rf "$TMP_DIR"' EXIT mkdir -p "$WORK_DIR" log "=== backup start ($TS) ===" # дамп базы SQL_FILE="$TMP_DIR/db-${DB_NAME}-${TS}.sql" MYSQL_PWD="$DB_PASS" mysqldump --single-transaction --quick \ --routines --triggers --events --default-character-set=utf8mb4 \ -h "$DB_HOST" -u "$DB_USER" "$DB_NAME" > "$SQL_FILE" 2>>"$LOG" \ || { log "ERROR: mysqldump failed"; exit 1; } log "mysqldump OK: $(stat -c %s "$SQL_FILE") bytes" # архив файлов + дамп tar --warning=no-file-changed -czf "$ARCHIVE_PATH" \ --exclude='*/cache/*' --exclude='*/.git' --exclude='*/node_modules' \ -C "$(dirname "$SITE_DIR")" "$(basename "$SITE_DIR")" \ -C "$TMP_DIR" "$(basename "$SQL_FILE")" 2>>"$LOG" || true [ -s "$ARCHIVE_PATH" ] || { log "ERROR: empty archive"; exit 1; } log "archive: $(stat -c %s "$ARCHIVE_PATH") bytes" # заливка + список файлов для ротации lftp -u "$FTP_USER","$FTP_PASS" "$FTP_HOST" >>"$LOG" 2>&1 <<EOF set ftp:ssl-allow no set net:max-retries 3 mkdir -p $FTP_DIR cd $FTP_DIR put $ARCHIVE_PATH -o $ARCHIVE cls -1 site-*.tar.gz > $TMP_DIR/list.txt bye EOF log "upload OK: $ARCHIVE" # ротация на FTP TO_DEL=$(grep -E '^site-[0-9]{8}-[0-9]{6}\.tar\.gz$' "$TMP_DIR/list.txt" \ | sort | head -n -"$KEEP") if [ -n "$TO_DEL" ]; then { echo "set ftp:ssl-allow no" echo "cd $FTP_DIR" while IFS= read -r f; do echo "rm -f $f"; done <<< "$TO_DEL" echo "bye" } | lftp -u "$FTP_USER","$FTP_PASS" "$FTP_HOST" >>"$LOG" 2>&1 log "rotation: deleted $(echo "$TO_DEL" | wc -l)" fi log "=== backup done ==="
Вещи, которые неочевидны при первом прочтении
MYSQL_PWD вместо -p. Это не паранойя. Пароль в аргументе командной строки виден через ps aux всем пользователям сервера — а на shared их сотни. MYSQL_PWD читается mysqldump из окружения и в список процессов не попадает. Формально чище ~/.my.cnf с секцией [client] и chmod 600, но для одного скрипта оба варианта эквивалентны.
--single-transaction. Снимает консистентный снимок InnoDB без блокировки таблиц. Без него на живом магазине дамп может поймать транзакцию в промежуточном состоянии. Для MyISAM этот флаг ничего не даёт — но в 2026 году MyISAM это уже отдельная история.
--quick. Без него mysqldump загружает таблицу целиком в память и только потом дампит. На таблице с двумя миллионами строк это oom-killer и бэкап которого нет. Узнали не из документации.
--warning=no-file-changed у tar. На живом сайте файлы меняются прямо во время архивации — сессии, кеши. tar возвращает код 1 и пишет предупреждения, хотя архив нормальный. Без флага мониторинг считает бэкап упавшим. Флаг подавляет именно эти предупреждения, критические ошибки по-прежнему видны.
Грабли, которые мы прошли
Cron из панели не видит mysqldump. cron-задача получает урезанный PATH, а mysqldump живёт не там где ожидается. Скрипт падает сразу и молча. Решение — export PATH=/usr/local/bin:$PATH в начало.
SSH-триггер глотает ошибки. ssh user@host 'script.sh' — если скрипт упал, снаружи не видно ничего без явного логирования. Пишем с обеих сторон: внутри скрипта в файл на хостинге, с VPS тоже в файл. Несколько раз это выловило ситуацию когда mysqldump тихо умирал из-за квоты.
mkdir: Access failed: 550. lftp ругается при попытке создать уже существующую папку — mkdir -p в bash идемпотентен, в lftp нет. На сам бэкап не влияет, но выглядит страшно. Лечится добавлением set cmd:fail-exit no перед mkdir если нервирует.
Все архивы в корне FTP. Через полгода — 600 файлов непонятно от каких проектов. Делайте подпапки сразу: site1-backups/, shop-backups/. Ротация по маске site-*.tar.gz тогда не цепляет чужое.
Ротация на хостинге, а не на FTP. Видели такой вариант у коллег. Выглядит логично, но всё хранится на той же машине. Если что-то случилось с аккаунтом — архивы пропадают вместе с ним. Это именно та ситуация с которой началась статья.
Про проверку восстановления
Бэкап без проверки восстановления — это файлы на FTP с неизвестным содержимым. Звучит банально, но у двух клиентов из десяти бэкапы оказались нерабочими при первой проверке.
Мы делаем раз в месяц: скачиваем последний архив, разворачиваем локально в Docker, смотрим что сайт стартует и база читается. 15–20 минут. Раз в полгода — полное восстановление на тестовый аккаунт.
За три года так нашли два битых дампа: один раз кодировка utf8 вместо utf8mb4 резала кириллицу в именах, второй раз tar без -p потерял права на uploads/ и WordPress не мог сохранять файлы.
Что ещё можно добавить
Шифрование: gpg --symmetric --batch --passphrase "$GPG_PASS" "$ARCHIVE_PATH" перед заливкой, грузить .tar.gz.gpg. Тогда компрометация FTP не означает слив данных.
Мониторинг без своего сервиса: в конец скрипта curl -s "https://hc-ping.com/uuid". Healthchecks.io бесплатно алертит, если пинг не пришёл вовремя.
restic или borg: если можно поставить бинарник на хостинге — инкрементальные бэкапы с дедупликацией. На shared обычно нельзя, но если есть VPS — стоит смотреть в эту сторону вместо tar.
Итого
Схема bash + lftp + внешний SSH-триггер работает на любом хостинге с SSH. Ничего не нужно устанавливать, не нужен root, не нужен специфический стек.
Если у вас другой подход к бэкапам на shared — интересно услышать в комментариях.
