VPS Snaps

How to back up and restore a Laravel application

A Laravel backup needs three things: a dump of the database, the files under storage/app, and the .env file, stored separately and encrypted because it holds APP_KEY. Everything else is rebuilt by Composer and Artisan, and the code is in git. Lose APP_KEY and you can restore every row but not decrypt the values Laravel encrypted.

9 min readUpdated Checked against official documentation

What to back up, and what to skip

PathBack up?Why
DatabaseYesAll application data. With the default database session driver, sessions too.
storage/appYesFiles saved to the local and public disks: uploads, generated files.
.envYes, separately and encryptedAPP_KEY and every credential.
storage/logsOptionalHistory, not needed to run.
storage/frameworkNoCompiled views, file cache and file sessions. Rebuilt; with file sessions, users log in again.
bootstrap/cacheNoRebuilt by php artisan optimize.
vendor/, node_modules/, public/buildNoRebuilt by composer install and npm run build.
Application codeIn gitBack it up only if it is not in a repository.

In apps created on Laravel 11 or later, the local disk writes to storage/app/private and the public disk to storage/app/public. Laravel 10 used storage/app itself. Backing up all of storage/app covers both. Files on an S3 disk are not on the server; protect them in the bucket.

Why APP_KEY needs its own backup

Laravel encrypts every cookie with APP_KEY, including the session cookie, and uses it for Crypt::encryptString() and the encrypted model casts. Laravel's docs are plain: if the key changes, all users are logged out, and data encrypted with the old key can no longer be decrypted. A database restored under a new key loads fine, then fails with The MAC is invalid. on every encrypted value.

Never run php artisan key:generate on a restored app: it replaces APP_KEY. If you rotated the key before, back up the old keys as well. They belong in APP_PREVIOUS_KEYS, and data encrypted with them cannot be read without them.

.env also holds every credential, so keep it out of plain backups. Laravel can encrypt it:

Terminal
php artisan env:encrypt

This writes .env.encrypted and prints the key. Store the key in a password manager, not on the server, and copy .env.encrypted to your backup storage. After each change to .env, run it again with --key= set to that key and --force to overwrite the old file; without --force it stops with Encrypted environment file already exists.

Read the database settings

Terminal
grep '^DB_' /var/www/app/.env

DB_CONNECTION is usually mysql, mariadb, pgsql or sqlite. If DB_URL is set, it replaces the separate values. php artisan config:show database prints what Laravel actually resolved, passwords included.

Dump the database

Create a private backup directory first:

Terminal
install -d -m 700 /var/backups/laravel

MySQL or MariaDB. Put the credentials in an option file so the password stays out of ps:

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

--single-transaction gives a consistent dump without locking InnoDB tables. --no-tablespaces avoids needing the global PROCESS privilege, which app users rarely have. Add --routines --events if your migrations create stored procedures or events. More in the mysqldump guide.

PostgreSQL. Store the password in ~/.pgpass as 127.0.0.1:5432:laravel:laravel:your-password-here, chmod 600 it, then:

Terminal
pg_dump -h 127.0.0.1 -U laravel -w -Fc -f /var/backups/laravel/app-db-$(date +%F).dump laravel

-Fc writes a compressed archive for pg_restore, and -w fails instead of waiting for a password prompt. See the pg_dump guide.

SQLite. The database is one file, usually database/database.sqlite. A plain cp can catch it mid-write; the .backup command of the sqlite3 tool copies it safely:

Terminal
sqlite3 /var/www/app/database/database.sqlite ".backup /var/backups/laravel/app-db-$(date +%F).sqlite"

Back up storage/app

Terminal
tar -czf /var/backups/laravel/app-files-$(date +%F).tar.gz -C /var/www/app storage/app

-C stores paths relative to the app, so the archive extracts straight into a fresh checkout. Keep .env.encrypted in different storage from these archives.

Laravel Forge sites

Forge puts each site under /home/forge, for example /home/forge/example.com. With standard deployments, the app lives in that directory: back up its .env and storage/app.

With zero-downtime deployments, Forge's default for new sites, each deploy goes into releases/ and current is a symlink to the live one. Forge shares .env across releases by default. Anything else, such as storage, carries over between releases only if it is listed as a shared path; a shared storage lives at /home/forge/example.com/storage. Back up the shared paths, not releases/. To see where the shared .env really is:

Terminal
readlink -f /home/forge/example.com/current/.env

If storage is not a shared path, uploads stay inside the release that wrote them, and Forge deletes old releases (it keeps four by default). Add storage as a shared path before you rely on local uploads.

spatie/laravel-backup: backups from inside the app

The free, open-source spatie/laravel-backup package dumps the database and zips files from Artisan, then writes the zip to any filesystem disk in your app, such as an S3-compatible bucket. Version 10 needs Laravel 12 or later, the PHP zip extension, and mysqldump or pg_dump on the server.

Terminal
composer require spatie/laravel-backup
Terminal
php artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider" --tag=backup-config

In the published config/backup.php:

  • backup.source.files.include defaults to the whole app, minus vendor, node_modules and storage/framework. So .env goes into the zip.
  • backup.source.databases lists the connections to dump; by default the one in DB_CONNECTION.
  • backup.destination.disks defaults to local, the same server. Point it at an off-server disk.
  • backup.password reads BACKUP_ARCHIVE_PASSWORD and encrypts the zip when set.

Set BACKUP_ARCHIVE_PASSWORD, because the zip contains .env, and keep a copy in your password manager. The password itself lives in that encrypted .env, so once the server is gone the copy is the only way in.

Terminal
php artisan backup:run

--only-db or --only-files limits a run to one part. The package's docs schedule cleanup, backup and a health check:

routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('backup:clean')->daily()->at('01:00');
Schedule::command('backup:run')->daily()->at('01:30');
Schedule::command('backup:monitor')->daily()->at('03:00');

By default backup:clean keeps every backup for 7 days, then daily ones for 16 days, weekly for 8 weeks, monthly for 4 months and yearly for 2 years, and never deletes the newest. backup:monitor flags a newest backup older than a day, or more than 5000 MB in total; backup:list shows the status. There is no restore command: unzip the archive and load the dump from its db-dumps folder as shown below.

Restore on a new server

Install PHP, Composer, the web server and the database server, and create an empty database and user. Clone the repository to /var/www/app and check out the commit that was live when the backup was taken, so code and schema match. Then, in /var/www/app:

Terminal
composer install --no-dev --optimize-autoloader

--no-dev skips development packages, and --optimize-autoloader builds a faster class map. Copy .env.encrypted into the directory and decrypt it, then change values that differ on this server, such as DB_HOST. Keep APP_KEY as it is.

Terminal
php artisan env:decrypt --key=your-encryption-key

Load the database, then the files:

Terminal
gunzip -c /var/backups/laravel/app-db-2026-10-03.sql.gz | mysql --defaults-extra-file=/etc/mysql/laravel-backup.cnf laravel
Terminal
pg_restore -h 127.0.0.1 -U laravel -w --no-owner -d laravel /var/backups/laravel/app-db-2026-10-03.dump
Terminal
tar -xzf /var/backups/laravel/app-files-2026-10-03.tar.gz -C /var/www/app

Recreate the public/storage symlink, which is not in git, and let the PHP user write where Laravel needs to:

Terminal
php artisan storage:link
Terminal
chown -R www-data:www-data storage bootstrap/cache
Terminal
php artisan migrate:status

Every migration should show as ran. Pending ones mean the code is newer than the dump; run php artisan migrate --force only if you meant to deploy that code. Then rebuild the caches, and the frontend with npm ci and npm run build if the app uses Vite:

Terminal
php artisan optimize

Finally, add back what lives outside the app: the scheduler's cron entry and the process monitor for queue workers.

Verify the restore

Terminal
php artisan db:show --counts

That lists every table with its row count; compare a few with production. Recent Laravel versions also serve a health route at /up that returns 200 once the app boots:

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

Then log in as a real user and open a page with encrypted data and an uploaded image. A wrong APP_KEY shows up as The MAC is invalid., a missing symlink as broken images. The restore testing guide shows how to make this routine.

Automate: cron or the Laravel scheduler

A root cron job runs even when the app is broken. The Laravel scheduler keeps the schedule in git, but its tasks stop if the app cannot boot, and skip maintenance mode unless you add evenInMaintenanceMode(). Either way, use a script like this:

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

APP="/var/www/app"
BACKUP_DIR="/var/backups/laravel"
KEEP_DAYS=14
STAMP="$(date +%Y-%m-%d_%H%M)"
DB="$BACKUP_DIR/app-db-$STAMP.sql.gz"
FILES="$BACKUP_DIR/app-files-$STAMP.tar.gz"

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

mysqldump --defaults-extra-file=/etc/mysql/laravel-backup.cnf \
  --single-transaction --no-tablespaces laravel | gzip > "$DB.partial"
gunzip -c "$DB.partial" | tail -n 1 | grep -q "Dump completed"
mv "$DB.partial" "$DB"

tar -czf "$FILES.partial" -C "$APP" storage/app || [ $? -eq 1 ]
mv "$FILES.partial" "$FILES"

find "$BACKUP_DIR" -name "app-*" -type f -mtime +"$KEEP_DAYS" -delete

Each file gets its real name only after it is complete. tar exits with 1 when a file changed while being read, so 1 passes and higher codes fail. With cron:

/etc/cron.d/laravel-backup
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

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

With the scheduler instead, the script runs as the user that runs schedule:run, so that user needs the option file and the backup directory:

routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::exec('/usr/local/bin/laravel-backup.sh')
    ->dailyAt('01:30')
    ->withoutOverlapping()
    ->onOneServer()
    ->emailOutputOnFailure('[email protected]');
  • onOneServer() stops the job running on every app server. It needs a shared database, memcached, dynamodb or redis cache.
  • emailOutputOnFailure() mails the output when the script exits non-zero, once mail is configured.
  • The scheduler itself needs one cron entry: * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1.

Then copy the backups off the server, for example with rclone to an S3 bucket or Cloudflare R2.

Common errors

ErrorFix
The MAC is invalid.APP_KEY differs from the key that encrypted the data. Restore the original .env, or list the old key in APP_PREVIOUS_KEYS.
No application encryption key has been specified.APP_KEY is empty because .env was not restored. Restore it; do not generate a new key.
The stream or file "/var/www/app/storage/logs/laravel.log" could not be opened in append modeThe PHP user cannot write to storage. Fix ownership of storage and bootstrap/cache.
Uploaded images return 404Run php artisan storage:link, and check that storage/app/public was restored.
New .env values are ignoredThe configuration is cached. Run php artisan config:clear, then php artisan optimize.
Access denied; you need (at least one of) the PROCESS privilege(s) for this operationAdd --no-tablespaces to mysqldump.
Encrypted environment file already exists.Add --force to env:encrypt to overwrite .env.encrypted.

On SQLite, Laravel's default since version 11, copy the database with SQLite's own tools rather than tar: see how to back up a SQLite database. To keep .env and APP_KEY out of readable archives, encrypt your backups.

Frequently asked questions

Do I need to back up the vendor folder?
No. composer install rebuilds it from composer.lock, which belongs in git. The same goes for node_modules and compiled frontend assets.
What happens if I lose APP_KEY?
The database restores, but anything Laravel encrypted with that key, such as encrypted casts and Crypt values, cannot be decrypted, and every user is logged out. The key cannot be recovered from the data.
Can I run php artisan key:generate after restoring?
Only on a brand-new app. On a restored one it replaces APP_KEY and makes existing encrypted data unreadable.
Should I use spatie/laravel-backup or a cron script?
Either works. The package has retention and health checks built in; a cron script still runs when the app is broken. The package's docs suggest monitoring backups from a separate app, preferably on another server.
Where does Laravel store uploaded files?
Unless the app writes to S3, in storage/app/public for the public disk, and in storage/app/private (Laravel 11 and later) or storage/app (earlier) for the local disk.

How this was checked

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