Blog

Automated MySQL and MariaDB Backups in Docker (2026 Guide)

Automate MySQL and MariaDB backups in Docker with mysqldump: cron, retention, encrypted off-site copies, restores and restore tests, with a complete script.

Max Diamond 15 min read

A bad migration, a dropped table, a full disk, a volume deleted by mistake. When MySQL or MariaDB runs in Docker, any of these can take your data with it, unless you have a backup that you know how to restore.

This guide shows how to back up MySQL and MariaDB in Docker with mysqldump, run the backup on a schedule, prune old files, encrypt them, copy them off the server and restore them. The examples use MySQL 8.4, the current LTS release. A table below lists the small differences for MariaDB.

Quick start: back up and restore in two commands

Back up a database from a running container named mysql:

Terminal window
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events mydb' | gzip > mydb.sql.gz

Restore it into an empty database:

Terminal window
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -e "CREATE DATABASE mydb_restored"'
gunzip -c mydb.sql.gz | docker exec -i mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot mydb_restored'

The commands run through sh -c so the root password comes from the container’s own environment. It never appears in your shell history or on the command line of the host.

MySQL and MariaDB command differences

Both databases use the same approach. Only the binary names and environment variables change:

MySQLMariaDB
Dump toolmysqldumpmariadb-dump
Clientmysqlmariadb
Admin toolmysqladminmariadb-admin
Root password variableMYSQL_ROOT_PASSWORDMARIADB_ROOT_PASSWORD
Imagemysql:8.4mariadb:11.4
--set-gtid-purged=OFFSupportedNot supported, leave it out

The mariadb* names are available from MariaDB 10.5. The mariadb:11 Docker images don’t include the mysql* names, so mysqldump, mysql and mysqladmin fail with “command not found”. Use the mariadb* names for MariaDB.

For the health check, the MariaDB image includes its own script. Replace the test line in the Compose file below with:

test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']

Logical vs physical backups

Logical (mysqldump)Physical (XtraBackup, mariadb-backup)
OutputSQL statementsA copy of the data files
Restore to a newer major versionYesNo
Point-in-time recoveryNo, only to the time of the dumpYes, with binary logs
Speed on large databasesSlow to dump and restoreFast
SetupOne commandMore configuration

For most applications under roughly 50 GB, mysqldump is the right choice, and it’s what the rest of this guide uses. For larger databases, jump to Large databases and point-in-time recovery.

Example MySQL setup

All the examples use this docker-compose.yml. The credentials live in a .env file next to it, which you should keep out of version control:

Terminal window
MYSQL_ROOT_PASSWORD=a-long-random-password
MYSQL_DATABASE=mydb
services:
db:
image: mysql:8.4
container_name: mysql
env_file: .env
volumes:
- db_data:/var/lib/mysql
healthcheck:
test:
[
'CMD-SHELL',
'mysqladmin ping -h 127.0.0.1 -uroot -p"$${MYSQL_ROOT_PASSWORD}"',
]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:

How to back up a MySQL database in a Docker container

The core command is mysqldump, run inside the container:

Terminal window
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events mydb' > mydb.sql

These options matter:

  • --single-transaction takes the dump from one consistent snapshot without locking InnoDB tables, so your application keeps reading and writing. It doesn’t protect MyISAM tables, which are rare today.
  • --routines and --events include stored procedures, functions and scheduled events. Triggers are included by default, but routines and events are not.
  • --set-gtid-purged=OFF (MySQL only): add it if SELECT @@gtid_mode returns ON. Without it, restoring onto a server that already has the dump’s GTIDs, such as the source server, fails. The mysql:8.4 image has GTIDs off by default.

Pipe the output through gzip, as in the quick start. A SQL dump often compresses to a tenth of its size.

Back up every database on the instance

To dump all databases, including the accounts and privileges in the mysql system schema, use --all-databases:

Terminal window
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events --flush-privileges --all-databases' | gzip > all.sql.gz

Restoring this dump replaces the accounts on the target, including root, so use it for full-instance recovery rather than copying one database between servers. --flush-privileges makes the restored accounts take effect without a restart.

Use a dedicated backup user

The examples in this guide run as root to keep them short. A backup job doesn’t need root, so for production, create a user that can only read:

CREATE USER 'backup'@'%' IDENTIFIED BY 'a-long-random-password';
GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES ON *.* TO 'backup'@'%';

Use '%', not 'localhost', so the user can also connect from another container, such as the sidecar below. Then put the password in a MYSQL_BACKUP_PASSWORD variable and swap the credentials in each command:

Terminal window
docker exec -e MYSQL_PWD="$MYSQL_BACKUP_PASSWORD" mysql mysqldump -ubackup --no-tablespaces --single-transaction --routines --events mydb | gzip > mydb.sql.gz

On MySQL, --no-tablespaces stops an access error, because the user doesn’t have the PROCESS privilege. MariaDB doesn’t need it. Restores still need an account that can write, such as root.

Automate backups with a sidecar container

The simplest automation is a second container that runs mysqldump in a loop:

services:
# db: ... (as above)
backup:
image: mysql:8.4
container_name: mysql_backup
environment:
MYSQL_PWD: ${MYSQL_ROOT_PASSWORD}
depends_on:
db:
condition: service_healthy
volumes:
- ./backups:/backups
entrypoint: >
bash -c 'set -o pipefail; while true; do
file=/backups/db-$$(date +%F-%H%M%S).sql.gz;
if mysqldump -h db -uroot --single-transaction --routines --events --databases ${MYSQL_DATABASE} | gzip > "$$file.tmp"; then
mv "$$file.tmp" "$$file";
else
rm -f "$$file.tmp";
echo "Backup failed" >&2;
fi;
find /backups -name "db-*.sql.gz" -mtime +7 -delete;
sleep 86400;
done'

The service waits for the database health check, writes a compressed dump to ./backups every 24 hours and deletes dumps older than 7 days. It writes to a .tmp file first and checks the exit code of mysqldump itself, so a failed dump never looks like a valid backup. $ is written as $$, because Docker Compose treats $ as variable interpolation.

The schedule depends on when the container started. For backups at a fixed time, use cron.

Schedule MySQL backups with cron

Cron on the host gives you a fixed schedule and room for more steps after the dump. This script handles the full job: dump, retention, encrypted off-site upload and a success ping. Save it as /opt/mysql-backup/backup.sh:

#!/usr/bin/env bash
# /opt/mysql-backup/backup.sh - MySQL backup for Docker
set -euo pipefail
CONTAINER="mysql"
DB_NAME="mydb"
BACKUP_DIR="/var/backups/mysql"
DAILY_DAYS=7
WEEKLY_DAYS=28
MONTHLY_DAYS=365
AGE_RECIPIENT="age1yourpublickey"
REMOTE="remote:my-bucket/mysql"
PING_URL="https://hc-ping.com/your-check-uuid"
# Stop if a previous run is still going
exec 9> /var/lock/mysql-backup.lock
flock -n 9 || { echo "Previous backup still running" >&2; exit 1; }
STAMP=$(date +%F_%H%M%S)
DUMP="$BACKUP_DIR/$DB_NAME-$STAMP.sql.gz"
mkdir -p "$BACKUP_DIR/weekly" "$BACKUP_DIR/monthly" "$BACKUP_DIR/outbox"
trap 'rm -f "$DUMP.tmp"' EXIT
# 1. Dump and compress the database
docker exec "$CONTAINER" sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events --databases '"$DB_NAME" \
| gzip > "$DUMP.tmp"
mv "$DUMP.tmp" "$DUMP"
# 2. Keep weekly (Sunday) and monthly (1st) copies
if [ "$(date +%u)" -eq 7 ]; then cp "$DUMP" "$BACKUP_DIR/weekly/"; fi
if [ "$(date +%d)" = "01" ]; then cp "$DUMP" "$BACKUP_DIR/monthly/"; fi
# 3. Encrypt and upload off-site
age -r "$AGE_RECIPIENT" -o "$BACKUP_DIR/outbox/$(basename "$DUMP").age" "$DUMP"
rclone move "$BACKUP_DIR/outbox" "$REMOTE"
# 4. Prune local backups
find "$BACKUP_DIR" -maxdepth 1 -name "*.sql.gz" -mtime +"$DAILY_DAYS" -delete
find "$BACKUP_DIR/weekly" -name "*.sql.gz" -mtime +"$WEEKLY_DAYS" -delete
find "$BACKUP_DIR/monthly" -name "*.sql.gz" -mtime +"$MONTHLY_DAYS" -delete
# 5. Report success
curl -fsS -m 10 --retry 3 "$PING_URL" > /dev/null
echo "Backup complete: $(basename "$DUMP")"

Here’s what protects you from silent failures:

  • set -euo pipefail stops the script at the first failed command, including a failure inside a pipe. Without pipefail, mysqldump | gzip > file reports success when mysqldump fails, and you end up with a tiny, useless archive.
  • The dump goes to a .tmp file, and the trap deletes it on failure.
  • flock stops a slow run from overlapping with the next one.
  • The ping only runs when every step before it succeeded.

Step 3 needs age and rclone. Set them up as described in the PostgreSQL backup guide, because nothing in that setup is specific to the database engine. To start without them, delete step 3 for now.

Make the script executable with sudo chmod 700 /opt/mysql-backup/backup.sh, then add it to root’s crontab with sudo crontab -e:

0 2 * * * /opt/mysql-backup/backup.sh >> /var/log/mysql-backup.log 2>&1

Cron runs with a minimal PATH, so use full paths if docker isn’t in /usr/bin.

MySQL backup retention and off-site storage

The script uses a grandfather-father-son (GFS) policy: daily backups for 7 days, weekly backups (Sunday) for 4 weeks and monthly backups (the 1st) for 12 months.

A local backup doesn’t survive a failed disk, a deleted server or a compromised host, so every backup also goes to an S3-compatible bucket, such as AWS S3. Once that works, lock the bucket down:

  • Set remote retention with a bucket lifecycle rule, for example “delete objects older than 90 days”.
  • Give the server a key that can write but not delete.
  • Turn on object lock if your storage supports it.

A dump contains all of your data, so encrypt every copy that leaves the server. The script uses age. Generate the key pair on your own machine, put only the public key in AGE_RECIPIENT, and keep the private key in a password manager. If you lose it, you lose your off-site backups.

Restore a MySQL dump in Docker

Restore a single database

Terminal window
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -e "CREATE DATABASE mydb_restored"'
gunzip -c mydb.sql.gz | docker exec -i mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot mydb_restored'

The -i flag lets the container read the dump from the pipe. If you created the dump with --databases or --all-databases, leave out the database name: the dump contains its own CREATE DATABASE and USE statements.

Always restore into an empty database. A SQL dump drops and recreates each table, so restoring into a database you still use replaces its data.

Restore from an encrypted off-site backup

Download the .age file with rclone, then decrypt and restore it in one pipe. The cron script dumps with --databases, so you don’t name a database:

Terminal window
rclone copy remote:my-bucket/mysql/mydb-2026-10-12_020001.sql.gz.age .
age -d -i key.txt mydb-2026-10-12_020001.sql.gz.age | gunzip | docker exec -i mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot'

key.txt is the private key from your password manager. Copy it to the server only for the restore, then delete it.

Restore to a new server

On a fresh server, start the container with the same major version, wait for the health check to pass, then restore:

Terminal window
gunzip -c all.sql.gz | docker exec -i mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot'

If you restore an --all-databases dump, the accounts from the dump replace the accounts on the new server. After the restore, root uses the source server’s root password, not the one in the new .env. Update the .env to match.

On MariaDB, the restore also replaces the healthcheck user that the image creates. Its password no longer matches the new container, so healthcheck.sh fails and Docker marks the container unhealthy. To keep the health check working, restore with --databases and recreate your application users, or copy .my-healthcheck.cnf from the old data volume into the new one.

You can restore a dump into a newer major version of the same engine, for example 8.0 into 8.4. Restoring into an older version, or between MySQL and MariaDB, can fail on syntax or collations that the target doesn’t know, so test it before you depend on it.

Test your restores automatically

Backups fail without anyone noticing: a full disk, a changed password, an expired bucket key. The only real proof is a restore. Save this as /opt/mysql-backup/restore-test.sh:

#!/usr/bin/env bash
set -euo pipefail
LATEST=$(ls -t /var/backups/mysql/*.sql.gz | head -n 1)
docker run -d --name mysql-restore-test -e MYSQL_ROOT_PASSWORD=test mysql:8.4 > /dev/null
trap 'docker rm -f mysql-restore-test > /dev/null' EXIT
until docker exec mysql-restore-test mysqladmin ping -h 127.0.0.1 -uroot -ptest --silent > /dev/null 2>&1; do
sleep 1
done
gunzip -c "$LATEST" | docker exec -i -e MYSQL_PWD=test mysql-restore-test mysql -uroot
# Change "mydb" and "users" to a database and table that always have rows
ROWS=$(docker exec -e MYSQL_PWD=test mysql-restore-test mysql -uroot -N -B -e "SELECT COUNT(*) FROM mydb.users")
if [ "$ROWS" -eq 0 ]; then
echo "Restore test failed: users table is empty" >&2
exit 1
fi
echo "Restore test passed: $ROWS rows in users from $(basename "$LATEST")"

The script restores the newest dump into a temporary container, checks that a known table has rows and removes the container. It checks readiness over TCP (-h 127.0.0.1) because, on first start, the image runs a temporary setup server that doesn’t listen on the network. A socket check would pass too early.

Make it executable with sudo chmod 700 /opt/mysql-backup/restore-test.sh, then add it to root’s crontab to run every Sunday, after the nightly backup:

0 4 * * 0 /opt/mysql-backup/restore-test.sh >> /var/log/mysql-restore-test.log 2>&1

Monitor your backup jobs

A cron job that fails at 02:00 won’t tell anyone, so something needs to watch it.

Report each run. The script pings a dead man’s switch service, such as Healthchecks.io or Cronitor. If a ping doesn’t arrive on time, the service alerts you. Use a separate check for the restore test.

Check the age of the newest backup. This catches problems the ping can’t, like a deleted crontab or a stopped sidecar container:

Terminal window
if [ -z "$(find /var/backups/mysql -maxdepth 1 -name '*.sql.gz' -mmin -1500)" ]; then
echo "No MySQL backup in the last 25 hours" >&2
exit 1
fi

To get alerts when a database container stops, read our guide to monitoring Docker containers with real-time alerts.

Large databases and point-in-time recovery

On a large database, mysqldump and especially the restore can take hours, and a daily dump means you can lose up to 24 hours of data. The restore is the slow part, because every row is replayed as an INSERT.

Past roughly 50 GB, switch to physical backups. Percona XtraBackup for MySQL and mariadb-backup for MariaDB copy the data files while the server runs, and restore far faster than SQL.

For point-in-time recovery, keep the binary logs next to a physical backup. You restore the backup, then replay the logs with mysqlbinlog up to the second before a bad DELETE. MySQL 8 writes binary logs by default. MariaDB needs log_bin turned on, and you should set a retention period for the logs, or they fill your disk.

Production backup checklist

Before you call your backups production-ready, check that:

  • Backups run on a schedule and report each run to a monitor.
  • Every backup has an encrypted copy off the server, in a bucket the server cannot delete from.
  • Dumps include routines and events, and use --single-transaction.
  • Backups run as a read-only user, not root.
  • A restore test runs every week.

Automate MySQL and MariaDB backups with Serversinc

The scripts in this guide work, but you maintain them on every server. Serversinc handles the whole job for databases on servers you own:

  • Back up MySQL and MariaDB instances, or selected databases, on a cron schedule.
  • Upload directly from your server to your own S3-compatible bucket, with retention by age, count or both.
  • Restore to the same server or another one. Serversinc checks the engine and version before the restore starts.

Read the databases and backups docs to get started.

FAQ

How do I automate MySQL backups in Docker?

Run mysqldump on a schedule: with cron on the host, or a sidecar container that loops. Then add compression, retention, an encrypted off-site copy and monitoring, as in the cron backup script.

Does mysqldump lock the database?

Not with --single-transaction on InnoDB tables. The dump reads from a consistent snapshot and your application keeps writing. Without that option, mysqldump locks each table while it dumps it, which blocks writes.

How often should I back up MySQL?

Back up at least as often as the amount of data you can afford to lose. A daily dump is the minimum for most applications. If you can’t lose an hour of data, dump hourly or add binary logs.

Can I back up the Docker volume instead of using mysqldump?

Only if the database container is stopped. Copying the data files of a running database doesn’t give you a consistent backup. A volume copy also restores only to the same major version and the same engine.

Can I use the same script for MariaDB?

Yes. Swap mysqldump for mariadb-dump, mysql for mariadb, mysqladmin for mariadb-admin, MYSQL_ROOT_PASSWORD for MARIADB_ROOT_PASSWORD and mysql:8.4 for mariadb:11.4, and drop --set-gtid-purged=OFF.

What’s the difference between mariadb-dump and mysqldump?

For MariaDB, they’re the same tool. mariadb-dump is MariaDB’s name for its fork of mysqldump, and older MariaDB versions ship mysqldump as an alias. The mariadb:11 Docker images only include mariadb-dump. Use MySQL’s mysqldump for MySQL and mariadb-dump for MariaDB, because each knows its own server’s features.

Run your apps on servers you own.

Connect a server you already run, or provision one on your cloud account. £5 a month covers the first three.