Дисковый сбой, случайный DROP TABLE, ошибочная миграция от AI-ассистента, взлом — базу можно потерять по-разному, и почти всегда в неудобный момент. Цель — бэкапы, которые делаются сами, хранятся отдельно от сервера и регулярно проверяются восстановлением. Ниже — рабочие варианты: от cron-скрипта на VPS до CI и Docker-сайдкара.
Почему ручные бэкапы не работают
Ручной бэкап — это бэкап, который однажды не сделали. О нём вспоминают перед рискованным деплоем, а теряют данные обычно в обычный вторник. Поэтому бэкап должен запускаться по расписанию, а о пропущенном запуске вы должны узнавать сразу.
Правило 3-2-1:
- 3 — три копии данных;
- 2 — на двух разных типах носителей;
- 1 — одна копия в другом месте (другой провайдер или регион).
Для типичного веб-проекта это означает: локальный дамп на VPS + копия в S3-совместимом хранилище (Yandex Object Storage, Selectel и т. п.).
Сценарий 1: cron + pg_dump (PostgreSQL на VPS)
Классический подход для PostgreSQL на Ubuntu/Debian без дополнительных зависимостей. Подробный разбор с S3 и ротацией — в статье pg_dump по cron в S3.
Базовый скрипт бэкапа
#!/bin/bash
# /opt/scripts/backup-postgres.sh
set -euo pipefail # pipefail: если pg_dump упадёт, скрипт не сохранит пустой .gz
DB_NAME="myapp_production"
BACKUP_DIR="/var/backups/postgresql"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
# Plain SQL-дамп со сжатием; pg_dump не блокирует чтение и запись
pg_dump "$DB_NAME" | gzip > "$BACKUP_FILE"
gzip -t "$BACKUP_FILE" # проверка, что архив не битый
echo "Бэкап создан: $BACKUP_FILE ($(du -sh "$BACKUP_FILE" | cut -f1))"
# Удаляем дампы старше RETENTION_DAYS дней
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
chmod +x /opt/scripts/backup-postgres.sh
sudo install -d -o postgres -g postgres /var/backups/postgresql
# crontab пользователя postgres (локальный вход по peer-аутентификации)
sudo crontab -e -u postgres
# каждый день в 3:00
0 3 * * * /opt/scripts/backup-postgres.sh >> /var/log/pg-backup.log 2>&1
Файл лога /var/log/pg-backup.log должен быть доступен пользователю postgres на запись — создайте его заранее и выдайте права.
Вариант с отправкой в S3
#!/bin/bash
set -euo pipefail
DB_NAME="${DB_NAME:-myapp}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TMP_FILE="$(mktemp /tmp/backup_XXXXXX.sql.gz)"
S3_BUCKET="${S3_BUCKET:-s3://my-backups}"
S3_KEY="postgresql/${DB_NAME}/${TIMESTAMP}.sql.gz"
trap 'rm -f "$TMP_FILE"' EXIT
pg_dump "$DATABASE_URL" | gzip > "$TMP_FILE"
aws s3 cp "$TMP_FILE" "${S3_BUCKET}/${S3_KEY}" \
--endpoint-url "${S3_ENDPOINT:-https://storage.yandexcloud.net}"
echo "Бэкап загружен: ${S3_BUCKET}/${S3_KEY}"
Переменные для Yandex Object Storage (храните их в файле с правами 600, а не в скрипте):
export AWS_ACCESS_KEY_ID="ваш_access_key"
export AWS_SECRET_ACCESS_KEY="ваш_secret_key"
export AWS_DEFAULT_REGION="ru-central1"
export S3_ENDPOINT="https://storage.yandexcloud.net"
export S3_BUCKET="s3://ваш-бакет"
Для Selectel эндпоинт — https://s3.ru-1.storage.selcloud.ru (регион ru-1). Старые копии в бакете удобнее удалять правилом жизненного цикла (lifecycle), а не скриптом.
Бэкап MySQL/MariaDB через cron
Пароль не передавайте в командной строке: --password=... и -pSECRET видны в ps другим пользователям сервера. Положите учётные данные в отдельный файл:
# /root/.my-backup.cnf (chmod 600)
[client]
user=backup
password=секрет
host=localhost
#!/bin/bash
# backup-mysql.sh
set -euo pipefail
DB_NAME="${DB_NAME:-myapp}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/var/backups/mysql/${DB_NAME}_${TIMESTAMP}.sql.gz"
mkdir -p /var/backups/mysql
# --defaults-extra-file должен идти первым аргументом
mysqldump --defaults-extra-file=/root/.my-backup.cnf \
--single-transaction \
--routines --triggers --events \
--hex-blob \
"$DB_NAME" | gzip > "$BACKUP_FILE"
echo "MySQL бэкап: $BACKUP_FILE"
--single-transaction даёт согласованный снимок только для таблиц InnoDB; не сочетайте его с --lock-tables. Ротацию и восстановление разбираем в статье mysqldump: автоматизация и ротация.
Бэкап SQLite (PocketBase, локальные базы)
Простое копирование файла во время записи может дать повреждённую копию, особенно в режиме WAL: часть транзакций ещё лежит в файле -wal. Используйте online backup API через команду .backup или VACUUM INTO — подробности в статье как сделать бэкап SQLite без повреждения.
#!/bin/bash
# backup-sqlite.sh
set -euo pipefail
DB_FILE="${DB_FILE:-/opt/pocketbase/pb_data/data.db}"
BACKUP_DIR="/var/backups/sqlite"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/pocketbase_${TIMESTAMP}.db"
mkdir -p "$BACKUP_DIR"
# Согласованная копия живой базы через online backup API
sqlite3 "$DB_FILE" ".backup '$BACKUP_FILE'"
sqlite3 "$BACKUP_FILE" "PRAGMA quick_check;"
gzip "$BACKUP_FILE"
echo "SQLite бэкап: ${BACKUP_FILE}.gz"
# Храним 7 дней
find "$BACKUP_DIR" -name "*.db.gz" -mtime +7 -delete
Сценарий 2: GitHub Actions — бэкап без своего сервера
Подходит для managed-баз (Supabase, Neon, Railway и т. п.), где у вас есть строка подключения, но нет доступа к серверу.
# .github/workflows/daily-backup.yml
name: Daily Database Backup
on:
schedule:
- cron: '0 2 * * *' # каждый день в 2:00 UTC
workflow_dispatch: # ручной запуск
jobs:
backup:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Install PostgreSQL client
run: sudo apt-get update && sudo apt-get install -y postgresql-client
- name: Create database dump
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
set -o pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
pg_dump "$DATABASE_URL" | gzip > backup_${TIMESTAMP}.sql.gz
echo "BACKUP_FILE=backup_${TIMESTAMP}.sql.gz" >> "$GITHUB_ENV"
- name: Upload to S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ru-central1
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
run: |
aws s3 cp "$BACKUP_FILE" \
"s3://${S3_BUCKET}/backups/${BACKUP_FILE}" \
--endpoint-url "$S3_ENDPOINT"
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Database backup failed!"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Что учесть:
- версия
pg_dumpдолжна быть не старше версии сервера, иначе дамп не создастся — при необходимости ставьте клиент из репозитория PGDG; - расписание GitHub Actions не гарантирует точное время запуска, а в публичных репозиториях scheduled-воркфлоу отключаются после долгого отсутствия активности;
- лимиты бесплатных минут зависят от тарифа и видимости репозитория — проверьте текущие условия у GitHub.
Сценарий 3: Docker Compose сайдкар
Если приложение работает в Docker, бэкапы может делать отдельный контейнер рядом с базой. Самый простой вариант без cron-демона — цикл со sleep:
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
backup:
image: postgres:16-alpine # тот же мажор, что и у сервера
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/myapp
S3_BUCKET: ${S3_BUCKET}
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
AWS_DEFAULT_REGION: ru-central1
S3_ENDPOINT: https://storage.yandexcloud.net
volumes:
- ./scripts/backup.sh:/backup.sh:ro
entrypoint: ["/bin/sh", "-c"]
command:
- |
apk add --no-cache bash aws-cli
while true; do bash /backup.sh || echo "backup failed"; sleep 21600; done
depends_on:
- db
restart: unless-stopped
volumes:
pgdata:
Здесь backup.sh — скрипт с отправкой в S3 из первого сценария, запуск раз в 6 часов. Если нужно точное время, запускайте docker compose exec из cron хоста. Подробнее про Docker и Coolify — в статье бэкап БД в Docker и Coolify.
Сценарий 4: Railway / Render — cron job сервис
На Railway и Render можно завести отдельный сервис, который запускается по расписанию и завершается после работы. Пример скрипта на Node.js, который делает дамп в custom-формате и кладёт его в S3:
// backup-job/index.ts
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createReadStream } from 'node:fs'
import { unlink } from 'node:fs/promises'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const execFileAsync = promisify(execFile)
const s3 = new S3Client({
region: 'ru-central1',
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
})
async function backup() {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `backup_${timestamp}.dump`
const tmpPath = `/tmp/${filename}`
// Без shell: строка подключения не попадает в интерпретатор команд,
// а ошибка pg_dump не теряется в пайпе
await execFileAsync('pg_dump', [
'--format=custom',
`--file=${tmpPath}`,
process.env.DATABASE_URL!,
])
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: `backups/${filename}`,
Body: createReadStream(tmpPath),
ContentType: 'application/octet-stream',
}))
await unlink(tmpPath)
console.log(`Загружен в S3: backups/${filename}`)
}
backup().catch((err) => {
console.error('Ошибка бэкапа:', err)
process.exit(1)
})
Custom-формат (-Fc) уже сжат и восстанавливается через pg_restore, а не через psql.
Сценарий 5: хранение версий и оповещения в dbsend
Все сценарии выше создают файлы, но вопросы хранения версий, оповещений о пропущенном бэкапе и проверки остаются на вас. dbsend закрывает именно эту часть. Он не подключается к вашему серверу и не запускает pg_dump/mysqldump — дамп вы по-прежнему создаёте сами, а затем загружаете его через CLI.
Что добавляет dbsend:
- каждая загрузка становится версией базы (v1, v2, …) с меткой; если файл побайтно совпадает с последней версией, новая версия не создаётся;
- для SQLite, DuckDB и plain SQL-дампов PostgreSQL/MySQL видны схема, таблицы и число строк, а на платных тарифах — diff между любыми двумя версиями;
- проверка SHA-256 при загрузке и при восстановлении, для SQLite —
PRAGMA quick_checkпри загрузке иPRAGMA integrity_checkпри восстановлении; - оповещение в Telegram, на email или webhook, если бэкап не пришёл по расписанию (на платных тарифах);
- хранение в S3 в России с шифрованием AES-256-GCM.
Чего dbsend не делает: не применяет дамп к серверу при восстановлении (это psql/mysql на вашей стороне) и не разбирает custom/tar/directory-форматы pg_dump — их можно хранить только как обычный файл.
# 1. CLI и ключ (Node.js 22.12+)
npm install --global @dbsend/sdk
dbsend login --key sqv_pk_...
# 2. ID базы, созданной в личном кабинете
dbsend databases
# 3. Дамп штатной утилитой и загрузка
pg_dump --format=plain "$DATABASE_URL" > backup.sql
dbsend backup ./backup.sql -d "$DBSEND_DATABASE_ID" -e postgresql_dump -l "scheduled-$(date +%F-%H%M)"
# 4. История версий
dbsend log "$DBSEND_DATABASE_ID"
Те же действия доступны из SDK для Node.js:
import { dbsend } from '@dbsend/sdk'
const client = new dbsend({ apiKey: process.env.DBSEND_API_KEY! })
await client.backup('./backup.sql', {
databaseId: process.env.DBSEND_DATABASE_ID,
engine: 'postgresql_dump',
label: `pre-deploy-${process.env.GIT_SHA}`,
})
console.log(await client.listSnapshots(process.env.DBSEND_DATABASE_ID!))
Бэкап перед деплоем в CI/CD
# .github/workflows/deploy.yml
- name: Backup before deploy
run: |
pg_dump --format=plain "$DATABASE_URL" > prod.sql
npx --package @dbsend/sdk dbsend backup ./prod.sql -d "$DBSEND_DATABASE_ID" -e postgresql_dump -l "pre-deploy-${{ github.sha }}"
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DBSEND_API_KEY: ${{ secrets.DBSEND_API_KEY }}
DBSEND_DATABASE_ID: ${{ secrets.DBSEND_DATABASE_ID }}
- name: Deploy
run: ./deploy.sh
- name: Download the last backup on failure
if: failure()
run: |
SNAPSHOT_ID=$(npx --package @dbsend/sdk dbsend snapshots "$DBSEND_DATABASE_ID" --json | jq -r '.[0].id')
npx --package @dbsend/sdk dbsend restore "$SNAPSHOT_ID" ./restore.sql --yes
# Применяйте дамп вручную после проверки:
# psql -v ON_ERROR_STOP=1 "$DATABASE_URL" -f ./restore.sql
env:
DBSEND_API_KEY: ${{ secrets.DBSEND_API_KEY }}
DBSEND_DATABASE_ID: ${{ secrets.DBSEND_DATABASE_ID }}
Все команды и коды выхода — в документации CLI, тарифы — на странице /pricing.
Чем хороший бэкап отличается от плохого
Настроить бэкап и проверить бэкап — разные задачи.
Регулярное восстановление. Хотя бы раз в месяц разворачивайте бэкап в тестовую базу и проверяйте данные. Повреждённый бэкап равен отсутствию бэкапа. Как это делать — в статье как проверить, что бэкап рабочий.
Мониторинг. Каждый запуск должен логироваться, а о неудачном вы должны узнать сразу, а не при попытке восстановления.
Несколько версий. Логическая порча данных (неверная миграция, баг в коде) может обнаружиться через дни. Нужна возможность откатиться на неделю назад, а не только на вчера.
Отдельное хранилище. Бэкап на том же диске, что и база, погибнет вместе с ней.
Чеклист
- [ ] Бэкап запускается автоматически по расписанию
- [ ] Копии хранятся вне сервера с базой (S3, другой провайдер)
- [ ] Уведомления о сбоях настроены (email, Telegram, Slack)
- [ ] Политика хранения определена (сколько дней/версий)
- [ ] Тестовое восстановление проводится регулярно
- [ ] Бэкап делается перед каждым деплоем
- [ ] Доступ к бэкапам ограничен (никаких публичных бакетов)
- [ ] Определены RTO (время восстановления) и RPO (допустимая потеря данных)
Частые вопросы
Как часто делать бэкапы базы данных?
Зависит от RPO — сколько данных вы готовы потерять. Для большинства веб-приложений достаточно бэкапа раз в 6–24 часа. Если потеря даже нескольких минут недопустима, нужен не дамп, а непрерывное архивирование WAL с восстановлением на момент времени (PITR). Минимальный стандарт для любого проекта — ежедневный бэкап плюс бэкап перед каждым деплоем.
Чем отличается pg_dump от pg_basebackup?
pg_dump — логический дамп одной базы: SQL-команды или архив custom-формата. Переносим между версиями PostgreSQL и удобен для небольших и средних баз. pg_basebackup — физическая копия всего кластера: восстанавливается только на той же мажорной версии, зато вместе с архивом WAL позволяет вернуться на любой момент времени. Для большинства проектов хватает pg_dump.
Можно ли делать бэкап PostgreSQL без даунтайма?
Да. pg_dump работает внутри одной транзакции со снимком MVCC и не блокирует обычные чтение и запись. Он берёт блокировки ACCESS SHARE, поэтому мешает только DDL вроде ALTER TABLE или DROP. Флаг --lock-wait-timeout=<мс> задаёт, сколько ждать эти блокировки, прежде чем завершиться с ошибкой. pg_basebackup тоже работает без остановки сервера.
Сколько хранить бэкапы?
Частая схема: ежечасные — сутки, ежедневные — 30 дней, еженедельные — 3 месяца, ежемесячные — год. Сроки для персональных данных определяйте вместе с юристом: закон может требовать не только хранить, но и удалять данные. Удаление старых копий в S3 автоматизируется правилами жизненного цикла бакета.
Нужно ли шифровать бэкапы?
Да, если в базе есть персональные данные. Минимум — шифрование на стороне хранилища (SSE), лучше — шифрование на клиенте (например, gpg или age) перед загрузкой. В dbsend файлы хранятся зашифрованными AES-256-GCM, передача — по HTTPS; подробнее в разделе безопасность.
Как восстановить PostgreSQL из pg_dump?
# plain .sql
psql -v ON_ERROR_STOP=1 -U postgres -d myapp -f backup.sql
# .sql.gz
gunzip -c backup.sql.gz | psql -v ON_ERROR_STOP=1 -U postgres -d myapp
# custom-формат (-Fc)
pg_restore --clean --if-exists -U postgres -d myapp backup.dump
Остальные варианты, включая MySQL и SQLite, — в статье как восстановить базу из бэкапа.
Что делать, если бэкап повреждён?
Проверьте архив: gzip -t backup.sql.gz, для SQLite — PRAGMA integrity_check;. Если копия битая, берите предыдущую: поэтому и нужно хранить несколько версий и регулярно делать тестовое восстановление. В S3 дополнительно можно включить версионирование объектов.



