Как настроить автоматические бэкапы базы данных: cron, CI и S3

Обновлено:

Дисковый сбой, случайный 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 дополнительно можно включить версионирование объектов.