WooCommerce database and media backups with restic: IDrive e2 vs AWS S3
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Back up a WooCommerce store with restic: dump the database with mysqldump through restic's --stdin-from-command, so a failed dump never becomes a snapshot, back up wp-content/uploads, keep 14 daily, 8 weekly and 12 monthly snapshots, read back a different 1/30 of the repository every night, and run a monthly restore drill that loads the dump into a scratch database and compares it with the live store. restic encrypts on your server, so the bucket only ever holds ciphertext. For the bucket, egress decides the bill: IDrive e2 is $6 per TB a month with free downloads up to 3 times the stored volume and no API fees, while AWS S3 Standard is $0.023 per GB plus $0.09 per GB downloaded after the first 100 GB. Below roughly 200 GB, S3 is cheaper; at 500 GB with a monthly full verification read, e2 is $6 against about $47.50.
Most WooCommerce stores have a backup. Far fewer have a restore. The host's nightly snapshot lives on the same provider as the store, the backup plugin writes a ZIP next to the site it's backing up, and nobody has opened either since the day they were set up. This guide builds an off-site backup that is encrypted before it leaves the server, tells you when it fails, checks its own data, and proves once a month that it can bring the orders back. Then it compares two places to keep it: IDrive e2 and AWS S3.
Prices come from IDrive e2's pricing page and AWS S3's US East (N. Virginia) list prices, checked in October 2026; restic behavior from its documentation and our own runs. Both scripts were tested on our machine with restic 0.19.1, MariaDB 11.4.5 and an S3 API served locally by rclone serve s3, against a WooCommerce-style database of 20,000 orders and 68 MB of product images. They were not run against IDrive e2 or AWS themselves, and the test ran under Git Bash on Windows rather than on a Linux server. The timings below are from that machine and only show proportions.
1. The short answer
| AWS S3 Standard | ||
|---|---|---|
| Storage | $6 per TB a month, billed per full TB; $59.50 per TB a year ($29.75 the first year) | $0.023 per GB a month (about $23.55 per TB) |
| Downloads (verification reads, restores) | Free up to 3× your stored volume a month, then $0.01 per GB | First 100 GB a month free across your AWS account, then $0.09 per GB |
| API requests | No charge | $0.005 per 1,000 uploads; negligible with restic's large pack files |
| Minimum storage duration | None | None on Standard (30 days on Standard-IA, 90 on Glacier Instant Retrieval) |
| Object Lock and versioning | Chosen when the bucket is created | Yes |
| Cheaper when | The repository is over about 200 to 260 GB, or you read it back regularly | The repository is small and you rarely download from it |
The backup itself is the same on both: restic speaks the S3 API, so moving between them is a change to one line in the config file.
2. What gets backed up, and where it goes
A WooCommerce store needs two things to come back: the database (orders, customers, products, settings) and wp-content/uploads (product images and, under woocommerce_uploads, the files you sell). Themes and plugins can be reinstalled; if you've customized them, keep them in Git. uploads/wc-logs holds WooCommerce's log files and is left out.
Why restic and not a plugin: it runs outside WordPress, so a hacked or broken site doesn't take the backup with it. It encrypts with a key that never leaves your server. Its deduplication means the second night only uploads what changed: in our test, about 1.1 MB after 50 new orders and 5 new images, against 68.6 MB the first night. And it has the two commands a restore drill needs, check and dump.
3. The backup script
One bash script for cron. It reads its settings from an env file, exits non-zero if any step fails, and pings a heartbeat URL only at the very end, so a missing ping means something went wrong.
#!/usr/bin/env bash
# Nightly WooCommerce backup with restic: the MySQL/MariaDB database and wp-content/uploads, encrypted on this
# server before upload to any S3-compatible bucket (IDrive e2, AWS S3, ...), then retention and an integrity check.
# Usage: store-backup.sh /etc/store-backup.env
# Exits non-zero on any failure, and only pings the heartbeat URL when every step succeeded.
set -euo pipefail
source "$1"
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
host=${BACKUP_HOST:-$(hostname)}
log() { echo "$(date -u +%FT%TZ) $*"; }
# 1. Database. With --stdin-from-command, restic saves no snapshot if the dump exits non-zero. The usual
# "mysqldump | restic backup --stdin" stores whatever arrived before the dump died, as a normal snapshot.
log "db: $DB_NAME"
restic backup --host "$host" --tag db --stdin-filename "$DB_NAME.sql" --stdin-from-command -- \
mysqldump --defaults-extra-file="$DB_CNF" --single-transaction --quick --routines --triggers \
--no-tablespaces "$DB_NAME"
# 2. Media and downloadable products. WooCommerce logs live in uploads/wc-logs and don't need a backup.
log "files: $UPLOADS_DIR"
restic backup --host "$host" --tag files --exclude-caches --exclude "$UPLOADS_DIR/wc-logs" "$UPLOADS_DIR"
# 3. Retention per tag: 14 daily, 8 weekly, 12 monthly snapshots. Pruning rewrites data, so only on Sundays.
prune=()
[ "$(date +%u)" = 7 ] && prune=(--prune)
restic forget --host "$host" --group-by host,tags --keep-daily 14 --keep-weekly 8 --keep-monthly 12 "${prune[@]}"
# 4. Integrity: metadata every night, plus 1/CHECK_GROUPS of the stored data, a different part each day,
# so the whole repository is read back every CHECK_GROUPS days. Reads are billed as egress on AWS S3.
groups=${CHECK_GROUPS:-30}
restic check --read-data-subset="$(( $(date +%s) / 86400 % groups + 1 ))/$groups"
# 5. No ping = alert (UptimeRobot heartbeat, StatusCake push test, Healthchecks.io).
if [ -n "${HEARTBEAT_URL:-}" ]; then curl -fsS -m 10 --retry 3 "$HEARTBEAT_URL" >/dev/null; fi
log "backup ok"
The settings file. Keep it readable only by root, and use an access key limited to this one bucket:
# /etc/store-backup.env (chmod 600, owned by root)
# IDrive e2: the endpoint is shown per region under "Enabled regions" in the e2 dashboard, unique to your account.
RESTIC_REPOSITORY=s3:https://YOUR-REGION-ENDPOINT/store-backups/shop
# AWS S3 instead:
# RESTIC_REPOSITORY=s3:https://s3.us-east-1.amazonaws.com/store-backups/shop
RESTIC_PASSWORD_FILE=/etc/restic.pass # also kept in your password manager: without it the backups are unreadable
AWS_ACCESS_KEY_ID=... # an access key limited to this one bucket
AWS_SECRET_ACCESS_KEY=...
DB_CNF=/etc/store-backup.my.cnf # [client] user=backup password=... (SELECT, SHOW VIEW, TRIGGER, LOCK TABLES, EVENT)
DB_NAME=wordpress
UPLOADS_DIR=/var/www/html/wp-content/uploads
HEARTBEAT_URL=https://heartbeat.uptimerobot.com/YOUR-TOKEN
DRILL_HEARTBEAT_URL=https://push.statuscake.com/?PK=YOUR-KEY&TestID=YOUR-ID&time=0
# CHECK_GROUPS=30 whole repository read back once a month
# MAX_LAG_HOURS=26 drill fails if the newest restored order is older than live by more than this
The database user needs SELECT, SHOW VIEW, TRIGGER, LOCK TABLES and EVENT. --single-transaction takes a consistent snapshot of InnoDB tables without locking the store, so checkout keeps working during the dump. Create the repository once with restic init and the same environment.
Postgres: replace the dump command with pg_dump --format=custom --no-owner "$DB_NAME" and use pg_restore --no-owner -d restore_drill in the drill. We only tested the MySQL path.
4. The restore drill
A backup you haven't restored is a hope. The drill does what you would do on the worst day, on a schedule: it pulls the latest dump out of the repository, loads it into a scratch database on the same server, and compares it with the live store. Then it reads 50 random images back out of the repository and compares them byte for byte with the live files.
#!/usr/bin/env bash
# Restore drill: load the latest database snapshot into a scratch database and compare it with the live store,
# restore a random sample of media files and compare them byte for byte. Prints how long the restore took.
# Usage: restore-drill.sh /etc/store-backup.env (same env file as store-backup.sh)
set -euo pipefail
source "$1"
export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
host=${BACKUP_HOST:-$(hostname)}
drill_db=${DRILL_DB:-restore_drill}
orders=${ORDERS_TABLE:-wp_wc_orders} # HPOS. Stores still on posts storage: see the guide.
sql() { mysql --defaults-extra-file="$DB_CNF" -N -B "$@"; }
work=$(mktemp -d)
trap 'rm -rf "$work"; sql -e "DROP DATABASE IF EXISTS \`$drill_db\`"' EXIT
fail=0
t0=$SECONDS
# 1. Database: stream the dump out of the repository into a scratch database on the same server.
sql -e "DROP DATABASE IF EXISTS \`$drill_db\`; CREATE DATABASE \`$drill_db\`"
restic dump --host "$host" --tag db latest "/$DB_NAME.sql" | sql "$drill_db"
db_secs=$(( SECONDS - t0 ))
stats() { sql "$1" -e "SELECT COUNT(*), COALESCE(UNIX_TIMESTAMP(MAX(date_created_gmt)), 0) FROM \`$orders\`"; }
read -r live_n live_last < <(stats "$DB_NAME")
read -r rest_n rest_last < <(stats "$drill_db")
lag_h=$(( (live_last - rest_last) / 3600 ))
echo "orders: live $live_n, restored $rest_n, newest restored order ${lag_h}h behind live"
# The newest order is compared with live, not with the clock: a quiet weekend isn't a stale backup.
if [ "$rest_n" -eq 0 ] || [ "$lag_h" -gt "${MAX_LAG_HOURS:-26}" ]; then
echo "FAIL: restored database is empty or older than ${MAX_LAG_HOURS:-26}h"; fail=1
fi
# 2. Media: read a random sample of files back out of the repository and compare them with the live copies.
# A live file whose mtime differs from the snapshot's was changed after the backup: skipped, not failed.
restic ls --host "$host" --tag files latest --json |
sed -n 's/.*"type":"file","path":"\([^"]*\)".*"mtime":"\([^"]*\)".*/\2 \1/p' |
shuf -n "${DRILL_SAMPLE:-50}" > "$work/sample"
t1=$SECONDS
same=0 changed=0 bad=0
while read -r mtime f; do
if [ ! -f "$f" ] || [ "$(date -d "$mtime" +%s)" != "$(stat -c %Y "$f")" ]; then changed=$((changed + 1))
elif restic dump --host "$host" --tag files latest "$f" </dev/null | cmp -s - "$f"; then same=$((same + 1))
else bad=$((bad + 1)); echo "MISMATCH: $f"; fi
done < "$work/sample"
echo "media sample: $same identical, $changed changed since backup, $bad mismatched ($(( SECONDS - t1 ))s)"
[ "$bad" -eq 0 ] && [ "$same" -gt 0 ] || fail=1
echo "database restore took ${db_secs}s"
[ "$fail" -eq 0 ] || exit 1
if [ -n "${DRILL_HEARTBEAT_URL:-}" ]; then curl -fsS -m 10 --retry 3 "$DRILL_HEARTBEAT_URL" >/dev/null; fi
echo "drill ok"
The freshness test compares the newest restored order with the newest live order, not with the clock. If the store took no orders over the weekend, that isn't a failure. If live has orders from today and the backup's newest is from 30 hours ago, the backup quietly stopped, and the drill says so. Stores that still use the old posts storage for orders instead of HPOS change the stats query to SELECT COUNT(*), UNIX_TIMESTAMP(MAX(post_date_gmt)) FROM wp_posts WHERE post_type = 'shop_order'.
Schedule both scripts with cron. flock keeps the drill from starting while a backup is still running:
# /etc/cron.d/store-backup (server time; flock keeps the backup and the drill from overlapping)
15 3 * * * root flock -n /run/store-backup.lock /usr/local/bin/store-backup.sh /etc/store-backup.env >>/var/log/store-backup.log 2>&1
45 5 1 * * root flock -w 3600 /run/store-backup.lock /usr/local/bin/restore-drill.sh /etc/store-backup.env >>/var/log/store-backup.log 2>&1
5. Test results
The test harness creates a WooCommerce-style database with 20,000 orders over two years, 400 product images (68 MB, random bytes so nothing compresses), a wc-logs folder and a downloadable product, and runs both scripts against MariaDB and a local S3 API:
| Scenario | Expected | Result |
|---|---|---|
| First backup | Exit 0, one db and one files snapshot, heartbeat pinged | Pass, 7 s |
| What's in the backup | wc-logs left out, woocommerce_uploads included | Pass |
| Bucket contents searched for a customer email and for raw image bytes | Not found: only ciphertext is stored | Pass |
| Second night: 50 new orders, 5 new images | Only the changes uploaded | Pass: 1.1 MB uploaded, 4 s |
| Database dump fails (wrong password) | Script exits non-zero, no new snapshot, no heartbeat | Pass |
Same failure with mysqldump | restic backup --stdin | Shows the trap | A 0-byte snapshot was saved and listed like any other |
| Restore drill | Orders match live, 50 images identical, scratch database dropped, heartbeat pinged | Pass: 20,050 orders restored, database load 1 s |
| Live store has orders 30 hours newer than the backup | Drill fails, no heartbeat | Pass |
| One byte flipped in a pack file in the bucket | Plain restic check misses it; exactly one of the 30 daily subsets catches it | Pass |
Two results deserve attention. The pipe version is what most tutorials show, and on failure it leaves a snapshot of an empty or truncated dump that looks normal in restic snapshots; you find out on the day you restore. And a plain restic check only verifies the repository's structure: it passed with a corrupted data file. Only reading the data back (--read-data or --read-data-subset) finds that.
The media part of the drill took 43 seconds for 50 files, because each restic dump loads the repository index again. That's fine once a month. On a repository of several hundred GB, expect the index load, and the database restore, to take much longer than in our test. Time your own drill: that number is how long the store would be down.
6. What it costs
The storage price is the number on the pricing page. The number that decides the bill is egress, because a backup you verify is a backup you download. With the default CHECK_GROUPS=30, the script reads the whole repository back once a month. On IDrive e2 that fits in the free allowance of 3× your stored volume; on AWS S3 every GB after the first 100 is $0.09.
| Repository size | AWS S3: storage + monthly verification read | IDrive e2, monthly billing | IDrive e2, yearly billing | One full restore, extra |
|---|---|---|---|---|
| 100 GB | $2.30 + $0 (within the free 100 GB) = $2.30 | $6.00 (1 TB minimum) | $4.96 | S3: up to $9; e2: $0 |
| 500 GB | $11.50 + $36.00 = $47.50 | $6.00 | $4.96 | S3: $45; e2: $0 |
| 2 TB | $47.10 + $175.32 = $222.42 | $12.00 | $9.92 | S3: about $184; e2: $0 |
List prices, October 2026, AWS US East (N. Virginia), S3 Standard. The S3 column assumes the 100 GB free egress isn't used by anything else in the account; "one full restore" assumes it is, since a disaster rarely comes on a quiet month. The IDrive e2 yearly price is $59.50 per TB, and the first year is half that. Upload requests on S3 add cents: restic writes pack files of about 16 MiB, so 500 GB is roughly 32,000 uploads, about $0.16.
You could verify less often on S3 to save money, and many people do. That's the trade-off: the corrupted pack in our test was invisible to a plain check and would only show up on the day of the restore. Pricing that makes reading your own backup expensive pushes you toward not reading it. The S3 Glacier classes are cheaper to store, but restic's prune rewrites and deletes pack files, which runs into their 90-day minimums and retrieval fees. They're a poor fit for a repository that changes every night.
7. Where the job runs: Vultr, DigitalOcean or the store server
If the store runs on its own VPS, run the scripts there: they need the database, the uploads folder and about as much free disk as nothing, since the dump streams straight into restic. If the store is on managed hosting that doesn't let you install restic or run cron as root, use a small separate server: a $5
Vultr or $6
DigitalOcean VPS that reaches the database over a private network or an SSH tunnel and copies the uploads folder with rsync before each run. That VPS is also a good place for the drill, because restoring onto a machine that isn't the production server is closer to the real disaster. We didn't test that setup; the scripts only need DB_CNF to point at the remote database and UPLOADS_DIR at the local copy.
8. Setting up IDrive e2
- Create a bucket in the region closest to the server. Decide now whether to enable Object Lock and versioning: IDrive sets both when the bucket is created. With Object Lock, someone who gets the server's access key still can't delete existing backups before the retention period ends.
- Copy the region endpoint from Enabled regions in the dashboard. IDrive gives each account its own endpoint URL, so you won't find a shared one in tutorials.
- Create an access key limited to that bucket, and put the endpoint, bucket and key in the env file.
- Run
restic init, then the backup script by hand, then the drill. Only after the first successful drill, add the cron entries and the heartbeat monitors.
With Object Lock on, restic prune can't delete locked pack files until their retention expires, so set the retention period shorter than your oldest kept snapshot (here, 12 months), or the bucket keeps growing.
9. Our opinion: trade-offs
- Small store, under about 200 GB: either works, S3 is cheaper. IDrive e2 bills at least 1 TB, and S3's free 100 GB of egress covers a monthly full verification at this size. Pick S3 if you already use AWS.
- A media-heavy catalog in the hundreds of GB or more: IDrive e2. Storage is a quarter of S3's price, and the free egress means you can verify the whole repository every month and restore without a surprise bill. This is the case most stores with years of product photography end up in.
- Whatever the bucket: test the restore. The drill is the part that matters. Our corrupted-pack and empty-snapshot tests both "succeeded" until something read the data back.
- Keep the restic password off the server. Client-side encryption cuts both ways: without the password, nobody can read the backup, including you. Put it in your password manager the day you create the repository.
Related: monitoring with a heartbeat for cron jobs, protecting WooCommerce from bot attacks and handling customer export files.
10. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| S3-compatible bucket for the restic repository | $6 per TB a month or $59.50 per TB a year, free egress up to 3× stored volume, no API fees, Object Lock and versioning. Verification reads and restores don't add to the bill. | |
| Server for the backup job and drill | A $5 VPS can run the backup for a store on managed hosting and is a separate machine for restore drills. | |
| Server for the backup job, or the store itself | Droplets from $6 a month; managed MySQL if you'd rather not run the database yourself. |
Alternatives: free and official tools
We don't earn anything from these links.
- restic documentation: the S3 backend,
check --read-data-subset,forgetpolicies anddump. - AWS S3 pricing: storage classes, request prices and data transfer out.
- BorgBackup with borgmatic: a similar deduplicating, encrypted backup tool, if your target is a server over SSH rather than object storage.
- rclone: copies between storage providers; we used
rclone serve s3as the local S3 API in our test. - mariadb-dump and mysqldump: options such as
--single-transactionand the privileges they need.
11. Pitfalls
- Piping the dump into restic.
mysqldump | restic backup --stdinsaved a 0-byte snapshot in our test when the dump failed. Use--stdin-from-command(restic 0.17 and later). - A MariaDB dump restored with a MySQL client. The dump from MariaDB 11.4 started with a
/*M!999999\- enable the sandbox mode */line, which MariaDB added in 2024 and which MySQL's client doesn't understand. Restore with the client that matches the server that made the dump. - Only running
restic check. It passed with a corrupted data file. Read the data back on a rotation. - The password lives only on the server. If the server is what you lost, so is the backup.
- The server's key can delete the bucket. Anyone who takes over the server can wipe the backups with the same credentials. Object Lock, set when the bucket is created, is the fix.
- Pruning every night.
prunerepacks data and is the expensive step; the script prunes once a week. - No alert on silence. A cron job that stops running doesn't email anyone. The heartbeat ping is what turns "nothing happened" into an alert.
Back up the database with --stdin-from-command so a failed dump can't pass as a backup, encrypt on the server with restic, read a slice of the repository back every night, and let a monthly drill restore the orders into a scratch database. Store it on AWS S3 if the repository is small and you rarely read it, and on IDrive e2 once it reaches a few hundred GB, where storage costs about a quarter of S3's and verification and restores don't add egress charges.