How to Back Up WordPress to S3 With Restic on Ubuntu
In this article10 sections
A WordPress site has two things to back up: the database, which holds posts, users and settings, and the files, which hold your themes, plugins, uploads and wp-config.php. Restic is a backup tool that stores both as encrypted, deduplicated snapshots in object storage such as Amazon S3 or any S3-compatible service. Each run adds a snapshot, and only new data is uploaded, so daily backups stay small. This guide sets up restic on Ubuntu 24.04, backs up a WordPress site to S3-compatible storage on a schedule, keeps a set number of snapshots, and proves the backups work by restoring one.
The one rule that matters most: a backup you have never restored is a hope, not a backup. Part 6 is the step that proves the setup works, and you should run it before you trust the schedule.
Warning. The restic repository password encrypts every backup. If you lose it, the backups cannot be read by anyone, including you. Store it in a password manager, and keep a second copy somewhere separate from the server and the bucket.
Before you begin
- An Ubuntu 24.04 server with the WordPress site in a known folder, for example
/var/www/example. - An S3 bucket, or an S3-compatible bucket, with an access key that can read and write only that bucket.
- The database credentials for the site.
Create a separate access key for the backup job, not the one your application uses. Give it access to the backup bucket only.
Part 1: Install restic
sudo apt update
sudo apt install -y restic
restic version
Expected: a version line such as restic 0.x.x. The Ubuntu package can lag behind the newest release. Both are fine for this task, and the commands below work on current versions.
Part 2: Store the credentials safely
Restic reads its settings from environment variables. Put them in one file that only root can read, so the password is not visible to other users on the machine:
sudo nano /root/.restic-env
export AWS_ACCESS_KEY_ID="your-backup-access-key"
export AWS_SECRET_ACCESS_KEY="your-backup-secret-key"
export RESTIC_REPOSITORY="s3:s3.us-east-1.amazonaws.com/your-backup-bucket"
export RESTIC_PASSWORD="a-long-random-repository-password"
Replace the endpoint and bucket with yours. For an S3-compatible provider, use that provider’s endpoint in place of s3.us-east-1.amazonaws.com, in the same s3:<endpoint>/<bucket> form. The endpoint must be the path-style address the provider documents for your region.
Lock the file down and check the permissions:
sudo chmod 600 /root/.restic-env
sudo ls -l /root/.restic-env
Expected: -rw------- with root as the owner. If other users can read the file, they can read the backup password.
Part 3: Create the repository
Load the settings and initialise the repository once. Restic creates the folder structure in the bucket and encrypts it with your password:
sudo -i
source /root/.restic-env
restic init
Expected: created restic repository followed by a short ID. Running restic init again on the same repository reports an error, which is expected and harmless. Check the repository from the server at any time:
restic snapshots
Expected on a new repository: an empty list. Leave the root shell with exit when you are finished.
Part 4: Write the backup script
The script dumps the database to a temporary file, adds the dump and the site folder to restic, then removes the dump. Put the database credentials in a separate file, so they do not appear in the script or the process list:
sudo nano /root/.my-backup.cnf
[client]
user=backup_user
password=the-database-password-for-backup_user
Create a database user with read access to the site’s database only. Lock down the file:
sudo chmod 600 /root/.my-backup.cnf
Now the script. Save it as /usr/local/sbin/wp-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
source /root/.restic-env
SITE_DIR="/var/www/example"
DB_NAME="example_db"
DUMP_DIR="$(mktemp -d)"
trap 'rm -rf "$DUMP_DIR"' EXIT
# Consistent InnoDB dump without locking the tables for the whole run
mariadb-dump --defaults-extra-file=/root/.my-backup.cnf \
--single-transaction --routines --triggers --events \
"$DB_NAME" > "$DUMP_DIR/$DB_NAME.sql"
restic backup \
--tag wordpress \
--exclude "$SITE_DIR/wp-content/cache" \
--exclude "$SITE_DIR/wp-content/debug.log" \
"$SITE_DIR" "$DUMP_DIR"
echo "backup finished $(date -Iseconds)"
Set the site folder and database name for your site. Three details matter:
--single-transactiontakes a consistent copy of InnoDB tables without locking the site for the whole dump.- The
trapline removes the temporary dump even if the backup fails, so an unencrypted copy of the database does not stay on disk. - The cache and debug log are excluded, because they are rebuilt and they can be large.
Make it executable, then run it once by hand:
sudo chmod 750 /usr/local/sbin/wp-backup.sh
sudo /usr/local/sbin/wp-backup.sh
Expected: restic prints the files it processed, a summary with the snapshot ID, and then backup finished with the time. A first run uploads everything, so it can take a while on a large site. Later runs upload only what changed.
Part 5: Schedule the backup and keep a fixed number of snapshots
Run the backup every night with root’s cron, and prune old snapshots once a week. Pruning deletes data, so it is kept separate from the backup run and runs when no backup is active:
sudo crontab -e
30 2 * * * /usr/local/sbin/wp-backup.sh >> /var/log/wp-backup.log 2>&1
15 4 * * 0 bash -c 'source /root/.restic-env && restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune' >> /var/log/wp-prune.log 2>&1
The first line backs up every night at 02:30. The second line, on Sundays at 04:15, keeps the last 7 daily, 4 weekly and 6 monthly snapshots and removes the rest. The forget command removes the snapshot references that fall outside the policy, and --prune then deletes the stored data that no remaining snapshot uses. A prune locks the repository while it runs, so the schedule keeps it away from the nightly backup.
Check the log after the first scheduled run:
sudo tail -20 /var/log/wp-backup.log
Part 6: Check the repository and restore a test copy
This is the step that proves the backups work. Do it now, and again every month.
First, check that the repository is consistent. The check reads the index and the data, and reports any damage:
sudo -i
source /root/.restic-env
restic check
Expected: no errors were found. Run a full data check once a month with restic check --read-data, which downloads and verifies every pack, so expect it to take time and bandwidth.
Then restore the latest snapshot into a separate folder. Never restore over the live site for a test:
mkdir -p /root/restore-test
restic restore latest --target /root/restore-test
ls /root/restore-test
Expected: the site folder and a dump file appear under the restore path. Check the database dump by loading it into a scratch database, not the live one:
mariadb -u root -p -e "CREATE DATABASE restore_check;"
mariadb -u root -p restore_check < /root/restore-test/tmp.*/example_db.sql
mariadb -u root -p -e "SELECT COUNT(*) FROM restore_check.wp_posts;"
Expected: the count matches the live post count, which you can read on the server with sudo -u www-data wp --path=/var/www/example post list --post_type=post --post_status=publish --format=count. Replace wp_posts with your table prefix if your site uses a different one. Counts that differ mean the dump is not the snapshot you expected, so stop and check the snapshot ID before relying on the backup. Then remove the scratch database and the restored files:
mariadb -u root -p -e "DROP DATABASE restore_check;"
rm -rf /root/restore-test
A restore you have done once is far more reliable than a backup you have only scheduled.
Part 7: Know how to restore for real
When you need the site back, restore into an empty folder first, check it, then move it into place:
sudo -i
source /root/.restic-env
restic snapshots
restic restore <snapshot-id> --target /root/restore-live --include /var/www/example
Replace <snapshot-id> with the ID from the snapshot list for the moment you want. The restore copies the files and the dump. Import the dump into the database only after you have checked which snapshot you want, because an import overwrites the tables it contains.
Common mistakes
- Losing the repository password. The backups become unreadable. Store it in a password manager and in a second place.
- Using the application’s database user for the dump. If the application leaks its credentials, the attacker gets the backups too. Use a dedicated read-only user.
- Never restoring. Run Part 6 at least once a month.
- Keeping the backup bucket public. Encrypted data is still a target. Keep the bucket private, with the backup key limited to that bucket.
- Pruning on the same time as the backup. The prune locks the repository. Keep the two jobs apart, as in Part 5.
- Storing the database dump without encryption. Restic encrypts everything it stores, but the dump must not sit on disk. The script removes it.
Frequently asked questions
Is restic better than a plugin backup?
For a server, yes. The backup runs from outside WordPress, so it works when the site is broken, and it does not depend on a plugin staying updated.
How much storage will it use?
Each snapshot shares unchanged data with the others, so storage grows with the size of your changes, not with the number of snapshots. Check the repository size in your storage provider’s console after the first month.
Can I back up several sites?
Yes. Give each site its own repository, with its own password and access key. One repository per site keeps a problem in one site from affecting the others.
Do I need to stop the site during a backup?
No. The database dump uses a consistent transaction, and the file copy is good enough for a daily backup. A restore may need a small check after it, for files that changed during the run.
Set the password and access key once, run the script by hand, schedule it nightly with a weekly prune, and then restore a snapshot into a scratch location. The restore is the proof. A backup that restores cleanly, and that you have tested this month, is the one you can rely on.