4 · Off-server backups
Objective — get an automated daily backup landing off-server — the one observability task you can’t skip: assess what exists, wire Spatie Laravel Backup, and ship to an S3-compatible off-site destination (S3 / Spaces / B2 / R2) under one BACKUP_AWS_* prefix, honoring the 3-2-1 rule.
Background
Section titled “Background”A backup on the same server is not a backup. This page wires automated daily backups that ship off-server, the single observability task you cannot skip. Everything else in Observability is “should”; this is “must.”
flowchart LR DB[("MySQL database")] --> SB["spatie/laravel-backup<br/>daily 02:00 · weekly full"] SB --> Local["backup-local disk<br/>(fast restore drills)"] SB --> Off["backup-offsite disk<br/>S3 / Spaces / B2 / R2"] Off --> LC["Lifecycle: 30d standard → 90d IA → delete"] SB -. on failure .-> N["Email / webhook alert"]3-2-1 rule: 3 copies of the data, on 2 storage types, with 1 off-site. Sections 1–3 handle the Laravel side; section 4 handles the off-site destination.
1. Assess what already exists
Section titled “1. Assess what already exists”Many hosts ship monitoring scripts, and some CodeCanyon vendors bundle Spatie backup or Sentry. Inventory before adding tools so you don’t double up and create conflicting alert noise.
-
Inventory existing backup tooling.
Terminal window composer show | grep -i spatie/laravel-backupls config/backup.php 2>/dev/null && echo "config exists" || echo "no config"php artisan schedule:list | grep -i backup# Expected: shows whether Spatie backup, its config, or a backup schedule already exist- ✅ Existing backup state is recorded in
CLAUDE.local.mdunder a## Monitoring Stateheading.
- ✅ Existing backup state is recorded in
If present, configure on top of what exists; if not, follow the rest of this page as written.
2. Install + configure Spatie Laravel Backup
Section titled “2. Install + configure Spatie Laravel Backup”Install the package and point its source at the database (and only the uploads worth keeping).
-
Install the backup package + AWS SDK.
Terminal window composer require spatie/laravel-backup aws/aws-sdk-phpphp artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider"# Expected: package installs and config/backup.php is published- ✅
config/backup.phpexists.
- ✅
-
Set the source in
config/backup.php. DB-only is right for most CodeCanyon apps — 10–100× smaller than full-file backups, andstorage/app/publicis usually regeneratable. Pick full only if you have un-regeneratable uploads.'source' => ['files' => ['include' => [storage_path('app')], // user uploads; add base_path() for full-app'exclude' => [base_path('vendor'),base_path('node_modules'),storage_path('logs'),storage_path('framework/cache'),storage_path('app/backup-temp'),],],'databases' => ['mysql'],],'destination' => ['disks' => ['backup-local', 'backup-offsite'],],- ✅ The source targets the database; the destination lists both disks.
-
Define the local disk in
config/filesystems.php(the offsite disk comes in section 4).'backup-local' => ['driver' => 'local','root' => env('BACKUP_DISK_PATH', storage_path('app/backup-temp')),'throw' => false,],- ✅ The
backup-localdisk is defined, withBACKUP_DISK_PATHpointing outside the Deployercurrent/symlink (e.g./home/[SERVER_USER]/[PROJECT_NAME]-admin/backups) so backups survivedep deploy.
- ✅ The
3. Schedule, retention, notifications
Section titled “3. Schedule, retention, notifications”Run the backup on a schedule, bound how long copies live, and route failures somewhere a human sees them.
-
Schedule the backup commands in
app/Console/Kernel.php.$schedule->command('backup:clean')->dailyAt('03:00');$schedule->command('backup:run --only-db')->dailyAt('02:00');$schedule->command('backup:run')->weeklyOn(0, '02:30'); // full weekly$schedule->command('backup:monitor')->dailyAt('04:00');- ✅ Daily DB, weekly full, clean, and monitor are all scheduled.
-
Bound retention and route failure notifications in
config/backup.php.'cleanup' => ['default_strategy' => ['keep_all_backups_for_days' => 7,'keep_daily_backups_for_days' => 30,'keep_weekly_backups_for_weeks' => 8,'keep_monthly_backups_for_months' => 4,'delete_oldest_backups_when_using_more_megabytes_than' => 5000,]],'notifications' => ['mail' => ['to' => env('BACKUP_NOTIFICATION_EMAIL')]],- ✅ Retention is bounded and failure mail routes to
BACKUP_NOTIFICATION_EMAIL.
- ✅ Retention is bounded and failure mail routes to
-
Confirm the server cron drives the scheduler — installed once in Phase 5 (do not paste a second line here):
Terminal window ssh <deploy-alias> 'crontab -l 2>/dev/null | grep -c "schedule:run"' # must print exactly 1- ✅ Exactly one
schedule:runline exists; Spatie’s scheduled backup commands will fire via that single cron.
- ✅ Exactly one
Use the plain php binary (or Deployer’s {{bin/php}}) — never a hardcoded path like /opt/alt/php83/usr/bin/php. If shared hosting forces a specific PHP version, document the path in CLAUDE.local.md, not in config.
4. Wire the off-site destination (pick ONE)
Section titled “4. Wire the off-site destination (pick ONE)”All four providers are S3-compatible and share the BACKUP_AWS_* prefix, so switching later is an env edit — no code change.
-
Create the bucket and keys (👤) with the provider that fits, then block public access.
Provider Free tier Egress Best for AWS S3 5 GB / 12 mo $0.09/GB Existing AWS users, tight IAM, lifecycle rules DigitalOcean Spaces $5/mo · 250 GB 1 TB/mo free Flat, predictable pricing Backblaze B2 10 GB free 3× storage free Cheapest per-GB for large backups Cloudflare R2 10 GB free $0 egress Frequent restore drills, Cloudflare teams - ✅ A private bucket and access keys exist, keys saved to the password manager.
-
Add the common
.envblock — name buckets[PROJECT_NAME]-backups-[YYYY-MM](never a hardcoded name from another project).BACKUP_AWS_ACCESS_KEY_ID=...BACKUP_AWS_SECRET_ACCESS_KEY=...BACKUP_AWS_REGION=us-east-1BACKUP_AWS_BUCKET=[PROJECT_NAME]-backups-[YYYY-MM]BACKUP_AWS_ENDPOINT= # blank for AWS S3; set for Spaces / B2 / R2BACKUP_AWS_USE_PATH_STYLE=false # true for B2 / R2 and some S3-compatible providers- ✅ The
BACKUP_AWS_*vars are set with the project-scoped bucket name.
- ✅ The
-
Add the
backup-offsitedisk toconfig/filesystems.php(one disk serves all four — only the env values change).'backup-offsite' => ['driver' => 's3','key' => env('BACKUP_AWS_ACCESS_KEY_ID'),'secret' => env('BACKUP_AWS_SECRET_ACCESS_KEY'),'region' => env('BACKUP_AWS_REGION', 'us-east-1'),'bucket' => env('BACKUP_AWS_BUCKET'),'endpoint' => env('BACKUP_AWS_ENDPOINT'),'use_path_style_endpoint' => env('BACKUP_AWS_USE_PATH_STYLE', false),'visibility' => 'private','throw' => false,],- ✅ The
backup-offsiteS3 disk is defined and private.
- ✅ The
-
Apply the provider-specific endpoint values.
Provider Key differences AWS S3 Leave BACKUP_AWS_ENDPOINTblank. Block all public access; enable Versioning + SSE-S3.DO Spaces BACKUP_AWS_ENDPOINT=https://nyc3.digitaloceanspaces.com;REGION=nyc3; disable CDN, restrict listing.Backblaze B2 BACKUP_AWS_ENDPOINT=https://s3.us-west-002.backblazeb2.com;REGION=us-west-002;USE_PATH_STYLE=true.Cloudflare R2 BACKUP_AWS_ENDPOINT=https://[CF_ACCOUNT_ID].r2.cloudflarestorage.com;REGION=auto;USE_PATH_STYLE=true. Token creation is dashboard-only.- ✅ The endpoint / region / path-style values match the chosen provider.
Bucket lifecycle (universal)
Section titled “Bucket lifecycle (universal)”All four support S3-style lifecycle rules — apply this to bound cost:
| Age | Action |
|---|---|
| 0–30 days | Standard storage (fast restore) |
| 30–90 days | Transition to infrequent-access tier (S3 Standard-IA / B2 / R2 IA) |
| 90+ days | Delete |
5. Test + enable in the deploy pipeline
Section titled “5. Test + enable in the deploy pipeline”Prove a backup actually lands off-site, then turn on the pre-deploy database backup.
-
Run a test backup and confirm it appears.
Terminal window php artisan config:clearphp artisan backup:run --only-dbphp artisan backup:list# Verify in the cloud console: object exists under [APP_NAME]/[APP_ENV]/<date>.zip# Expected: backup:list shows a non-zero-size entry; the object is in the bucket- ✅ The test backup is listed locally and visible in the cloud console.
-
Enable the pre-deploy database backup by uncommenting in
deploy.php.before('deploy:symlink', 'deploy:backup_database');- ✅
deploy:backup_databaseruns before each deploy symlink.
- ✅
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 Existing state audited — monitoring/backup state recorded in
CLAUDE.local.md. - 🤖 Spatie backup runs —
backup:run --only-dbcompletes with non-zero size inbackup:list. - 🤖 Schedule + alerts set — daily + weekly schedule; server cron drives
schedule:run; failure notifications route somewhere. - 🔀 Off-site provider wired — one private bucket (👤 created),
[PROJECT_NAME]-backups-[YYYY-MM], lifecycle rule applied. - 🤖 Pipeline backup live — test backup visible in the cloud console;
deploy:backup_databaseenabled.