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.
What to back up, and what to skip
| Path | Back up? | Why |
|---|---|---|
| Database | Yes | All application data. With the default database session driver, sessions too. |
storage/app | Yes | Files saved to the local and public disks: uploads, generated files. |
.env | Yes, separately and encrypted | APP_KEY and every credential. |
storage/logs | Optional | History, not needed to run. |
storage/framework | No | Compiled views, file cache and file sessions. Rebuilt; with file sessions, users log in again. |
bootstrap/cache | No | Rebuilt by php artisan optimize. |
vendor/, node_modules/, public/build | No | Rebuilt by composer install and npm run build. |
| Application code | In git | Back 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:
php artisan env:encryptThis 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
grep '^DB_' /var/www/app/.envDB_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:
install -d -m 700 /var/backups/laravelMySQL or MariaDB. Put the credentials in an option file so the password stays out of ps:
[client]
user=laravel
password="your-password-here"
host=127.0.0.1chmod 600 /etc/mysql/laravel-backup.cnfmysqldump --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:
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:
sqlite3 /var/www/app/database/database.sqlite ".backup /var/backups/laravel/app-db-$(date +%F).sqlite"Back up storage/app
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:
readlink -f /home/forge/example.com/current/.envIf 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.
composer require spatie/laravel-backupphp artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider" --tag=backup-configIn the published config/backup.php:
backup.source.files.includedefaults to the whole app, minusvendor,node_modulesandstorage/framework. So.envgoes into the zip.backup.source.databaseslists the connections to dump; by default the one inDB_CONNECTION.backup.destination.disksdefaults tolocal, the same server. Point it at an off-server disk.backup.passwordreadsBACKUP_ARCHIVE_PASSWORDand 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.
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:
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:
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.
php artisan env:decrypt --key=your-encryption-keyLoad the database, then the files:
gunzip -c /var/backups/laravel/app-db-2026-10-03.sql.gz | mysql --defaults-extra-file=/etc/mysql/laravel-backup.cnf laravelpg_restore -h 127.0.0.1 -U laravel -w --no-owner -d laravel /var/backups/laravel/app-db-2026-10-03.dumptar -xzf /var/backups/laravel/app-files-2026-10-03.tar.gz -C /var/www/appRecreate the public/storage symlink, which is not in git, and let the PHP user write where Laravel needs to:
php artisan storage:linkchown -R www-data:www-data storage bootstrap/cachephp artisan migrate:statusEvery 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:
php artisan optimizeFinally, add back what lives outside the app: the scheduler's cron entry and the process monitor for queue workers.
Verify the restore
php artisan db:show --countsThat 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:
curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/upThen 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:
#!/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" -deleteEach 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:
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>&1With the scheduler instead, the script runs as the user that runs schedule:run, so that user needs the option file and the backup directory:
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 shareddatabase,memcached,dynamodborrediscache.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
| Error | Fix |
|---|---|
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 mode | The PHP user cannot write to storage. Fix ownership of storage and bootstrap/cache. |
| Uploaded images return 404 | Run php artisan storage:link, and check that storage/app/public was restored. |
New .env values are ignored | The 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 operation | Add --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:
- Laravel 13.x: Encryption
- Laravel 13.x: Configuration (environment file encryption, config caching)
- Laravel 13.x: File Storage
- Laravel 13.x: Deployment
- Laravel 13.x: Task Scheduling
- Laravel 13.x: Database: Getting Started
- Laravel 13.x: Database: Migrations
- Laravel 13.x: Eloquent: Mutators & Casting (encrypted casts)
- Laravel 13.x: HTTP Session
- spatie/laravel-backup v10: Installation and setup
- spatie/laravel-backup v10: Requirements
- spatie/laravel-backup v10: Taking backups
- spatie/laravel-backup v10: Cleaning up old backups
- spatie/laravel-backup v10: Monitoring the health of all backups
- spatie/laravel-backup: default config/backup.php
- Laravel Forge: Deployments (zero-downtime layout, shared paths)
- Composer: Command-line interface (install)
- MySQL 8.4 Reference Manual: mysqldump
- PostgreSQL documentation: pg_dump
- SQLite: Command Line Shell