spatie/laravel-backup-server
Laravel Backup Server lets multiple Laravel apps automatically send backups to one dedicated server, building on laravel-backup for secure, centralized storage and management.
1. Stand up a dedicated backup server. This package is the receiving side. Install it into its own Laravel application (not in the apps being backed up), on a host with plenty of disk and a stable network path to every source.
composer require spatie/laravel-backup-server
php artisan vendor:publish --provider="Spatie\BackupServer\BackupServerServiceProvider"
php artisan migrate
Publish both the config and the migrations — the package owns tables for sources, backups and destinations, and nothing works until those are migrated.
2. Read config/backup-server.php first. This single file is where 90% of your day-to-day decisions live:
| Concern | What to look for |
|---|---|
| Where zips land | The backup disk (plus the temporary disk used while receiving an upload) |
| Retention | keep_daily_*, keep_weekly_*, keep_monthly_*, keep_yearly_*, delete_old_backups_after_days |
| Alerts | Notification mail/recipients |
| Long operations | Process timeout values for disk usage and size calculations |
| Identity | The server's own name shown in listings and notifications |
Make sure php artisan schedule:run is wired to cron and a queue worker is supervised before you create your first source — backups are dispatched as jobs, so a dead worker looks exactly like "the backup never happened."
3. Create your first source. A Source is one application that ships backups here. At minimum: a name, the host URL, and a cron expression describing when the server should ask that app for a backup. The source gets an API token — copy it into the client app's .env.
4. Wire up the client app. In the app being backed up, install spatie/laravel-backup and add the backup-server destination:
// config/backup.php in the client app
'destination' => [
'disks' => [
// backup-server destination (see the docs for the exact class in your version)
'backup-server' => [
'url' => env('BACKUP_SERVER_URL'),
'token' => env('BACKUP_SERVER_TOKEN'),
],
],
],
Then run one backup manually from the client and confirm it appears on the server via php artisan backup-server:list.
5. First real use case: a single production app, backed up nightly at 02:00, retained 7 daily / 4 weekly / 12 monthly, with a mail alert when a run fails. Get that boring loop green for a week before adding sources two through twenty.
One source per application, one cron expression per source. The per-source cron is the scheduling unit (since v2), so different apps can have genuinely different rhythms: high-churn SaaS at hourly, a brochure site weekly. Keep expressions in the source record rather than hard-coding them in the server's console kernel — it means adding an app is a data change, not a deploy.
Treat "adding a new app" as a repeatable checklist. Create source → copy token to the app's env → run backup:run manually on the client → confirm receipt with backup-server:list → verify the scheduled run actually fires the next night. Anything that skips the manual step tends to surface as a 401 or an oversized-upload error at 3am.
Sort the listing instead of writing dashboards. backup-server:list supports sorting on columns like backup size, youngest-backup size and storage used. Day to day, that command is your status page:
php artisan backup-server:list --sort=youngest_backup_age
Use it in a Monday-morning check, and grep it in a health endpoint:
// app monitoring endpoint
$stale = Source::all()->filter(
fn ($source) => $source->youngestBackup?->created_at?->lt(now()->subDay())
);
abort_if($stale->isNotEmpty(), 500, 'Stale backups: '.$stale->pluck('name')->implode(', '));
Queue discipline. Backup jobs are long-running: zip creation upload on the client, download and size calculation on the server. Give them a dedicated queue with a generous --timeout and --tries=1, so a retry storm doesn't collide with the next scheduled run. Never run them on the default queue shared with mail and notifications.
Retention as policy, not accident. Configure the daily/weekly/monthly/yearly tiers to express your actual recovery requirements, and schedule the cleanup command alongside the backups. A useful sanity rule: your oldest monthly backup should roughly match the age of your accounting data, not your disk size.
Replicate off the backup server. The package stores zips on a Laravel disk; point that disk at object storage (S3, R2, B2) or rsync it nightly to a second machine. A backup server that shares a failure domain with the app it protects is a false sense of security.
Write the restore runbook before you need it — and rehearse it. Download the zip, unzip, mysql < db-dump.sql, restore storage/, point a staging app at it. Do this once a quarter against a real source; the restore is the product, the backup is just the supply chain. Keep the runbook in the repo next to the config.
Use the pause-notifications feature for planned work. During a migration window where backups will legitimately fail or be skipped, pause notifications for that source (v4 feature) rather than teaching your team to ignore the alert mails. Unpausing is part of the migration checklist.
Customise by swapping models, not by forking. Extend the package's Source/Backup models (add relations, accessors, notification routing) and point the config at your subclasses. This is the intended extension point for things like per-source Slack channels or tagging sources with a customer/team.
Upload limits, not backup limits, will break you first. Large databases die on PHP upload_max_filesize / post_max_size and on nginx's client_max_body_size long before the package gives up. If a new source "backs up but nothing arrives," check these three settings and your web server's error log before touching package config.
Give the temporary disk room. Incoming backups are staged before being moved to the final destination, so peak usage is roughly incoming zip + retained zips. A disk that is 85% full is a backup outage waiting to happen; monitor it and alert early.
Watch the timeout knobs. Disk-usage and collection-size calculations got configurable timeouts in 3.1.x for a reason — big backup sets blow through defaults. The same goes for the disk usage command timeout. If backup-server:list hangs or reports stale sizes, raise these before assuming corruption.
Upgrade deliberately — two entries are load-bearing. 4.3.1 fixed oldest() deleting recent backups instead of old ones, which is the worst possible class of bug in this package. If you're on a pre-4.3.1 release with retention cleanup enabled, upgrade now and verify what is actually on disk. Version 4.3.0 also added the ability to name a source's backup directory; pick the name before the first backup, because renaming later leaves the old directory behind and your "new" source starts with an empty history.
Check both sides for token mismatches. A 401 on the client is almost always a stale BACKUP_SERVER_TOKEN compared to the source record (or a client pointed at staging instead of production). Rotate on the server, then update env and redeploy the client.
Cron expressions are evaluated on the server. Timezone mismatches between server and client make "nightly at 2am" mean something different than you think, and DST shifts can skip or double a run. Use a time where the ambiguity doesn't matter, and check the source's last-run timestamp, not your intuition.
Silent failure modes to check in order: failed jobs table → queue worker running → schedule:run in cron → source health/status in the listing → the source's own log. Most "backups stopped" reports are the queue, not the package.
Permissions, twice. The web server user (receiving the upload) and the queue worker user (moving/calculating) must both write to the same disk path. A mismatch shows up as an upload that succeeds and a file that never lands.
Enumerate what the package actually registers before you assume behaviour — routes, commands, gates and UI:
php artisan route:list | grep backup-server
php artisan list | grep backup-server
If the bundled dashboard is exposed, confirm it sits behind the authorization gate and is not publicly reachable on a host with production dumps on it. Also confirm that the whole server is behind TLS and, ideally, IP allow-listing for the client apps.
Don't hand-prune the database. Deleting rows from backups without deleting files (or vice versa) leaves orphaned zips silently eating disk. Let retention run, and if you must clean up out-of-band, do it against both the table and the disk deliberately.
Verify before trusting. The most valuable habit with this package is checking files on the destination, not rows in a table: list the backup directory, confirm timestamps advance nightly, and restore one archive a quarter.
How can I help you explore Laravel packages today?