MySQL/MariaDB on AlmaLinux series, part 3. Part 1 installed and secured the server, and part 2, users, grants and remote access, created the shopdb schema and its accounts. This part makes sure you can get that data back.
A database without a tested restore is a database you are hoping about. MariaDB gives you two complementary backup tools: mariadb-dump (the tool many people still call mysqldump), which writes portable SQL, and mariadb-backup, which copies the InnoDB files while the server runs and restores a large server in minutes instead of hours. Add the binary log and you can also replay changes up to the second before someone ran the wrong DELETE. This guide sets up a dedicated backup account, both kinds of backup, a systemd timer that runs them every night, and full, single-database and point-in-time restores, with verification and troubleshooting for each step.
Applies to: AlmaLinux 10.x with MariaDB 10.11 (package mariadb-server) or 11.8 (mariadb11.8-server) from AppStream. A section near the end covers what changes on MySQL 8.4. The examples use the server db1.example.lan from the earlier parts and keep backups in /srv/backup/mysql.
Prerequisites
- A MariaDB server installed as in part 1, with the local settings file
/etc/my.cnf.d/zz-local.cnf, and root access throughsudo mariadb. - Enough free disk for at least two full copies of the data directory plus compressed dumps. Check with
sudo du -sh /var/lib/mysqlanddf -h /srv; ideally/srv/backupis a separate filesystem or volume, and the backups are copied off the host afterwards. - The physical backup tool, which matches the server package you installed, and
zstdfor compression:
# MariaDB 10.11 (mariadb-server)
sudo dnf install -y mariadb-backup zstd
# MariaDB 11.8 (mariadb11.8-server)
sudo dnf install -y mariadb11.8-backup zstd
mariadb-backup --version
mariadb --version # the two major.minor versions must match
Step 1: Choose logical, physical or both
Logical: mariadb-dump | Physical: mariadb-backup | |
|---|---|---|
| Output | SQL text (CREATE TABLE, INSERT) | Copy of the InnoDB data files and redo log |
| Server impact | Reads every row; one short global lock with --single-transaction | Copies files; short lock at the end for non-InnoDB tables |
| Restore speed | Slow: every row is re-inserted and every index rebuilt | Fast: files are copied back into place |
| Granularity | One table, one database or everything | The whole server (single tables are possible but fiddly) |
| Portability | Loads into other versions and other hosts, even MySQL | Same MariaDB major version only |
| Best for | Small to medium databases, migrations, restoring one database | Large servers, fast full recovery, seeding replicas |
The usual answer is both: a nightly logical dump for flexibility and a weekly or nightly physical backup for fast full recovery, plus the binary log to fill the gap between backups. This guide builds exactly that.
Step 2: Create a backup account and a credentials file
Do not run backups as root with a password on the command line. Create one account with only the privileges both tools need, following the least-privilege approach from part 2:
sudo mariadb <<'SQL'
CREATE USER 'backup'@'localhost' IDENTIFIED BY 'Paste-A-Long-Random-Password';
GRANT SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT,
RELOAD, PROCESS, BINLOG MONITOR
ON *.* TO 'backup'@'localhost';
SHOW GRANTS FOR 'backup'@'localhost';
SQL
SELECT, SHOW VIEW, TRIGGER, LOCK TABLES and EVENT let mariadb-dump read tables, views, triggers and events; RELOAD allows the FLUSH TABLES WITH READ LOCK and FLUSH LOGS both tools use; PROCESS and BINLOG MONITOR are what the MariaDB documentation lists for mariadb-backup (together with RELOAD and LOCK TABLES), and BINLOG MONITOR also lets mariadb-dump record the binary log position. The account can read everything but change nothing.
Store the password in a root-only option file. The [client] group is read by mariadb-dump; the [mariadb-backup] group is read by mariadb-backup:
sudo install -m 600 -o root -g root /dev/null /root/.db-backup.cnf
sudo tee /root/.db-backup.cnf >/dev/null <<'EOF'
[client]
user = backup
password = Paste-A-Long-Random-Password
[mariadb-backup]
user = backup
password = Paste-A-Long-Random-Password
EOF
sudo mariadb --defaults-extra-file=/root/.db-backup.cnf -e "SELECT CURRENT_USER();"
--defaults-extra-file must be the first option on the command line, or the client ignores it. The test should print backup@localhost.
Step 3: Turn on the binary log for point-in-time recovery
A backup only takes you back to the moment it was made. The binary log records every change after that, so you can replay it on top of a restored backup. MariaDB on AlmaLinux ships with it off. Put the settings in their own file so they are easy to find:
sudo tee /etc/my.cnf.d/zz-binlog.cnf >/dev/null <<'EOF'
[mariadb]
server_id = 1
log_bin = mariadb-bin
binlog_format = ROW
binlog_expire_logs_seconds = 1209600 # 14 days, keep longer than your oldest full backup chain
EOF
sudo systemctl restart mariadb
sudo mariadb -e "SHOW VARIABLES LIKE 'log_bin'; SHOW BINARY LOGS;"
A relative log_bin name puts the files (mariadb-bin.000001 and the index mariadb-bin.index) in the data directory /var/lib/mysql, which already has the right SELinux label. ROW format logs the actual row changes, which is the safest format to replay. Size the expiry to your backup schedule: you need every binary log written since the oldest backup you might restore. Part 4 of this series reuses the binary log for replication.
Step 4: Logical backups with mariadb-dump
Create the backup directories, readable by root only, then take a full logical backup of every database:
sudo install -d -m 700 /srv/backup/mysql /srv/backup/mysql/dump /srv/backup/mysql/phys
STAMP=$(date +%F_%H%M)
sudo mariadb-dump --defaults-extra-file=/root/.db-backup.cnf \
--all-databases --single-transaction --routines --events \
--master-data=2 --gtid --flush-logs \
| zstd -q -T0 | sudo tee /srv/backup/mysql/dump/all-$STAMP.sql.zst >/dev/null
What the options do:
--single-transactiondumps every InnoDB table from one consistent snapshot without blocking writes. It only takes a brief global read lock at the start to record the binary log position. Tables in other engines (MyISAM, Aria) are not protected by the snapshot; convert them to InnoDB if their consistency matters.--routinesand--eventsadd stored procedures, functions and scheduled events, which are not dumped by default. Triggers are included by default.--master-data=2writes the binary log file and position as a commented-outCHANGE MASTER TOline near the top of the dump, and--gtidadds the GTID position too. That is your starting point for point-in-time recovery (Step 9) and for seeding a replica in part 4.--flush-logsstarts a new binary log file at the moment of the dump, so the logs you would replay begin cleanly at the start of a file.
Dumping a single database (without --databases) produces a file you can load into a database of any name, which is ideal for restore tests and for copying data to a test server:
sudo mariadb-dump --defaults-extra-file=/root/.db-backup.cnf \
--single-transaction --routines --events shopdb \
| zstd -q | sudo tee /srv/backup/mysql/dump/shopdb-$(date +%F).sql.zst >/dev/null
Accounts and grants live in the mysql system database. --all-databases includes it, but a raw dump of the grant tables is version-specific. MariaDB can also write accounts as portable CREATE USER and GRANT statements, which is what you want when rebuilding on a newer server:
sudo mariadb-dump --defaults-extra-file=/root/.db-backup.cnf --system=users \
| sudo tee /srv/backup/mysql/dump/users-$(date +%F).sql >/dev/null
sudo grep -c '^CREATE USER' /srv/backup/mysql/dump/users-$(date +%F).sql
Check the dump right away. A dump that stopped half way still looks like a valid file, so look for the trailer mariadb-dump writes at the very end, and test the compression:
f=$(ls -t /srv/backup/mysql/dump/all-*.sql.zst | head -1)
sudo zstd -t "$f"
sudo zstd -dc "$f" | tail -n 1 # expect: -- Dump completed on 2026-...
sudo zstd -dc "$f" | grep -m2 -E 'CHANGE MASTER|gtid_slave_pos'
ls -lh "$f"
Step 5: Physical backups with mariadb-backup
mariadb-backup copies the data files while the server keeps running, records the redo log written during the copy, and takes only a short lock at the end to copy non-InnoDB tables and record the binary log position. The target directory must be empty or not exist yet:
FULL=/srv/backup/mysql/phys/full-$(date +%F)
sudo mariadb-backup --defaults-extra-file=/root/.db-backup.cnf \
--backup --target-dir="$FULL"
sudo ls "$FULL"
The run ends with completed OK!. The directory looks like a data directory plus a few metadata files: the checkpoint file with the backup’s log sequence numbers (xtrabackup_checkpoints on 10.11, mariadb_backup_checkpoints on 11.x) and the binary log position (xtrabackup_binlog_info or mariadb_backup_binlog_info).
The copied files are not consistent yet, because pages changed during the copy. Preparing the backup applies the saved redo log so the files are crash-consistent and ready to copy back. It needs no database connection:
sudo mariadb-backup --prepare --target-dir="$FULL" # ends with "completed OK!"
Preparing right after the backup confirms that the backup is usable and saves time during an outage. For large servers, incremental backups copy only the pages changed since a previous backup, identified by their log sequence number:
# incremental on top of the full backup (use the previous incremental as the base for the next one)
INC1=/srv/backup/mysql/phys/inc1-$(date +%F_%H%M)
sudo mariadb-backup --defaults-extra-file=/root/.db-backup.cnf \
--backup --target-dir="$INC1" --incremental-basedir="$FULL"
# at restore time: prepare the full backup, then apply each incremental in order
sudo mariadb-backup --prepare --target-dir="$FULL"
sudo mariadb-backup --prepare --target-dir="$FULL" --incremental-dir="$INC1"
Applying an incremental changes the full backup directory for good, so work on a copy (cp -a) if you might need to stop at an earlier point. A physical backup is only usable with the same major MariaDB version: always back up and prepare with the mariadb-backup that matches the running server, and take a new full backup after every major upgrade.
Step 6: Automate the backups with a systemd timer
A small script runs a nightly logical dump, a physical full backup on Sundays, and deletes old files. set -o pipefail matters here: without it, a failed mariadb-dump piped into zstd would still count as success.
sudo tee /usr/local/sbin/db-backup.sh >/dev/null <<'EOF'
#!/bin/bash
# Nightly MariaDB backup: logical dump every day, physical full backup on Sunday
set -euo pipefail
umask 077
CNF=/root/.db-backup.cnf
BASE=/srv/backup/mysql
STAMP=$(date +%F_%H%M)
DUMP_KEEP_DAYS=14
PHYS_KEEP_DAYS=35
mariadb-dump --defaults-extra-file=$CNF --all-databases --single-transaction \
--routines --events --master-data=2 --gtid --flush-logs \
| zstd -q -T0 -o "$BASE/dump/all-$STAMP.sql.zst"
mariadb-dump --defaults-extra-file=$CNF --system=users > "$BASE/dump/users-$STAMP.sql"
zstd -dc "$BASE/dump/all-$STAMP.sql.zst" | tail -n 1 | grep -q '^-- Dump completed'
if [ "$(date +%u)" = 7 ]; then
mariadb-backup --defaults-extra-file=$CNF --backup --target-dir="$BASE/phys/full-$STAMP"
mariadb-backup --prepare --target-dir="$BASE/phys/full-$STAMP"
fi
find "$BASE/dump" -type f -mtime +$DUMP_KEEP_DAYS -delete
find "$BASE/phys" -mindepth 1 -maxdepth 1 -type d -mtime +$PHYS_KEEP_DAYS -exec rm -rf {} +
echo "backup $STAMP OK"
EOF
sudo chmod 700 /usr/local/sbin/db-backup.sh
Now the service and timer. Persistent=true runs a missed backup at the next boot, and the I/O and CPU settings keep the job from starving the database:
sudo tee /etc/systemd/system/db-backup.service >/dev/null <<'EOF'
[Unit]
Description=MariaDB backup (mariadb-dump and mariadb-backup)
After=mariadb.service
Requires=mariadb.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/db-backup.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF
sudo tee /etc/systemd/system/db-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Nightly MariaDB backup
[Timer]
OnCalendar=*-*-* 01:30:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now db-backup.timer
sudo systemctl start db-backup.service # run once now to test
systemctl list-timers db-backup.timer
journalctl -u db-backup.service -n 20 --no-pager
The journal should end with backup ... OK. If you use mariadb11.8-server, the service is still called mariadb.service. Copy /srv/backup/mysql to another machine afterwards (rsync over SSH, or a backup system such as Bacula): a backup on the same disk as the database does not survive the loss of that disk.
Step 7: Restore from a logical dump
Before restoring over anything, stop the applications that write to the database. Then pick the case that fits.
Restore a whole server
f=/srv/backup/mysql/dump/all-2026-11-11_0130.sql.zst
sudo zstd -dc "$f" | sudo mariadb
A full dump contains DROP TABLE/CREATE TABLE for every table and replaces the mysql system database, so accounts and passwords return to their state at backup time. On a newly installed server of a newer version, load only the application databases and the portable users-*.sql file instead of the raw mysql schema, then run sudo mariadb-upgrade.
Restore one database from a full dump
sudo mariadb -e "CREATE DATABASE IF NOT EXISTS shopdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo zstd -dc "$f" | sudo mariadb --one-database shopdb
--one-database makes the client skip every statement that is not for shopdb, so the rest of the server is left alone.
Restore test into a scratch database
This is the restore you should run regularly, because it proves a backup works without touching production. Load the single-database dump into a new database and compare it with the live one:
sudo mariadb -e "CREATE DATABASE shopdb_restoretest;"
sudo zstd -dc /srv/backup/mysql/dump/shopdb-2026-11-11.sql.zst | sudo mariadb shopdb_restoretest
sudo mariadb -e "SELECT COUNT(*) FROM shopdb.orders; SELECT COUNT(*) FROM shopdb_restoretest.orders;"
sudo mariadb -e "CHECKSUM TABLE shopdb.customers, shopdb_restoretest.customers;"
sudo mariadb -e "DROP DATABASE shopdb_restoretest;"
Row counts can differ slightly if the application wrote rows since the dump; checksums of a table that has not changed should be identical.
Step 8: Restore from a physical backup
A physical restore replaces the whole data directory, so the server must be stopped and the directory empty. Move the old one aside instead of deleting it: it still contains the binary logs you need for point-in-time recovery.
FULL=/srv/backup/mysql/phys/full-2026-11-08_0130 # already prepared (Step 5)
sudo systemctl stop mariadb
sudo mv /var/lib/mysql /var/lib/mysql.old-$(date +%F)
sudo install -d -m 755 -o mysql -g mysql /var/lib/mysql
sudo mariadb-backup --copy-back --target-dir="$FULL"
sudo chown -R mysql:mysql /var/lib/mysql
sudo restorecon -Rv /var/lib/mysql
sudo systemctl start mariadb
sudo mariadb -e "SELECT NOW(); SHOW DATABASES;"
--copy-back reads the data directory location from the server configuration and keeps the backup intact; --move-back is faster but consumes the backup. The chown is required because the files are written as root, and restorecon resets the mysqld_db_t SELinux label in case files were created with the wrong context. Without them, MariaDB fails to start.
Step 9: Point-in-time recovery with the binary log
Suppose someone ran DELETE FROM shopdb.orders; without a WHERE clause at about 14:02. Restore the last backup (Step 7 or 8), then replay the binary log from the backup’s position up to just before the mistake. First, find the starting position:
# physical backup
sudo cat /srv/backup/mysql/phys/full-2026-11-08_0130/xtrabackup_binlog_info
# mariadb-bin.000012 385 0-1-48211 (file, position, GTID)
# logical dump
sudo zstd -dc /srv/backup/mysql/dump/all-2026-11-11_0130.sql.zst | grep -m1 'CHANGE MASTER'
# -- CHANGE MASTER TO MASTER_LOG_FILE='mariadb-bin.000015', MASTER_LOG_POS=385;
Next, find exactly where the bad statement is. With ROW format, decode the row events so you can read them, and search around the time of the incident:
cd /var/lib/mysql.old-2026-11-11 # or /var/lib/mysql if you restored a dump
sudo mariadb-binlog --base64-output=decode-rows -vv \
--start-datetime="2026-11-11 13:55:00" --stop-datetime="2026-11-11 14:05:00" \
mariadb-bin.000015 | grep -n -B4 -A6 'DELETE FROM `shopdb`.`orders`' | less
Note the # at NNN position of the event before the transaction that contains the bad delete (the BEGIN of that transaction). Then replay from the backup position up to that point:
sudo mariadb-binlog --start-position=385 --stop-position=918274 \
mariadb-bin.000015 | sudo mariadb
# when the logs span several files, --start-position applies to the first file only
# and --stop-position to the last one:
sudo mariadb-binlog --start-position=385 --stop-datetime="2026-11-11 14:01:59" \
mariadb-bin.000015 mariadb-bin.000016 | sudo mariadb
Replay all files in a single mariadb-binlog command rather than one command per file, so temporary tables and transactions that cross a file boundary are handled correctly. Check the result (SELECT COUNT(*) FROM shopdb.orders;), then take a fresh full backup before letting the applications back in, because the replayed server now has a new history.
Step 10: What changes on MySQL 8.4
- Logical dumps: use
mysqldumpwith--source-data=2;--master-datais the deprecated old name. A typical command ismysqldump --defaults-extra-file=/root/.db-backup.cnf --all-databases --single-transaction --routines --events --source-data=2 --flush-logs. There is no--system=users; save accounts withSHOW CREATE USERandSHOW GRANTSper account, or use MySQL Shell’sutil.dumpInstance(), which includes users. - Backup account: grant
REPLICATION CLIENTinstead of MariaDB’sBINLOG MONITOR; the rest of the list is the same.PROCESSis needed for the tablespace information mysqldump reads (or add--no-tablespaces). - Binary log: enabled by default in MySQL 8.4 (files
binlog.000001in the data directory, 30-day expiry).SHOW MASTER STATUSwas removed in 8.4; useSHOW BINARY LOG STATUS. Replay withmysqlbinlogusing the same options as Step 9. - Physical backups:
mariadb-backupdoes not support MySQL 8.x data files. Use Percona XtraBackup 8.4 (from Percona’s own repository, not AppStream), MySQL Enterprise Backup, or the clone plugin to copy a server.
Verification checklist
SHOW GRANTS FOR 'backup'@'localhost';shows only read and backup privileges, and/root/.db-backup.cnfis mode 600 owned by root.SHOW BINARY LOGS;lists files, and the oldest is older than your oldest backup you plan to restore.systemctl list-timers db-backup.timershows the next run, andjournalctl -u db-backup.serviceshowsbackup ... OKfor the last one.- Every recent dump passes
zstd -tand ends with-- Dump completed; the latest physical backup ended withcompleted OK!for both backup and prepare. - A restore test into a scratch database (Step 7) has been run recently, and a copy of the backups exists on another machine.
Troubleshooting tips
mariadb-dump: Error: Binlogging on server not active:--master-dataneeds the binary log. Enable it as in Step 3, or drop--master-dataand--gtidif you do not want point-in-time recovery.Access denied; you need (at least one of) the RELOAD privilege(s)(orPROCESS,BINLOG MONITOR): the backup account is missing a grant. CompareSHOW GRANTS FOR 'backup'@'localhost'with Step 2. IfCURRENT_USER()is notbackup@localhost, the credentials file was not read; put--defaults-extra-filefirst.ERROR at line 1: Unknown command '\-'when loading a dump: recent MariaDB versions ofmariadb-dumpstart the file with a sandbox-mode line that the MySQL client and older clients do not understand. Load it with the MariaDBmariadbclient, or skip the first line:zstd -dc file.sql.zst | tail -n +2 | mysql ....Table definition has changed, please retry transactionduring a dump: a DDL statement (ALTER,TRUNCATE,RENAME) ran on a table while--single-transactionwas reading it. Schedule the backup away from deployments and migrations;--order-by-sizehelps when small tables are truncated often.mariadb-backup --copy-backsays the original data directory is not empty: move or empty/var/lib/mysqlfirst, as in Step 8. Check for hidden files and leftoverlost+foundon a dedicated filesystem.- MariaDB will not start after a physical restore: check
journalctl -u mariadband/var/log/mariadb/mariadb.log. Wrong ownership (Permission denied) is fixed withchown -R mysql:mysql /var/lib/mysql; SELinux denials (ausearch -m avc -ts recent) withrestorecon -Rv /var/lib/mysql. A backup that was never prepared, or prepared with a differentmariadb-backupversion, can also fail here: prepare it with the matching version. mariadb-backupfails reading the redo log or reports an unsupported format: the tool and the server are different versions, typically afterdnf updatemoved one of them or after switching betweenmariadbandmariadb11.8. Install the matching backup package and take a new full backup./var/lib/mysqlor/srvfills up: binary logs are the usual cause. CheckSHOW BINARY LOGS;, shortenbinlog_expire_logs_seconds, or purge manually after a successful backup withPURGE BINARY LOGS BEFORE NOW() - INTERVAL 7 DAY;. Never delete binary log files withrm, because the index file still lists them.- The timer never ran:
systemctl list-timers --all | grep db-backupshows whether it is enabled;systemctl status db-backup.serviceand the journal show why a run failed, including the exact command that returned non-zero.
Quick reference
| Task | Command |
|---|---|
| Full logical dump | mariadb-dump --all-databases --single-transaction --routines --events --master-data=2 --gtid |
| One database | mariadb-dump --single-transaction --routines --events shopdb |
| Accounts as SQL | mariadb-dump --system=users |
| Physical full backup | mariadb-backup --backup --target-dir=DIR |
| Incremental backup | mariadb-backup --backup --target-dir=INC --incremental-basedir=DIR |
| Prepare | mariadb-backup --prepare --target-dir=DIR [--incremental-dir=INC] |
| Physical restore | mariadb-backup --copy-back --target-dir=DIR, then chown and restorecon |
| Restore one database | zstd -dc all.sql.zst | mariadb --one-database shopdb |
| Read the binary log | mariadb-binlog --base64-output=decode-rows -vv FILE |
| Replay to a point | mariadb-binlog --start-position=N --stop-datetime="..." FILES | mariadb |
Next in the series
Part 4 sets up primary/replica replication with GTID, using the binary log enabled here and a backup from this guide to seed the replica. Earlier: part 1, install and secure MariaDB/MySQL and part 2, users, grants and remote access. If you also run PostgreSQL, the equivalent guide is PostgreSQL backup and restore with pg_dump and pg_basebackup.
Official references: MariaDB: mariadb-dump, MariaDB: mariadb-backup overview, full backup and restore, incremental backup and restore, MySQL 8.4: mysqldump and MySQL 8.4: point-in-time recovery.
