A backup that has never been restored is only a theory. A restore drill proves whether your backup contains the right data, can be accessed, and can bring the service back within an acceptable time. The first test restore should not happen during an incident.
Define the restore target
Choose the right scope for this drill:
| Target | Use when | Risk |
|---|---|---|
| Temporary VPS | First drill, full stack test | None — disposable |
| Staging domain | Frequent testing, client demos | Must not send live emails |
| Database only | Quick integrity check | Does not test file recovery |
| Single file | Permission/config test | Narrow scope |
| Full production restore | Planned migration | Highest risk — do after staging success |
For the first drill, always use a temporary server. Do not practice on production.
Inventory what must be restored
For a typical WordPress server, you need:
Essential
- Database dump (
.sqlor.sql.gz) -
wp-content/uploads/(and all subdirectories) -
wp-content/themes/(custom themes) -
wp-content/plugins/(all plugins, including premium ones not in the repository) -
wp-config.php -
.htaccessor nginx config - PHP-FPM pool configuration
Frequently forgotten
- Cron jobs (system crontab for the web user)
- SSL certificates or ACME client config (Let’s Encrypt renewal paths)
- DNS records (export zone file before migration)
- SMTP credentials and API keys
- Object cache configuration (Redis/Memcached settings)
- Custom
.inifiles and PHP extensions - Web server includes and snippet files
- Monitoring agent config
- Backup job schedules (so backups resume after restore)
Missing secrets and cron jobs are the most common restore surprises. Check both early.
Step-by-step restore
1. Provision a test server
Create a fresh VPS or local Docker environment. The OS version, PHP version, and MySQL/MariaDB version should match the production server.
2. Install the stack
apt update && apt install nginx php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip mariadb-server redis-server certbot
3. Restore the database
# If compressed
gunzip < backup-20260607.sql.gz | mysql -u root -p database_name
# If plain SQL
mysql -u root -p database_name < backup-20260607.sql
If the database is large (>500 MB), consider:
pv backup.sql.gz | gunzip | mysql -u root -p database_name
pv shows progress, which is useful for large restores.
4. Run search-replace for the test domain
wp search-replace 'example.com' 'staging.example.com' --skip-columns=guid
Do not skip this step. If the database still references the production URL, you may accidentally redirect test traffic, generate analytics noise, or trigger live webhooks.
5. Restore files
rsync -a backup/wp-content/uploads/ /var/www/staging.example.com/htdocs/wp-content/uploads/
rsync -a backup/wp-content/themes/ /var/www/staging.example.com/htdocs/wp-content/themes/
rsync -a backup/wp-content/plugins/ /var/www/staging.example.com/htdocs/wp-content/plugins/
For extremely large uploads directories, use rsync with --bwlimit to avoid saturating the network:
rsync -a --bwlimit=50000 backup/wp-content/uploads/ /var/www/staging.example.com/htdocs/wp-content/uploads/
6. Fix file permissions
find /var/www/staging.example.com -type d -exec chmod 755 {} \;
find /var/www/staging.example.com -type f -exec chmod 644 {} \;
chmod 600 /var/www/staging.example.com/wp-config.php
chown -R www-data:www-data /var/www/staging.example.com
7. Reconfigure application secrets
Copy wp-config.php to the test server and update:
define('DB_NAME', 'database_name');
define('DB_USER', 'db_user');
define('DB_PASSWORD', 'test_db_password');
define('DB_HOST', 'localhost');
If the original wp-config.php uses environment variables or a secrets manager, replicate that approach on the test server.
8. Restore web server config
Copy nginx or Apache config and test:
nginx -t
systemctl reload nginx
9. Restore SSL
For Let’s Encrypt, issue a new certificate for the test domain:
certbot --nginx -d staging.example.com
Do not copy production certificates to a test server. Issue new ones.
Measure restore time
Track every phase:
| Phase | Time | Notes |
|---|---|---|
| Locate backup | ____ | Where is it? How long to access? |
| Download/mount | ____ | Network speed or snapshot time |
| Restore database | ____ | Affected by database size |
| Restore files | ____ | Affected by uploads size |
| Search-replace | ____ | Affected by database complexity |
| Reconfigure services | ____ | Affected by documentation quality |
| Application checks | ____ | Automated where possible |
This gives you a real Recovery Time Objective (RTO) instead of a guess.
Application checks after restore
- Homepage loads (check HTML, not just HTTP 200)
- Admin login works
- Media files render (check a few image URLs directly)
- Contact form submits (test mode — do not send to real recipients)
- WooCommerce product pages load
- Permalinks work (try a few deep URLs)
- Cron events are present:
wp cron event list - Cache can be flushed:
wp cache flush - Error logs are quiet:
tail -20 wp-content/debug.log(if enabled) - Backups are configured on the new server
Document gaps immediately
If the drill finds missing data, fix the backup job the same day. Common gaps:
- Missing uploads: The backup plugin only captured the database
- Expired API keys: SMTP, CDN, or payment gateway keys that only exist in prod env vars
- Missing plugins: Premium plugins that exist on disk but not in the backup
- Old PHP version: The backup assumes a PHP extension that is not available on the test server
- Undocumented DNS: You cannot recreate the zone because nobody exported it
Do not leave notes like “remember to add uploads later.” Backups drift unless they are actively maintained.
Frequency
| Site importance | Drill frequency |
|---|---|
| Business-critical (ecommerce, membership) | Monthly automated restore to staging |
| Important (lead gen, portfolio) | Quarterly |
| Low-risk (brochure site, blog) | Twice a year |
| After any major change | Immediately |
A quarterly drill takes a couple of hours. An incident with no working backup can take days and cost revenue. The maths is straightforward.