VPS Snaps

How to back up and restore a Drupal site

A Drupal backup needs four things that are not in git: a dump of the database, the public files in sites/default/files, the private files directory if the site has one, and settings.php, which holds the database credentials and hash_salt. drush sql:dump writes the database and tar copies the files. Drupal core, contributed modules and vendor/ come back from composer install, so leave them out.

10 min readUpdated Checked against official documentation

What a complete Drupal backup includes

This guide assumes a site built with Composer, which Drush 13 requires, in the drupal/recommended-project layout: composer.json and vendor/ in /var/www/example, the document root in /var/www/example/web. Without a web directory, drop the web/ prefix.

PartWhereBack it up?
DatabaseMySQL, MariaDB, PostgreSQL or SQLite, set in settings.phpYes. Content, users and the active configuration.
Public filesweb/sites/default/filesYes, without css, js, php and styles, which Drupal rebuilds.
Private files$settings['file_private_path'], outside the document rootYes, if the site has one.
settings.phpweb/sites/defaultYes, on its own and encrypted. Also settings.local.php and services.yml if present.
Config sync directory$settings['config_sync_directory']In git when it sits outside the files directory; otherwise inside the files backup.
Custom code, composer.json, composer.lockProject root, web/modules/custom, web/themes/customIn git. Back them up only if they are not in a repository.
Core, contrib, vendorweb/core, web/modules/contrib, vendor/No. composer install restores them.
Cachescache_* tables, files/css, files/js, files/php, files/stylesNo. Rebuilt on demand.

Drupal core's example.gitignore keeps exactly these out of git: sites/*/*settings*.php, sites/*/*services*.yml, sites/*/files and sites/*/private. Whatever your repository ignores, your backup has to carry.

On SQLite, Drupal's installer defaults to sites/default/files/.ht.sqlite for the database, inside the public files. A plain tar can catch it mid-write; copy it with SQLite's own tools first, as in how to back up a SQLite database.

Find the paths for your site

Drush 13 supports Drupal 10.2 and later and is installed per project with composer require drush/drush. Run it from the project root as the user PHP runs as, www-data on Debian and Ubuntu, so files it writes stay usable by the web server. The tar commands in this guide run as root.

Terminal
cd /var/www/example
Terminal
sudo -u www-data vendor/bin/drush status --fields=root,drupal-settings-file,db-name,files,private,config

files is the public files directory, relative to the Drupal root. private is empty when the site has no private file system. config is the config sync directory. Drupal's default puts it in a randomly named folder inside the public files, where the files backup covers it. If you moved it elsewhere, commit it to git.

Dump the database with drush sql:dump

Create a private backup directory that the Drush user can write to:

Terminal
install -d -m 700 -o www-data -g www-data /var/backups/drupal
Terminal
sudo -u www-data vendor/bin/drush sql:dump --gzip --result-file=/var/backups/drupal/example-db-$(date +%F).sql
  • --result-file names the file. With --gzip, Drush appends .gz, so this writes example-db-2026-10-04.sql.gz.
  • --gzip pipes the dump through gzip, and Drush runs that pipe with bash's pipefail, so a failed mysqldump fails the command instead of leaving a small file that looks fine.
  • On MySQL and MariaDB, Drush always adds --single-transaction, which dumps InnoDB tables from one consistent snapshot without locking them, plus --opt and -Q. On PostgreSQL it runs pg_dump with --clean.

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

Terminal
gunzip -c /var/backups/drupal/example-db-2026-10-04.sql.gz | tail -n 1

Drupal's user guide says cache and session tables can be emptied, though backing up the whole database is always safe. To keep some tables' definitions but skip their rows, list them:

Terminal
sudo -u www-data vendor/bin/drush sql:dump --gzip --structure-tables-list='cache_*,watchdog' --result-file=/var/backups/drupal/example-db-$(date +%F).sql

The quotes stop the shell expanding *. Include your table prefix, if any, and keep sessions unless everyone may log in again after a restore. The mysqldump guide and pg_dump guide cover the underlying tools.

Archive the public and private files

Dump the database first, then the files, so the database never points at a file the archive lacks.

Terminal
tar -czf /var/backups/drupal/example-files-$(date +%F).tar.gz --exclude='web/sites/default/files/css' --exclude='web/sites/default/files/js' --exclude='web/sites/default/files/php' --exclude='web/sites/default/files/styles' -C /var/www/example web/sites/default/files private
  • css and js hold aggregated CSS and JavaScript, styles holds resized image derivatives, and php holds compiled Twig templates. Drupal rebuilds all four on demand, and drush archive:dump leaves out the same four.
  • private is the private files directory, here /var/www/example/private. Remove it if there is none; if it lives elsewhere, add a second -C with its parent and name.
  • Exclude patterns match the stored names and must come before the directories.

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

Keep settings.php and the hash salt

settings.php holds the $databases credentials and $settings['hash_salt']. Drupal signs one-time login and cancel links with the hash salt, and combines it with a private key for form tokens and image style URLs. The private key is stored in the database, in the state entry system.private_key, so the dump carries it. The hash salt lives only in settings.php, or in a file settings.php reads.

Drupal's default.settings.php suggests keeping the salt in a file outside the document root, and making sure that file is not stored with your database backups. Treat settings.php the same way: archive it on its own, store it apart from the dumps, and encrypt it.

Terminal
tar -czf /var/backups/drupal/example-settings-$(date +%F).tar.gz -C /var/www/example web/sites/default/settings.php

Add web/sites/default/settings.local.php, web/sites/default/services.yml and the salt file if they exist. Run it again whenever settings change.

Without its hash salt, Drupal stops with Missing $settings['hash_salt'] in settings.php. A new random value, for example from php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;', fixes that, but every one-time login link already sent stops working. Use it only when the original is gone.

drush archive:dump, and what it leaves out

Drush can also pack code, public files and the database into one archive:

Terminal
sudo -u www-data vendor/bin/drush archive:dump --destination=/var/backups/drupal/example-archive-$(date +%F).tar.gz
  • The archive holds code/, files/, database/database.sql and a MANIFEST.yml. --code, --files or --db limit it to one part.
  • The code part skips .git, vendor/, every Composer-installed package (found with composer info, so Composer must be on the PATH) and settings.*.php files such as settings.local.php. settings.php itself is included.
  • The files part is the public directory without css, js, styles and php. Private files are not included.
  • Drush copies everything into a temporary directory before packing it, so it needs free disk space for a second copy.

There is no matching restore command. Drush 13.0.0 removed archive:restore, although Drupal's backup page still showed it as of October 2026. Extract the archive and follow the restore steps below, loading database/database.sql as the dump.

Restore on a new server

Install a web server, Composer, the same PHP version and the same or a newer MySQL or MariaDB; Drupal's restore guide says to record both versions with each backup. Create an empty database and user with the privileges Drupal's install guide lists, plus LOCK TABLES, because mysqldump writes LOCK TABLES statements into the dump:

MySQL / MariaDB prompt
CREATE DATABASE drupal CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'drupal'@'localhost' IDENTIFIED BY 'your-password-here';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES ON drupal.* TO 'drupal'@'localhost';

Clone the repository into /var/www/example, check out the commit that was live when the backup was taken, and, as the user that owns the code, install the locked dependencies:

Terminal
composer install --no-dev

--no-dev skips require-dev packages, so Drush must be listed under require. Restore settings.php and the files, then update $databases in settings.php if the credentials changed:

Terminal
tar -xzf /var/backups/drupal/example-settings-2026-10-04.tar.gz -C /var/www/example
Terminal
tar -xzf /var/backups/drupal/example-files-2026-10-04.tar.gz -C /var/www/example

If the site answers on a new hostname, add it to $settings['trusted_host_patterns']. Restoring over the live site instead? Dump its current state, then empty the database. A dump only replaces its own tables, so tables added after the backup would survive:

Terminal
sudo -u www-data vendor/bin/drush sql:drop -y

Then load the dump. sql:cli reads it from standard input with the credentials in settings.php:

Terminal
gunzip -c /var/backups/drupal/example-db-2026-10-04.sql.gz | sudo -u www-data vendor/bin/drush sql:cli

Drush warns that this is slow for large dumps; pipe those into mysql with an option file instead. Avoid drush sql:query --file on a .sql.gz: it decompresses your backup copy in place.

Root's tar restores the original owners when the same users exist. Otherwise follow Drupal's file permissions guide: code owned by a deploy user and readable by the web server's group, and only the files and private directories writable by it.

Updates, caches, and whether to import config

Terminal
sudo -u www-data vendor/bin/drush updatedb:status

Pending updates mean the code is newer than the dump; drush updatedb lists them, asks, and runs them in maintenance mode. Restoring the commit that was live, there should be none. Then rebuild every cache:

Terminal
sudo -u www-data vendor/bin/drush cache:rebuild
Terminal
sudo -u www-data vendor/bin/drush config:status

No differences between DB and sync directory. means the restored configuration matches the YAML. Differences are admin UI changes made after the last config:export, or newer YAML from a later commit. drush config:import makes the YAML win and discards the first kind, so run it only on purpose. The same goes for drush deploy, which runs updatedb, config:import, cache:rebuild and deploy hooks together.

Verify the restore

Terminal
sudo -u www-data vendor/bin/drush status --fields=db-status,bootstrap

Expect Connected and Successful. Then list the red lines of Drupal's status report, and count some content to compare with production:

Terminal
sudo -u www-data vendor/bin/drush core:requirements --severity=2
Terminal
sudo -u www-data vendor/bin/drush sql:query "SELECT COUNT(*) FROM node_field_data"

Fetch the home page and expect 200, then sign in with a one-time link and open a page with images and a private file:

Terminal
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/
Terminal
sudo -u www-data vendor/bin/drush user:login --uri=https://example.com --no-browser

Practice this on a spare server before you need it; testing restores explains how to make it routine.

Automate it with cron

The script dumps to a hidden temporary name, checks the dump (MySQL and MariaDB), and only then renames it. tar exits with 1 when a file changed while being read, so 1 counts as success.

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

PROJECT="/var/www/example"
SITE="example"
FILES_DIR="web/sites/default/files"
BACKUP_DIR="/var/backups/drupal"
KEEP_DAYS=14
STAMP="$(date +%Y-%m-%d_%H%M)"
TMP_DB="$BACKUP_DIR/.$SITE-db-$STAMP.sql"
DB="$BACKUP_DIR/$SITE-db-$STAMP.sql.gz"
FILES="$BACKUP_DIR/$SITE-files-$STAMP.tar.gz"

trap 'rm -f "$TMP_DB.gz" "$FILES.partial"' EXIT
cd "$PROJECT"

sudo -u www-data vendor/bin/drush sql:dump --gzip --result-file="$TMP_DB"
gunzip -c "$TMP_DB.gz" | tail -n 1 | grep -q "Dump completed"
mv "$TMP_DB.gz" "$DB"

tar -czf "$FILES.partial" \
  --exclude="$FILES_DIR/css" --exclude="$FILES_DIR/js" \
  --exclude="$FILES_DIR/php" --exclude="$FILES_DIR/styles" \
  -C "$PROJECT" "$FILES_DIR" private || [ $? -eq 1 ]
mv "$FILES.partial" "$FILES"

find "$BACKUP_DIR" -type f \( -name "$SITE-db-*" -o -name "$SITE-files-*" \) -mtime +"$KEEP_DAYS" -delete
Terminal
chmod 755 /usr/local/bin/drupal-backup.sh
/etc/cron.d/drupal-backup
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

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

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

A multisite install has one directory per site under web/sites, each with its own settings.php, files directory and usually its own database. Back up every site directory, and run each Drush command once per site with --uri= set to that site's address.

Common errors

ErrorFix
Missing $settings['hash_salt'] in settings.php.settings.php or the salt file it reads was not restored. Restore it; generate a new salt only as a last resort.
The provided host name is not valid for this server.Add the new hostname to $settings['trusted_host_patterns'] in settings.php.
Access denied; you need (at least one of) the PROCESS privilege(s) for this operationMySQL 8 needs PROCESS to dump tablespaces. Add --extra-dump=--no-tablespaces to sql:dump.
Unable to dump database. Rerun with --debug to see any error message.mysqldump or pg_dump failed or is not installed. Rerun with --debug, and install the database client package if it is missing.
The import stops at a LOCK TABLES line with access deniedGrant LOCK TABLES on the database to the Drupal user.
Images are missing after the restoreThe public files were not restored, or the web server cannot write to sites/default/files, where Drupal regenerates image styles. Check ownership.

Frequently asked questions

Do I need to back up Drupal core and the vendor folder?
No. composer install rebuilds web/core, contributed modules and themes, and vendor from composer.lock, which belongs in git. Back up the database, the public and private files, and settings.php.
What happened to drush archive:restore?
Drush 13.0.0 removed it. drush archive:dump still exists; extract its archive and restore the code, files and database/database.sql by hand. Note that it does not include private files.
What if I lose settings.php?
Copy default.settings.php, fill in $databases, and set file_private_path and config_sync_directory to their old values. You will need a new hash_salt, which invalidates one-time login links already sent.
Should I run drush config:import after a restore?
Usually not. The restored database holds the site's active configuration, and config:import would replace it with the YAML in the sync directory. Check drush config:status first.

How this was checked

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