Automated Laravel Database Backups to S3 with spatie/laravel-backup

Set up automated Laravel database backups to S3 with spatie/laravel-backup: scoped IAM, an encrypted disk, scheduled runs, retention, and failure alerts.

Steven Richardson
Steven Richardson
· 8 min read

A Laravel app without an off-server backup is one bad migration or one dead disk away from a very bad week. I've inherited setups where a cron'd mysqldump wrote to the same box it was meant to protect — useless the moment that box died. This guide sets up automated Laravel database backups to S3 with spatie/laravel-backup, end to end: a scoped IAM user, an encrypted S3 disk, scheduled runs, retention that prunes itself, and alerts that fire the moment a backup fails.

Install spatie/laravel-backup and the S3 adapter#

Two Composer packages get you there: the backup package itself, and the Flysystem S3 adapter that Laravel's s3 disk driver depends on. Version 10 of the package needs PHP 8.4 and Laravel 12 or newer, and it won't run on Windows servers.

composer require spatie/laravel-backup
composer require league/flysystem-aws-s3-v3 "^3.0"

The package registers its own service provider. Publish the config file so you can point backups at S3 and tune retention:

php artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider" --tag=backup-config

One easy-to-miss requirement: the package shells out to mysqldump (or pg_dump for PostgreSQL) to create the dump. Make sure that binary is installed wherever the backup actually runs — your server, your container image, your CI runner — or the very first backup:run will fail.

Configure an encrypted S3 disk in filesystems.php#

Create a least-privilege IAM user scoped to a single bucket, then define an s3 disk that uses its credentials. Don't reuse an admin key here — if this one leaks, the blast radius should be one backup bucket and nothing else. Attach a policy that grants only what the package needs:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "LaravelBackupBucketAccess",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:ListBucket",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::my-app-backups",
        "arn:aws:s3:::my-app-backups/*"
      ]
    }
  ]
}

Note the two ARNs: s3:ListBucket acts on the bucket itself (the bare ARN), while the object actions need the /* ARN. DeleteObject matters more than it looks — backup:clean needs it to prune old archives, and leaving it out means retention silently fails while your bill keeps climbing.

Now define the disk in config/filesystems.php. The options array is passed through to the S3 client on every write, which is where you turn on server-side encryption:

// config/filesystems.php
'disks' => [
    // ...

    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
        'throw' => true, // surface S3 write failures instead of swallowing them

        // Applied to every upload — enables SSE-S3 (AES-256) at rest.
        'options' => [
            'ServerSideEncryption' => 'AES256',
        ],
    ],
],

Fill in the matching credentials in .env:

AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=eu-west-2
AWS_BUCKET=my-app-backups

If you already defined an s3 disk for something like direct-to-S3 file uploads, you can reuse it — just confirm the IAM user behind it is allowed to delete objects, or add a second disk with its own scoped user. Prefer aws:kms over AES256 if you want customer-managed keys; both keep the archive encrypted at rest in the bucket.

Point the backup config at the S3 disk#

By default backups are written to the local disk. Change the destination to your new s3 disk in config/backup.php, and confirm the connection you actually want dumped:

// config/backup.php
'backup' => [
    'name' => env('APP_NAME', 'laravel-backup'),

    'source' => [
        'databases' => [
            env('DB_CONNECTION', 'mysql'),
        ],
    ],

    'destination' => [
        'disks' => [
            's3',
        ],
    ],
],

That configuration will back up both files and the database. For most apps I only want the database off-site — the code already lives in Git — so I run php artisan backup:run --only-db and skip zipping the whole project. On MySQL with InnoDB tables, add use_single_transaction to the connection's dump block so the export doesn't lock your tables mid-request:

// config/database.php
'mysql' => [
    // ...
    'dump' => [
        'use_single_transaction',
        'timeout' => 60 * 5, // give large databases 5 minutes to dump
    ],
],

Schedule backup:run and backup:clean in routes/console.php#

Laravel 12 has no console kernel, so scheduled commands live in routes/console.php. Clean first so retention runs against the existing set, then take the fresh backup a little later:

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

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

Two things bite people here. First, avoid the 02:00–03:00 window: in regions with daylight-saving changes, that hour can run twice or vanish entirely, doubling or skipping a backup. Second, the scheduler only fires if something invokes schedule:run every minute. On Forge that's a checkbox; in a container you need your own cron or a dedicated scheduler process — the same discipline you'd apply to keeping health-check probes actually running rather than assuming they are.

Set retention rules and failure notifications#

Retention keeps storage predictable; notifications keep you honest. The default cleanup strategy thins backups from daily to weekly to monthly to yearly as they age, and it will never delete the most recent backup regardless of size or age:

// config/backup.php
'cleanup' => [
    'strategy' => \Spatie\Backup\Tasks\Cleanup\Strategies\DefaultStrategy::class,

    'default_strategy' => [
        'keep_all_backups_for_days' => 7,
        'keep_daily_backups_for_days' => 16,
        'keep_weekly_backups_for_weeks' => 8,
        'keep_monthly_backups_for_months' => 4,
        'keep_yearly_backups_for_years' => 2,
        // Trim the oldest backups once the bucket passes this size (MB). null = no cap.
        'delete_oldest_backups_when_using_more_megabytes_than' => 5000,
    ],
],

Out of the box the package emails you on every event — successes included. That's notification fatigue waiting to happen, and fatigue is how a real failure gets ignored. I mute the "everything's fine" messages and route only the failures, adding Slack so a broken backup shows up where the team actually looks:

// config/backup.php
'notifications' => [
    'notifications' => [
        \Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification::class => ['mail', 'slack'],
        \Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification::class => ['mail', 'slack'],
        \Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification::class => ['mail', 'slack'],
        \Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification::class => [],
        \Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification::class => [],
        \Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification::class => [],
    ],

    'mail' => [
        'to' => 'ops@example.com',
        'from' => [
            'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
            'name' => env('MAIL_FROM_NAME', 'Example'),
        ],
    ],

    'slack' => [
        'webhook_url' => env('BACKUP_SLACK_WEBHOOK_URL', ''),
        'channel' => null,
        'username' => null,
        'icon' => null,
    ],
],

Slack delivery needs one extra package:

composer require laravel/slack-notification-channel

Failure mail is the floor, not the ceiling. I also pipe these events into the same place I watch everything else, so a missed backup sits alongside my other production observability signals instead of rotting behind an inbox filter. And if you want the archive encrypted before it ever reaches S3, the package will do that too — set BACKUP_ARCHIVE_PASSWORD and leave encryption on default:

// config/backup.php
'backup' => [
    // ...
    'password' => env('BACKUP_ARCHIVE_PASSWORD'),
    'encryption' => 'default', // AES-256 when your PHP/zip build supports it
],

That's belt-and-braces on top of the disk's ServerSideEncryption: SSE protects the object at rest in the bucket, while the archive password protects the file itself if it ever leaves the bucket.

Run a test backup and verify the archive in S3#

A backup you've never restored is a hope, not a backup. Run one by hand, confirm the archive lands in S3, then prove you can pull it back:

php artisan backup:run --only-db

Ask the package what it can see on every configured disk — this reads straight from S3, so it's the fastest way to confirm the archive really arrived and isn't stuck in a temp directory:

php artisan backup:list

Then do the part everyone skips: download the newest archive, unzip it, and import the dump into a throwaway database to confirm it's actually restorable.

aws s3 cp s3://my-app-backups/Laravel/2026-07-02-01-30-00.zip ./restore-test.zip
unzip restore-test.zip -d restore-test
mysql -u root -p scratch_db < restore-test/db-dumps/mysql-database.sql

For ongoing peace of mind, flip verify_backup to true in config/backup.php. The package then re-opens each archive right after creating it to confirm it's a valid, non-empty zip — cheap insurance against a truncated upload that would otherwise sit undetected until the day you need it.

Wrapping Up#

You now have off-site, encrypted, self-pruning database backups with alerts when they break — the boring infrastructure that turns a disaster into a shrug. Schedule the restore drill too: put a monthly reminder on your calendar to actually import the latest archive, because untested backups fail exactly when you can't afford it. From here, make sure the environment running these backups is itself production-grade — my Forge vs Vapor breakdown covers how each handles the scheduler, and once traffic grows, scaling Laravel queues in production is the natural next hardening step.

FAQ#

How do I back up a Laravel database to Amazon S3?

Install spatie/laravel-backup together with league/flysystem-aws-s3-v3, then define an s3 disk in config/filesystems.php using an IAM user scoped to your bucket. Set that disk as the destination in config/backup.php and run php artisan backup:run --only-db. The package dumps the database with mysqldump, zips it, and uploads the archive to your bucket.

How do I schedule automatic backups in Laravel?

In Laravel 12 you schedule commands in routes/console.php using the Schedule facade. Add Schedule::command('backup:clean')->daily()->at('01:00') and Schedule::command('backup:run --only-db')->daily()->at('01:30'). Those definitions only run if your server invokes schedule:run every minute via cron, so confirm that cron entry (or your platform's scheduler) actually exists.

How do I set a backup retention policy with spatie/laravel-backup?

Retention lives in the cleanup.default_strategy block of config/backup.php. You control how long full, daily, weekly, monthly, and yearly backups are kept, plus a delete_oldest_backups_when_using_more_megabytes_than cap for total storage. The backup:clean command applies these rules, and the default strategy never deletes your most recent backup no matter what.

How do I get notified when a Laravel backup fails?

Map the failure notification classes — such as BackupHasFailedNotification and CleanupHasFailedNotification — to the mail and slack channels in the notifications block of config/backup.php. Slack delivery requires the laravel/slack-notification-channel package and a webhook URL. Set the success and healthy notifications to an empty array so you only hear about real problems.

Can spatie/laravel-backup encrypt the backup archive?

Yes. Set a value for the password key in config/backup.php (via a BACKUP_ARCHIVE_PASSWORD env variable) and leave encryption on default, which uses AES-256 when your PHP and zip build support it. This encrypts the zip archive itself, which is separate from S3 server-side encryption — the archive password travels with the file, while SSE only protects the object while it sits in the bucket.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.