VPS Snaps

How to back up and restore a WordPress site on your own server

A complete WordPress backup has two parts: a dump of the database and a copy of the site directory, including wp-content and wp-config.php. With WP-CLI, wp db export dumps the database using the credentials in wp-config.php, and tar archives the files. To restore, load both on any server, fix ownership, and run wp search-replace only if the URL changed.

10 min readUpdated Checked against official documentation

What a complete backup includes

Posts, pages, comments, users, settings and most plugin data live in the MySQL or MariaDB database. Everything else is files in the site directory. You need both to restore the site.

PartWhereBack it up?
DatabaseMySQL or MariaDB, named in DB_NAMEYes. This is the site's content.
Uploadswp-content/uploadsYes. Media cannot be rebuilt.
Themes and pluginswp-content/themes, wp-content/pluginsYes. Paid, custom and edited ones cannot be downloaded again.
wp-config.phpSite root, or one directory above itYes, and treat it as a secret.
.htaccessSite root (Apache only)Yes. It holds permalink and custom rules.
WordPress corewp-admin, wp-includes, root PHP filesOptional. Reinstallable, but small, and keeping it makes restores simpler.
Cacheswp-content/cache, wp-content/upgradeNo. Temporary or rebuilt.

wp-config.php holds the database password and the security keys and salts behind every login cookie. Keep backups in private storage that only you can read. Keep the original keys too: if they change, every user has to log in again.

Read the database settings from wp-config.php

The commands in this guide run as root, for example after sudo -i. WP-CLI is the exception: it refuses to run as root, since plugin code would then get root's privileges, so run it as the user PHP runs as, www-data on Debian and Ubuntu. --path points it at the site.

Terminal
sudo -u www-data wp --path=/var/www/example.com config get DB_NAME

Repeat with DB_USER, DB_HOST and table_prefix. Without WP-CLI, read the file:

Terminal
grep -E "DB_(NAME|USER|PASSWORD|HOST)|table_prefix" /var/www/example.com/wp-config.php

DB_HOST can carry a port (127.0.0.1:3307) or a socket (127.0.0.1:/var/run/mysqld/mysqld.sock). WP-CLI understands both; mysqldump needs them as separate settings. If wp-config.php is missing from the site root, look one directory up: WordPress loads it from there too.

Export the database with WP-CLI

Create a private directory for the backups, then export:

Terminal
install -d -m 700 /var/backups/wordpress
Terminal
sudo -u www-data wp --path=/var/www/example.com db export --single-transaction - | gzip > /var/backups/wordpress/example.com-db-$(date +%F).sql.gz
  • - sends the dump to standard output. gzip compresses it and root's shell writes the file, so www-data never needs access to the backup directory.
  • WP-CLI passes flags it does not know to mysqldump. --single-transaction dumps InnoDB tables from one consistent snapshot without locking them, so the site stays up.
  • WP-CLI adds --no-tablespaces itself, so the WordPress database user does not need the global PROCESS privilege.
  • It dumps the whole database named in DB_NAME, including tables of other apps that share it.

A complete dump ends with a -- Dump completed on line. Check it:

Terminal
gunzip -c /var/backups/wordpress/example.com-db-2026-10-03.sql.gz | tail -n 1

A pipe returns gzip's exit status. If the export fails, you still get a small file that looks fine. Scripts need set -o pipefail and the check above.

Export without WP-CLI

Use mysqldump with the credentials from wp-config.php in an option file, so the password stays out of ps and your shell history:

/etc/mysql/wp-backup.cnf
[client]
user=wp_user
password="your-password-here"
host=localhost
Terminal
chmod 600 /etc/mysql/wp-backup.cnf
Terminal
mysqldump --defaults-extra-file=/etc/mysql/wp-backup.cnf --single-transaction --no-tablespaces wordpress | gzip > /var/backups/wordpress/example.com-db-$(date +%F).sql.gz

Here --no-tablespaces matters: without it mysqldump needs PROCESS, which a WordPress user rarely has. For a port or socket from DB_HOST, add port=3307 or socket=/var/run/mysqld/mysqld.sock to the option file. The mysqldump guide covers the other options.

Archive the site files

Dump the database first, then the files. A file uploaded in between is then present but unused, which is harmless. The other order can leave the database pointing at a file the archive lacks.

Terminal
tar -czf /var/backups/wordpress/example.com-files-$(date +%F).tar.gz --exclude='example.com/wp-content/cache' --exclude='example.com/wp-content/upgrade' -C /var/www example.com
  • -C /var/www example.com stores paths as example.com/..., so the archive extracts anywhere.
  • Exclude patterns match those stored names, and must come before the directory or GNU tar ignores them.
  • If backup plugins keep archives inside wp-content, exclude that folder too, or each backup carries old backups. du -sh /var/www/example.com/wp-content/* shows what is large.
  • If wp-config.php sits one level up, add wp-config.php after example.com at the end of the command.

tar run as root records owners and permissions, which a restore needs. The tar guide explains excludes and how to test an archive.

Restore on the same or a new server

On a new server, install the web server, PHP and MySQL or MariaDB, then create an empty database and user. wp db import does not create the database.

MySQL / MariaDB prompt
CREATE DATABASE wordpress;
CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'your-password-here';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wp_user'@'localhost';

On the same server, move the broken site aside instead of extracting over it, so files added after the backup do not mix with the restored ones:

Terminal
mv /var/www/example.com /var/www/example.com.old
Terminal
tar -xzf /var/backups/wordpress/example.com-files-2026-10-03.tar.gz -C /var/www

If the database name, user, password or host differ, update them in wp-config.php. Then import; - reads the dump from standard input:

Terminal
gunzip -c /var/backups/wordpress/example.com-db-2026-10-03.sql.gz | sudo -u www-data wp --path=/var/www/example.com db import -

mysqldump writes DROP TABLE IF EXISTS before each table, so the import replaces tables of the same name. Without WP-CLI, pipe the dump into mysql --defaults-extra-file=/etc/mysql/wp-backup.cnf wordpress instead.

Root's tar restores the original owners when the same users exist. If PHP runs as a different user here, give the files to the user that owned them before; a common setup is the PHP user. Then apply WordPress's recommended modes: 755 for directories, 644 for files, 440 for wp-config.php.

Terminal
chown -R www-data:www-data /var/www/example.com
Terminal
find /var/www/example.com -type d -exec chmod 755 {} \;
Terminal
find /var/www/example.com -type f -exec chmod 644 {} \;
Terminal
chmod 440 /var/www/example.com/wp-config.php

Moving the site to a new host for good, with DNS, HTTPS and email to switch over as well? See how to move a WordPress site to a new host.

Change the URL, then clear caches

If the domain changed, update it everywhere. WordPress stores full URLs in its settings, posts and plugin options, some inside PHP serialized data that records each string's length. Editing the SQL file with a text replace breaks those values. wp search-replace rewrites them correctly. Do a dry run first:

Terminal
sudo -u www-data wp --path=/var/www/example.com search-replace 'https://old.example.com' 'https://new.example.com' --skip-columns=guid --all-tables-with-prefix --dry-run
  • --dry-run reports the replacements per table without saving them. Run the command again without it to apply.
  • --skip-columns=guid leaves post GUIDs alone. WordPress's docs say never to change them: feed readers use them, and changed GUIDs show old posts as new.
  • --all-tables-with-prefix includes plugin tables with your prefix that WordPress does not register.

If wp-config.php defines WP_HOME or WP_SITEURL, update them too; they override the database values. Whether or not the URL changed, clear anything still holding old state:

Terminal
sudo -u www-data wp --path=/var/www/example.com cache flush
Terminal
sudo -u www-data wp --path=/var/www/example.com transient delete --all
Terminal
sudo -u www-data wp --path=/var/www/example.com rewrite flush --hard

cache flush empties the object cache; on multisite with a persistent object cache, that is every site's cache. transient delete --all removes cached values that may still hold the old address. rewrite flush rebuilds the permalink rules; --hard also updates .htaccess, and works on single sites only. A caching plugin's page cache is cleared from its settings page.

Verify the restore

Terminal
sudo -u www-data wp --path=/var/www/example.com core verify-checksums
Output
Success: WordPress installation verifies against checksums.

That compares core files with WordPress.org's checksums, which also catches a tampered core file. wp plugin verify-checksums --all does the same for plugins from WordPress.org. wp db check checks every table with mysqlcheck, and wp option get home shows the address the site now uses. Then fetch key pages and expect 200:

Terminal
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/

Finally, log in to wp-admin and open the Media Library. Practice this on a spare server before you need it; testing restores explains how.

Automate it with cron

This script writes each file under a temporary name, checks the dump, and only then gives it its real name. tar exits with 1 when a file changed while it was read, which is common for uploads, so 1 counts as success and anything higher as failure.

/usr/local/bin/wp-backup.sh
#!/bin/bash
set -euo pipefail

SITE_DIR="/var/www"
SITE="example.com"
BACKUP_DIR="/var/backups/wordpress"
KEEP_DAYS=14
STAMP="$(date +%Y-%m-%d_%H%M)"
DB="$BACKUP_DIR/$SITE-db-$STAMP.sql.gz"
FILES="$BACKUP_DIR/$SITE-files-$STAMP.tar.gz"

mkdir -p "$BACKUP_DIR"
trap 'rm -f "$DB.partial" "$FILES.partial"' EXIT

sudo -u www-data /usr/local/bin/wp --path="$SITE_DIR/$SITE" db export --single-transaction - | gzip > "$DB.partial"
gunzip -c "$DB.partial" | tail -n 1 | grep -q "Dump completed"
mv "$DB.partial" "$DB"

tar -czf "$FILES.partial" --exclude="$SITE/wp-content/cache" --exclude="$SITE/wp-content/upgrade" -C "$SITE_DIR" "$SITE" || [ $? -eq 1 ]
mv "$FILES.partial" "$FILES"

find "$BACKUP_DIR" -name "$SITE-*.gz" -type f -mtime +"$KEEP_DAYS" -delete
Terminal
chmod 755 /usr/local/bin/wp-backup.sh
/etc/cron.d/wp-backup
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

15 3 * * * root flock -n /run/lock/wp-backup.lock /usr/local/bin/wp-backup.sh >> /var/log/wp-backup.log 2>&1

-mtime +14 keeps about two weeks of nightly backups, and flock -n stops two runs overlapping. Backups on the server's own disk die with it, so copy them off it each night, for example with rclone to Backblaze B2 or an S3 bucket. The cron guide covers logging and alerts.

Running a WooCommerce store? Orders arrive between backups, so a restore needs more care than a blog does: see how to back up a WooCommerce store.

Multisite networks

A multisite network shares one database and one wp-content. Each site has its own tables, such as wp_2_posts, so the commands above back up every site at once. To export one site's tables on their own:

Terminal
sudo -u www-data wp --path=/var/www/example.com db export --tables=$(sudo -u www-data wp --path=/var/www/example.com db tables --url=sub.example.com --format=csv) - > /var/backups/wordpress/sub-site.sql

When the domain changes, add --network to search-replace so it covers every site's tables, and --url= with the old main address so WP-CLI can load the network. WordPress's moving guide also says to review the wp_site and wp_blogs tables by hand and update wp-config.php, where DOMAIN_CURRENT_SITE holds the main domain.

Common errors

ErrorFix
Error: YIKES! It looks like you're running this as root.Run WP-CLI as the site's user: sudo -u www-data wp ....
Error establishing a database connectionwp-config.php has the wrong name, user, password or host for this server, or the user has no grant on the database.
The install screen appears after a restoreThe database is empty, or $table_prefix does not match the table names in the dump.
Access denied; you need (at least one of) the PROCESS privilege(s) for this operationAdd --no-tablespaces to mysqldump. WP-CLI adds it for you.
The mysqldump or mariadb-dump binary is not available.Install the MySQL or MariaDB client package on the server.
The site redirects to the old domainRun search-replace, check wp option get home, and look for WP_HOME or WP_SITEURL in wp-config.php.
Every page but the home page returns 404On Apache, run wp rewrite flush --hard. On Nginx, check the new server block's try_files rule.
Images are missingwp-content/uploads was not restored, or the PHP user cannot read it. Check ownership.

On a cPanel server, WHM can back up whole accounts: see how to back up a cPanel server. Moving the site to a new host? See moving a server to a new provider.

Frequently asked questions

Is backing up wp-content enough?
No. Posts, pages, users and settings live in the database, and wp-config.php sits outside wp-content. Back up the database and the whole site directory.
Does wp db export take the site offline?
No. With --single-transaction, InnoDB tables are dumped from a snapshot and stay writable. Without it, mysqldump locks the tables while it dumps them, so writes wait.
How do I move WordPress to a new domain?
Restore the backup on the new server, then run wp search-replace with the old and new URLs and --skip-columns=guid. Never replace URLs in the SQL file with a text editor: it breaks serialized data.
Will restoring a backup log users out?
Not if you restore the original wp-config.php. New security keys and salts invalidate all existing login cookies, so everyone would have to log in again.

How this was checked

Commands, limits and prices were checked against these official pages, on October 3, 2026: