# Backups that actually restore: a small-site backup plan you can test

> A backup you have never restored is a hope. Decide what to copy, how often and where, script it with wp-cli and rclone or a plugin, keep it out of the web root, and test the restore once a quarter.

Updated 2026-09-25 · Security · HTML version: https://getreport.app/guides/backups-that-actually-restore

Every other security measure on a site reduces the chance of a bad day. A backup is the only one that decides how the bad day ends. A hacked site, a plugin update that white-screens the shop, a host that loses a disk: with a tested backup each is an hour of work; without one, each is a rebuild. This guide gives you a plan for a small site that you can set up in an afternoon and test in twenty minutes a quarter, and it explains the one way backups make a site *less* safe, which the report checks for.

## Quick answer

- Keep three copies: the live site, the host's automatic backup, and one copy you control in off-site storage (S3, Backblaze B2, Google Drive). Never only one of the last two.
- Back up the database as often as it changes (hourly for a shop, daily for a blog) and the files daily or on every deploy.
- Never store a backup inside the web root. A `backup.zip` in `/wp-content/` with directory listing on is a full copy of your site for anyone who asks.
- Restore to a staging site once a quarter, time it, and run a report on the result. Same findings as production means the copy is complete.
- Keep the secrets (`wp-config.php` or `.env`, the encryption key) separately from the backup they protect.
- Write the restore steps down before you need them.

## Why backups are a security topic

Three of the four things a backup protects against are security events: a compromise you want to roll back before, a ransomware-style defacement, a supply-chain update that ships something unwanted. The [hacked-site recovery guide](https://getreport.app/guides/recovering-a-hacked-wordpress-site-step-by-step) starts with "restore a clean copy from before the compromise", and that step only exists if you can name the date the backup was clean and find the copy.

The other direction matters just as much. Backups are complete copies of the site: every password in `wp-config.php`, every customer row in the database, every private upload. Put that copy where the web server can hand it out, and you have built the leak yourself. Scanners request `/backup.zip`, `/wp-content/backup.sql` and `/.env` on every site they meet, because it works often enough.

## How getReport checks it

> **Free tool:** [Security headers checker](https://getreport.app/tools/security-headers): Check HSTS, CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy and cookie flags on any site. Free, no signup, with a fix for every missing header.

The security module does not look for backup archives by name; guessing file names would be the scanner's approach, not a checkup's. It probes the two things that turn a stray backup into a leak. First it requests `/.env` and `/.git/HEAD` from the page's final URL and reads the first 4 KB. A 200 answer alone is not enough: the `.env` body must contain `NAME=value` lines and the `.git/HEAD` body a `ref: refs/…` line or a commit hash, so a custom 200 error page does not trigger it.

> **Check: No sensitive files are readable.** /.env holds database passwords and API keys; /.git exposes your source code and every secret ever committed. Both are the first things an automated scanner asks for.
>
> 1. Block dotfiles at the web server (nginx: location ~ /\. { deny all; }; Apache: RedirectMatch 404 /\..*$).
> 2. Move .env outside the web root and rotate every credential in it; assume the file has already been copied.

Then it takes up to three directories from the page's own asset paths (the folder of an image, a script or a stylesheet on the same host, typically `/wp-content/uploads/2026/09/` or `/wp-content/themes/yourtheme/assets/`), requests each, and looks for the "Index of" title that Apache and nginx put on an auto-generated listing:

> **Check: No directory shows a file listing.** An auto-generated "Index of /" page shows every file in the folder, including backups, uploads and configuration files nobody ever linked to.
>
> 1. Apache: Options -Indexes in .htaccess; nginx: autoindex off; or put an empty index.html in the folder.

![The exposed files finding on a site that serves its .git folder: the finding title, the explanation of what .env and .git reveal, the two fixes, and the technical line listing which path answered 200 with credential-like content](https://getreport.app/guides/img/backups-that-actually-restore/exposed.webp "The evidence line names the file that answered; the fix is a server rule plus rotating every secret the file held.")

If a listing is on, a backup archive saved into that folder is visible without anyone guessing its name. The wider set of leaks and the server rules for each are in [directory listings and exposed files](https://getreport.app/guides/directory-listings-and-exposed-files); this guide covers the backup side.

The [WordPress checker](https://getreport.app/tools/wordpress-checker) runs the same two probes and adds the version check:

> **Check: CMS version.** Old CMS versions have published security holes that bots scan for automatically, and the generator tag advertises the exact version to them.
>
> 1. Back up the site, then update the CMS from its admin dashboard; update plugins and themes at the same time.
> 2. Remove the generator tag (WordPress: remove_action('wp_head', 'wp_generator') in functions.php).

An out-of-date core is the other half of the backup story: the reason "back up first" appears in every update instruction is that an update is the most common trigger for needing a restore.

## Step by step

### 1. Decide what a complete copy contains

A site is more than the files in the web root. A copy that restores cleanly on a fresh server has all of these:

| Part | Where it lives | Notes |
| --- | --- | --- |
| Database | MySQL/MariaDB | Posts, orders, users, settings. Changes every minute on a shop. |
| Uploads | `wp-content/uploads/` (or your media folder) | Usually the largest part; changes when content is published. |
| Theme and plugin code | `wp-content/themes/`, `wp-content/plugins/`, `mu-plugins/` | Restorable from source, except the child theme and custom plugins. |
| Configuration | `wp-config.php` or `.env`, `.htaccess`, `nginx` site config | Small, secret, and the first thing missing from most backups. |
| Outside the web root | DNS zone, TLS certificate source, cron entries, email routing | Export the DNS zone from the registrar once; note where certificates come from. |

The last two rows are what people forget. A file backup of the web root does not contain the nginx configuration your developer tuned, the cron line that runs WP-Cron, or the DNS records. Export the DNS zone once (every registrar and Cloudflare offer a text export) and keep it with the plan.

### 2. Set a frequency per part

Match the schedule to how much you would lose:

| Site type | Database | Files (uploads, code) | Configuration |
| --- | --- | --- | --- |
| Shop, membership, bookings | Hourly | Daily | On every change |
| Blog, news, active editorial | Daily | Daily | On every change |
| Brochure site, rarely edited | Weekly | Weekly, and before every update | On every change |

A 200 MB shop database dumps in seconds and compresses to a tenth of that, so hourly is cheap. What you cannot recover is an order placed between the last dump and the failure; the interval is the size of that gap.

### 3. Apply 3-2-1 to a small site

The classic rule is three copies, on two kinds of storage, one of them off-site. Translated:

1. **The live site** is copy one.
2. **The host's backups** are copy two. Every managed WordPress host and most VPS providers offer daily snapshots; find out the retention (7, 14 or 30 days is typical) and whether restores are self-service or a support ticket. A host backup is fast to restore and worthless when the host itself is the problem: an account suspended over a billing error, a data-centre incident, the company closing.
3. **Your own off-site copy** is copy three, and the only one you fully control. It goes to storage you pay for separately from hosting: Backblaze B2 or Amazon S3 for anyone comfortable with an access key, Google Drive or Dropbox for everyone else. A `backups/` folder on the same server does not count; it survives a bad update and nothing else.

### 4. Pick the tool honestly

**Managed host backups.** Kinsta, WP Engine, SiteGround, Cloudways and similar keep daily snapshots and let you restore from the panel. Use them; they are the fastest restore you will ever have.

**A WordPress plugin to off-site storage.** UpdraftPlus (free) backs up database and files on a schedule to Drive, Dropbox, S3 and B2, and restores from the admin. BlogVault and Jetpack VaultPress Backup are paid services that pull the backup from outside, so they keep working when the site's own scheduler does not. Duplicator makes a full snapshot as one installer package, better suited to moving a site (see the [migration checklist](https://getreport.app/guides/wordpress-site-migration-checklist)) than to nightly backups.

**A script over SSH.** For anyone with shell access this is the most transparent option: two tools, no plugin, no size limits. `wp-cli` dumps the database, `tar` packs the files, `rclone` uploads to any storage. Save it as `/home/deploy/backup.sh`:

```bash
#!/usr/bin/env bash
# Nightly backup: database + wp-content to a B2/S3 bucket via rclone.
set -euo pipefail

SITE=/var/www/example.com          # folder that holds wp-config.php
STAMP=$(date +%F-%H%M)
WORK=$(mktemp -d)
REMOTE=b2:example-backups          # rclone remote name : bucket

wp --path="$SITE" db export "$WORK/db-$STAMP.sql" --add-drop-table
gzip "$WORK/db-$STAMP.sql"
tar -czf "$WORK/files-$STAMP.tar.gz" -C "$SITE" wp-content --exclude='wp-content/cache'
cp "$SITE/wp-config.php" "$WORK/wp-config-$STAMP.php"

rclone copy "$WORK" "$REMOTE/$(date +%Y/%m)/" --transfers 4
rm -rf "$WORK"
```

`rclone config` sets up the remote once (it asks for the B2 or S3 key). The cron line that runs it every night at 03:15, and a database-only dump every hour for a shop:

```text
# crontab -e for the site's user
15 3 * * *  /home/deploy/backup.sh >> /home/deploy/backup.log 2>&1
5  * * * *  wp --path=/var/www/example.com db export - | gzip | rclone rcat b2:example-backups/hourly/db-$(date +\%F-\%H).sql.gz
```

The `%` signs are escaped because cron treats them as newlines. Check `backup.log` the next morning and the bucket the day after; a script that fails silently is the same as no script.

**VPS snapshots.** Hetzner, DigitalOcean, Linode and others snapshot the whole disk on a schedule for a small share of the server price: a superb copy two, and still not copy three, because it lives with the same provider and account.

### 5. Keep backups out of the web root, and out of the mailbox

The rule is simple: no backup file may live in a folder the web server can serve. Not in `/`, not in `/wp-content/`, not in `/wp-content/uploads/backups/`. Plugins that write their working files under `wp-content` protect the folder with an `.htaccess` deny rule and an empty `index.html`; that protection is Apache-only, and on nginx the folder is served like any other. If a plugin stores locally, point it at a folder above the web root, or make sure the copy is deleted after the upload succeeds. An email inbox and the developer's laptop are not off-site storage either.

### 6. Retention, versioning and encryption

Keep enough history to reach back before a problem you noticed late. A hack is often found weeks after it started; 30 daily copies plus 3 monthly ones is a sound default, and most storage tiers make it cheap. On S3 or B2, turn on bucket versioning or a lifecycle rule so an overwritten or deleted object is kept, which also protects the backups from a compromised site deleting them: use an application key that can *write* to the bucket but not delete.

Encrypt the archive if the storage is shared or the database holds personal data. `rclone` does it transparently with a `crypt` remote layered on the bucket; `gpg --symmetric --cipher-algo AES256 files.tar.gz` does it by hand. Then the key becomes the thing to protect: it must live somewhere that survives the site, the server and the storage account, such as the team's password manager. An encrypted backup whose key sat on the server that just died is a random file.

### 7. Test the restore, every quarter

A backup has proven nothing until a restore has produced a working site. Once a quarter:

1. Restore the latest full backup to a staging site (a subdomain, a local Docker copy, or the host's staging feature, as in [staging sites: testing changes before they go live](https://getreport.app/guides/staging-sites-testing-changes-before-they-go-live)). Do not restore over production.
2. Log in, open the newest post or order, upload an image, submit a form.
3. Run a report on the staging URL and compare it with the production report. The same findings, the same page weight, the same plugin list means the copy is complete; a missing image finding or a different theme version tells you which part the backup skipped.
4. Note how long steps 1 to 3 took. That number is your recovery time, and it is the honest answer to "how bad would a bad day be".
5. Write the steps down as a runbook with the exact storage location, the key location and the restore commands, and store it somewhere that does not need the site to be up.

Keep the staging copy password-protected or `noindex`; the WordPress checker flags a restored copy that Google can reach as a public staging site.

### 8. Know the four recovery scenarios before you need one

**One file or folder.** A deleted upload, a broken template. Extract only that path from the latest archive (`tar -xzf files-2026-09-24.tar.gz wp-content/themes/child/functions.php`) and copy it into place.

**Database only.** A bad bulk edit, a plugin that mangled the options table. Import the last good dump: `wp db import db-2026-09-24-0300.sql`. Uploads and code stay as they are; on a shop, orders since that dump need re-entering from the payment provider's records.

**Whole site, same server.** A failed update, a defacement. Put the site in maintenance, copy the broken state first (you may need to examine it), restore files and database from the last clean set, then update to current versions before reopening.

**Host gone.** The account is unreachable, the server is dead, the provider closed. New server, install the web stack, copy files from your off-site storage, import the database, restore `wp-config.php` and the server config, point the DNS zone export at the new address. This is the scenario the quarterly test rehearses; with the runbook it is an afternoon.

## Verify

- The off-site bucket or folder holds a file dated last night, and the size is within a few percent of the previous one (a sudden 90 % drop means a part was skipped).
- `curl -sI https://example.com/.env` and `curl -sI https://example.com/.git/HEAD` answer 404 or 403, and the [security headers checker](https://getreport.app/tools/security-headers) shows the exposed files and directory listing findings as passed.
- No archive is reachable under the site: `curl -sI https://example.com/wp-content/backups/` answers 403 or 404, not a listing.
- The quarterly restore produced a working staging site, the report on it matched production, and the runbook is written down where someone other than you can find it.

## Common mistakes

- **The schedule never runs.** Symptom: "last backup: 6 weeks ago" in the plugin. Plugin schedules use WP-Cron, which only fires when someone visits the site; on a quiet site or one with WP-Cron disabled, nothing runs. Trigger it from a real cron job as described in [WP-Cron and scheduled tasks](https://getreport.app/guides/wp-cron-and-scheduled-tasks).
- **Only the database is backed up.** Symptom: the restore has every post and no images. Many "export" and "backup" buttons produce a database dump only. Check that the archive contains `wp-content/uploads`.
- **Uploads too large for the plugin.** Symptom: the file backup completes with a warning nobody read, and the media folder is partial. Plugins split or skip large archives; a 20 GB media library belongs in a script or a host snapshot, not a PHP process.
- **Restoring over the live site without a copy of it.** Symptom: the restore fixes yesterday's problem and deletes today's orders. Back up the broken state first, always.
- **The backup is in the web root, forever.** Symptom: the report's directory listing finding, or a scanner that found `backup.zip` before you did. Move it, and treat every secret in it as leaked: rotate the database password and the salts.
