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.
Steps at a glance:
- Assess what already exists — Many hosts ship monitoring scripts, and some CodeCanyon vendors bundle Spatie backup or Sentry.
- Install + configure Spatie Laravel Backup — Install the package and point its source at the database (and only the uploads worth keeping).
- Schedule, retention, notifications — Run the backup on a schedule, bound how long copies live, and route failures somewhere a human sees them.
- 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. - Test + enable in the deploy pipeline — Prove a backup actually lands off-site, then turn on the pre-deploy database backup.
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
-
Verify the DB dump binary exists before the first
backup:run. Spatie Laravel Backup shells out to a DB dump tool (mysqldumpby default) — if the binary isn’t onPATH,backup:runproduces a zero-byte or errored dump and the “backup” is a lie. The name varies by host: Herd Pro and most modern hosts ship MariaDB, where the tool ismariadb-dump(mysqldumpwas renamed); plain Herd / Homebrew / older Linux may give youmysqldump. Check first, then point Spatie at whichever exists.Terminal window # Find the dump binary the host actually has (MariaDB → mariadb-dump; MySQL → mysqldump).DUMP=$(command -v mariadb-dump || command -v mysqldump); echo "${DUMP:-NONE}"# Expected: an absolute path (e.g. /opt/homebrew/.../mariadb-dump). If it prints NONE,# install a client (Herd Pro bundles MariaDB; otherwise `brew install mysql-client`# or the server's mariadb-client/mysql-client package) BEFORE running any backup.If the only binary is
mariadb-dump(nomysqldump), tell Spatie’s dumper where it lives sobackup:rundoesn’t fail looking formysqldump:// config/database.php — on the mysql connection (Spatie reads dump.dump_binary_path from here)'mysql' => [// ... existing connection config ...'dump' => [// Directory holding mariadb-dump/mysqldump — NOT the file. Leave unset if mysqldump// is already on PATH. On Herd Pro point at the MariaDB bin dir; on a server, the// package's bin dir. Document the exact path in CLAUDE.local.md, never a literal here.'dump_binary_path' => env('DB_DUMP_BINARY_PATH', ''),],],- ✅
command -vresolves a real dump binary; if it’smariadb-dump-only,DB_DUMP_BINARY_PATHpoints Spatie at its directory (path recorded inCLAUDE.local.md, never hardcoded in committed config).
- ✅
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 provider cron drives the scheduler — installed once in Phase 5 (do not paste a second line here):
Terminal window ssh <deploy-alias> 'test -x ~/domains/<domain>/Admin-Domain/scripts/laravel-schedule-run.sh && echo wrapper-ok'# Expected: wrapper-ok; provider/API or panel inventory shows one five-asterisk cron row calling that wrapper.- ✅ Exactly one scheduler row exists for this app/environment; Spatie’s scheduled backup commands will fire via that single wrapper cron.
Use the same confirmed deploy PHP binary recorded in Zaj-PROJECT.md / Deployer bin/php — not the login-shell php. On shared hosting that is often a versioned path such as /opt/alt/php83/usr/bin/php; keep the exact path in Zaj-PROJECT.md so backup cron, scheduler cron, Deployer, and web PHP stay aligned.
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. The agent may drive non-secret bucket settings when authorized; the user owns account authorization and secret key capture.
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 — a non-zero size here is the runtime proof the dump binary (section 2 step 4) actually resolved; a 0-byte entry means
mysqldump/mariadb-dumpwasn’t found, fix that before trusting any schedule.
- ✅ The test backup is listed locally and visible in the cloud console — a non-zero size here is the runtime proof the dump binary (section 2 step 4) actually resolved; a 0-byte entry means
-
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. - 🤖 Dump binary resolved —
command -v mariadb-dump || command -v mysqldumpreturns a real path (orDB_DUMP_BINARY_PATHpoints Spatie at it); no MariaDB-only host left with a missingmysqldump. - 🤖 Spatie backup runs —
backup:run --only-dbcompletes with non-zero size inbackup:list(0 bytes = dump binary not found). - 🤖 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.