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:
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events mydb' | gzip > mydb.sql.gzRestore it into an empty database:
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:
| MySQL | MariaDB | |
|---|---|---|
| Dump tool | mysqldump | mariadb-dump |
| Client | mysql | mariadb |
| Admin tool | mysqladmin | mariadb-admin |
| Root password variable | MYSQL_ROOT_PASSWORD | MARIADB_ROOT_PASSWORD |
| Image | mysql:8.4 | mariadb:11.4 |
--set-gtid-purged=OFF | Supported | Not 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) | |
|---|---|---|
| Output | SQL statements | A copy of the data files |
| Restore to a newer major version | Yes | No |
| Point-in-time recovery | No, only to the time of the dump | Yes, with binary logs |
| Speed on large databases | Slow to dump and restore | Fast |
| Setup | One command | More 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:
MYSQL_ROOT_PASSWORD=a-long-random-passwordMYSQL_DATABASE=mydbservices: 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:
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events mydb' > mydb.sqlThese options matter:
--single-transactiontakes 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.--routinesand--eventsinclude 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 ifSELECT @@gtid_modereturnsON. Without it, restoring onto a server that already has the dump’s GTIDs, such as the source server, fails. Themysql:8.4image 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:
docker exec mysql sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events --flush-privileges --all-databases' | gzip > all.sql.gzRestoring 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:
docker exec -e MYSQL_PWD="$MYSQL_BACKUP_PASSWORD" mysql mysqldump -ubackup --no-tablespaces --single-transaction --routines --events mydb | gzip > mydb.sql.gzOn 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 Dockerset -euo pipefail
CONTAINER="mysql"DB_NAME="mydb"BACKUP_DIR="/var/backups/mysql"DAILY_DAYS=7WEEKLY_DAYS=28MONTHLY_DAYS=365AGE_RECIPIENT="age1yourpublickey"REMOTE="remote:my-bucket/mysql"PING_URL="https://hc-ping.com/your-check-uuid"
# Stop if a previous run is still goingexec 9> /var/lock/mysql-backup.lockflock -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 databasedocker 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) copiesif [ "$(date +%u)" -eq 7 ]; then cp "$DUMP" "$BACKUP_DIR/weekly/"; fiif [ "$(date +%d)" = "01" ]; then cp "$DUMP" "$BACKUP_DIR/monthly/"; fi
# 3. Encrypt and upload off-siteage -r "$AGE_RECIPIENT" -o "$BACKUP_DIR/outbox/$(basename "$DUMP").age" "$DUMP"rclone move "$BACKUP_DIR/outbox" "$REMOTE"
# 4. Prune local backupsfind "$BACKUP_DIR" -maxdepth 1 -name "*.sql.gz" -mtime +"$DAILY_DAYS" -deletefind "$BACKUP_DIR/weekly" -name "*.sql.gz" -mtime +"$WEEKLY_DAYS" -deletefind "$BACKUP_DIR/monthly" -name "*.sql.gz" -mtime +"$MONTHLY_DAYS" -delete
# 5. Report successcurl -fsS -m 10 --retry 3 "$PING_URL" > /dev/nullecho "Backup complete: $(basename "$DUMP")"Here’s what protects you from silent failures:
set -euo pipefailstops the script at the first failed command, including a failure inside a pipe. Withoutpipefail,mysqldump | gzip > filereports success whenmysqldumpfails, and you end up with a tiny, useless archive.- The dump goes to a
.tmpfile, and thetrapdeletes it on failure. flockstops 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>&1Cron 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
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:
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:
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 bashset -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/nulltrap '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 1done
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 rowsROWS=$(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 1fi
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>&1Monitor 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:
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 1fiTo 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.