Бэкап базы в Docker и Coolify: PostgreSQL, MySQL, SQLite

База в контейнере — это файлы в Docker volume, и бэкап у неё такой же, как у базы на обычном сервере: дамп средствами самой СУБД. Отличия — в деталях: как правильно вызвать pg_dump через docker exec, чтобы не испортить файл, где взять пароль внутри контейнера и что делать с SQLite, которая лежит в volume приложения. Ниже — команды для PostgreSQL, MySQL и SQLite, расписание и встроенные бэкапы Coolify.

Почему нельзя просто скопировать volume

Самая частая ошибка — tar или cp -r каталога /var/lib/docker/volumes/... при работающей базе. PostgreSQL и MySQL пишут в несколько файлов одновременно и держат часть изменений в памяти и журналах. Копирование идёт файл за файлом несколько секунд или минут, и за это время база меняет уже скопированные файлы. На выходе — набор файлов из разных моментов времени, который при запуске может не подняться или подняться с повреждёнными таблицами.

Копировать файлы данных можно только в двух случаях:

  1. Контейнер с базой остановлен.
  2. Вы делаете атомарный снимок файловой системы (LVM, ZFS, снапшот диска у облачного провайдера) — тогда база при старте пройдёт обычное восстановление после сбоя. Но это отдельная тема со своими оговорками.

Во всех остальных случаях делайте логический дамп: pg_dump, mysqldump, sqlite3 .backup.

docker exec: -t ломает дамп

Флаг -t выделяет псевдотерминал. Через терминал переводы строк превращаются в \r\n, а сообщения из stderr смешиваются с выводом. Для текстового SQL это даёт файл с лишними \r и предупреждениями посередине, а бинарный дамп (pg_dump -Fc) становится нечитаемым. Правила:

  • Выгрузка из контейнера (вывод в файл на хосте): docker exec без -t, docker compose exec -T. Важно: docker compose exec по умолчанию выделяет TTY, поэтому -T там обязателен.
  • Загрузка в контейнер (дамп на stdin): docker exec -i или docker compose exec -T.

PostgreSQL в контейнере

docker compose exec -T db pg_dump -U app -d app --format=plain > app-$(date -u +%Y-%m-%dT%H%M%SZ).sql

В официальном образе postgres подключения через локальный сокет внутри контейнера обычно не требуют пароля, поэтому пароль в команде не нужен. Если в вашем образе это не так, задайте PGPASSWORD через -e из переменной окружения хоста, а не строкой в скрипте.

Плюс такого подхода: pg_dump из образа совпадает по версии с сервером, и проблема «клиент старее сервера» исчезает. Для сжатия и проверки ошибок используйте конвейер с set -o pipefail:

#!/usr/bin/env bash
set -euo pipefail
cd /opt/myapp
OUT="/var/backups/myapp/app-$(date -u +%Y-%m-%dT%H%M%SZ).sql.gz"
docker compose exec -T db pg_dump -U app -d app --format=plain | gzip > "$OUT.part"
mv "$OUT.part" "$OUT"

Роли кластера в дамп одной базы не попадают — их выгружает docker compose exec -T db pg_dumpall -U app --globals-only. Подробно о форматах, pipefail, загрузке в Yandex Object Storage и Selectel — в статье про бэкап PostgreSQL через pg_dump в S3.

MySQL и MariaDB в контейнере

Пароль root в официальных образах лежит в переменной окружения контейнера. Главная ловушка — кавычки:

# правильно: одинарные кавычки, переменная раскрывается внутри контейнера
docker compose exec -T db sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" \
  --single-transaction --routines --triggers --events --hex-blob --no-tablespaces app' > app.sql

С двойными кавычками $MYSQL_ROOT_PASSWORD раскроется на хосте, где переменной нет, и mysqldump запросит пароль или упадёт.

Пароль в -p виден в списке процессов внутри контейнера. Альтернатива — MYSQL_PWD:

docker compose exec -T db sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot \
  --single-transaction --routines --triggers --events --hex-blob --no-tablespaces app' > app.sql

Документация MySQL считает MYSQL_PWD небезопасной: окружение процесса можно прочитать. Здесь это мало что меняет — тот же пароль уже лежит в окружении контейнера и виден через docker inspect. Надёжнее завести отдельного пользователя только с правами на чтение и смонтировать в контейнер файл [client] с правами 600 для --defaults-extra-file. В образе mariadb используйте mariadb-dump и переменную MARIADB_ROOT_PASSWORD.

Почему важны --single-transaction, --routines и --events и как настроить ротацию копий, описано в статье бэкап MySQL: автоматизация mysqldump.

SQLite в volume приложения

SQLite — это файл внутри volume приложения, отдельного контейнера с базой нет. Простой cp файла при работающем приложении небезопасен, особенно в режиме WAL: часть транзакций ещё лежит в файле -wal. Нужен онлайн-бэкап через .backup или VACUUM INTO.

Если в образе приложения есть sqlite3:

docker compose exec -T app sqlite3 /data/app.db ".backup '/data/app-backup.db'"
docker compose cp app:/data/app-backup.db ./app-$(date +%F).db

Если sqlite3 в образе нет, запустите временный контейнер с тем же volume:

docker volume ls    # имя volume в compose обычно с префиксом проекта: myapp_app_data
docker run --rm \
  -v myapp_app_data:/data \
  -v /var/backups/myapp:/backup \
  alpine sh -c 'apk add --no-cache sqlite >/dev/null && sqlite3 /data/app.db ".backup /backup/app-$(date +%F).db"'

Блокировки SQLite работают между процессами на одном хосте, так что приложение продолжит работать, пока идёт копирование. После бэкапа полезно проверить копию: sqlite3 app-2026-10-03.db "PRAGMA integrity_check;". Подробно про .backup, VACUUM INTO и WAL — в статье как сделать бэкап SQLite.

Tar volume — только при остановленном контейнере

Иногда нужен именно файловый бэкап volume, например перед обновлением мажорной версии. Порядок:

docker compose stop db
docker run --rm \
  -v myapp_pgdata:/source:ro \
  -v /var/backups/myapp:/backup \
  alpine tar czf /backup/pgdata-$(date +%F).tar.gz -C /source .
docker compose start db

Такой архив восстанавливается только в образ той же мажорной версии СУБД. Для регулярных бэкапов он не подходит: каждый раз нужна остановка базы.

Расписание: cron на хосте или sidecar

Самый простой вариант — скрипт с docker compose exec -T и cron или systemd timer на хосте:

# crontab -e
15 3 * * * flock -n /tmp/myapp-backup.lock /opt/myapp/backup.sh >> /var/log/myapp-backup.log 2>&1

Если доступа к cron хоста нет или хочется держать всё в compose.yaml, добавьте отдельный сервис-sidecar на базе того же образа postgres — в нём уже есть pg_dump нужной версии:

services:
  db-backup:
    image: postgres:17
    depends_on:
      - db
    env_file: .env.backup          # PGHOST=db, PGUSER, PGDATABASE, PGPASSWORD
    volumes:
      - ./backups:/backups
    entrypoint: ["bash", "-c"]
    command:
      - |
        set -o pipefail
        while true; do
          f=/backups/app-$$(date -u +%Y-%m-%dT%H%M%SZ).sql.gz
          pg_dump --format=plain | gzip > "$$f.part" && mv "$$f.part" "$$f" || echo "backup failed"
          find /backups -name 'app-*.sql.gz' -mtime +7 -delete
          sleep 86400
        done

$$ нужен, чтобы Compose не подставлял переменные сам. У цикла со sleep время запуска «плывёт» после каждого перезапуска контейнера. Если нужно точное расписание, используйте внутри sidecar планировщик для контейнеров, например supercronic: соберите свой образ на базе postgres и скачайте бинарник из официальных релизов проекта. PGPASSWORD в .env.backup виден через docker inspect — выдайте этому пользователю только права на чтение.

Coolify: встроенные бэкапы и Scheduled Tasks

Если базы развёрнуты как ресурсы Coolify, проще всего воспользоваться встроенными бэкапами. Для PostgreSQL, MySQL, MariaDB, MongoDB и ClickHouse на странице базы есть раздел Backups, где можно добавить расписание (cron-выражение или daily, weekly и т. п.), задать хранение по количеству копий, сроку или объёму и включить отправку в S3. Хранилище сначала добавляется и проверяется в настройках S3 Storages — подойдёт и Yandex Object Storage, и Selectel. Локальные копии по умолчанию остаются на сервере; их можно отключить после успешной загрузки в S3. Для Redis этот механизм не предусмотрен. Названия пунктов меню меняются от версии к версии, поэтому сверяйтесь с документацией Coolify для вашей версии.

SQLite внутри приложения Coolify не видит как базу. Для неё есть Scheduled Tasks в настройках приложения: команда с cron-расписанием выполняется в shell внутри работающего контейнера. Если в образе приложения есть sqlite3, задача может выглядеть так (каталог для копий должен существовать и лежать в постоянном хранилище):

sqlite3 /data/app.db ".backup '/data/backups/app-latest.db'"

Дальше копию нужно вывезти с сервера — например, скриптом, который отправит файл в S3 из того же контейнера, если там есть нужный клиент. Задачу сначала запустите вручную и проверьте результат. Выбор между Coolify и другими PaaS — в обзоре аналогов Railway в России.

Копия вне сервера и восстановление

Бэкап, который лежит на том же диске, что и база, не спасёт от удалённого сервера, сбоя диска или ошибки в docker volume prune. Минимум одна копия должна уезжать в S3 или на другую машину.

Восстановление в контейнер — тот же конвейер в обратную сторону, с -T или -i:

# PostgreSQL
gunzip -c app.sql.gz | docker compose exec -T db psql -U app -d app -v ON_ERROR_STOP=1

# MySQL
docker compose exec -T db sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" app' < app.sql

# SQLite: остановить приложение, подменить файл, убрать старые -wal/-shm
docker compose stop app
docker run --rm -v myapp_app_data:/data -v /var/backups/myapp:/backup alpine \
  sh -c 'cp /backup/app-2026-10-03.db /data/app.db && rm -f /data/app.db-wal /data/app.db-shm'
docker compose start app

Восстанавливайте PostgreSQL в пустую базу, иначе psql упадёт на уже существующих объектах. Остальные сценарии описаны в статье как восстановить базу из бэкапа, а регулярная проверка копий — в материале как проверить, что бэкап рабочий.

Как это сделать в dbsend

dbsend не подключается к контейнерам и не запускает дампы: вы делаете дамп командами выше, а CLI загружает файл. Для SQLite CLI сам делает согласованную копию через VACUUM INTO, поэтому ему достаточно пути к файлу — например, к каталогу volume, смонтированному на хосте.

npm install --global @dbsend/sdk
export DBSEND_API_KEY='sqv_pk_…'

docker compose exec -T db pg_dump -U app -d app --format=plain > /var/backups/myapp/app.sql
dbsend backup /var/backups/myapp/app.sql -d "$DBSEND_DATABASE_ID" -e postgresql_dump -l "nightly-$(date +%F)"

dbsend backup /srv/myapp/data/app.db -d "$DBSEND_SQLITE_ID" -e sqlite

Каждая загрузка становится версией. Для SQLite и plain-дампов PostgreSQL/MySQL видны схема и число строк, а между любыми двумя версиями доступен diff. Если бэкап не пришёл по расписанию, приходит уведомление в Telegram, на почту или в webhook. Хранение — S3 в России с шифрованием AES-256-GCM. Diff и уведомления доступны на платных тарифах, подробности на странице тарифов. Команды CLI описаны в документации, возможности для Postgres — на странице бэкапов PostgreSQL.

Частые вопросы

Можно ли делать бэкап Docker volume через tar при работающей базе?

Для PostgreSQL и MySQL — нет: файлы копируются в разные моменты времени, и база может не подняться из такой копии. Либо останавливайте контейнер, либо делайте логический дамп через pg_dump или mysqldump.

Почему дамп через docker exec получился битым?

Скорее всего, вы использовали docker exec -it или docker compose exec без -T. Псевдотерминал меняет переводы строк и подмешивает stderr в вывод. Для выгрузки используйте docker exec без -t или docker compose exec -T.

Нужен ли отдельный бэкап, если Coolify уже делает бэкапы базы?

Встроенных бэкапов Coolify достаточно, если они уходят в S3 вне сервера и вы проверили восстановление. SQLite внутри приложения в эти бэкапы не входит — для неё настройте Scheduled Task или cron на хосте.

Где хранить бэкапы базы из Docker?

Не на том же сервере и тем более не в соседнем volume. Отправляйте копии в S3 (Yandex Object Storage, Selectel) или на другую машину, а на сервере держите несколько последних копий для быстрого отката.

Каким pg_dump делать бэкап — с хоста или из контейнера?

Из контейнера с базой или из sidecar на том же образе: версия pg_dump гарантированно совпадёт с сервером. pg_dump с хоста тоже подойдёт, если его версия не старее сервера и порт базы доступен.