Skip to content
prod 352bb92
Browse

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:

  1. Assess what already exists — Many hosts ship monitoring scripts, and some CodeCanyon vendors bundle Spatie backup or Sentry.
  2. Install + configure Spatie Laravel Backup — Install the package and point its source at the database (and only the uploads worth keeping).
  3. Schedule, retention, notifications — Run the backup on a schedule, bound how long copies live, and route failures somewhere a human sees them.
  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.
  5. Test + enable in the deploy pipeline — Prove a backup actually lands off-site, then turn on the pre-deploy database backup.

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.

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.

  1. Inventory existing backup tooling.

    Terminal window
    composer show | grep -i spatie/laravel-backup
    ls 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.md under a ## Monitoring State heading.

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).

  1. Install the backup package + AWS SDK.

    Terminal window
    composer require spatie/laravel-backup aws/aws-sdk-php
    php artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider"
    # Expected: package installs and config/backup.php is published
    • config/backup.php exists.
  2. Set the source in config/backup.php. DB-only is right for most CodeCanyon apps — 10–100× smaller than full-file backups, and storage/app/public is 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.
  3. 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-local disk is defined, with BACKUP_DISK_PATH pointing outside the Deployer current/ symlink (e.g. /home/[SERVER_USER]/[PROJECT_NAME]-admin/backups) so backups survive dep deploy.
  4. Verify the DB dump binary exists before the first backup:run. Spatie Laravel Backup shells out to a DB dump tool (mysqldump by default) — if the binary isn’t on PATH, backup:run produces 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 is mariadb-dump (mysqldump was renamed); plain Herd / Homebrew / older Linux may give you mysqldump. 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 (no mysqldump), tell Spatie’s dumper where it lives so backup:run doesn’t fail looking for mysqldump:

    // 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 -v resolves a real dump binary; if it’s mariadb-dump-only, DB_DUMP_BINARY_PATH points Spatie at its directory (path recorded in CLAUDE.local.md, never hardcoded in committed config).

Run the backup on a schedule, bound how long copies live, and route failures somewhere a human sees them.

  1. 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.
  2. 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.
  3. 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.

  1. 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.

    ProviderFree tierEgressBest for
    AWS S35 GB / 12 mo$0.09/GBExisting AWS users, tight IAM, lifecycle rules
    DigitalOcean Spaces$5/mo · 250 GB1 TB/mo freeFlat, predictable pricing
    Backblaze B210 GB free3× storage freeCheapest per-GB for large backups
    Cloudflare R210 GB free$0 egressFrequent restore drills, Cloudflare teams
    • ✅ A private bucket and access keys exist, keys saved to the password manager.
  2. Add the common .env block — 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-1
    BACKUP_AWS_BUCKET=[PROJECT_NAME]-backups-[YYYY-MM]
    BACKUP_AWS_ENDPOINT= # blank for AWS S3; set for Spaces / B2 / R2
    BACKUP_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.
  3. Add the backup-offsite disk to config/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-offsite S3 disk is defined and private.
  4. Apply the provider-specific endpoint values.

    ProviderKey differences
    AWS S3Leave BACKUP_AWS_ENDPOINT blank. Block all public access; enable Versioning + SSE-S3.
    DO SpacesBACKUP_AWS_ENDPOINT=https://nyc3.digitaloceanspaces.com; REGION=nyc3; disable CDN, restrict listing.
    Backblaze B2BACKUP_AWS_ENDPOINT=https://s3.us-west-002.backblazeb2.com; REGION=us-west-002; USE_PATH_STYLE=true.
    Cloudflare R2BACKUP_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.

All four support S3-style lifecycle rules — apply this to bound cost:

AgeAction
0–30 daysStandard storage (fast restore)
30–90 daysTransition to infrequent-access tier (S3 Standard-IA / B2 / R2 IA)
90+ daysDelete

Prove a backup actually lands off-site, then turn on the pre-deploy database backup.

  1. Run a test backup and confirm it appears.

    Terminal window
    php artisan config:clear
    php artisan backup:run --only-db
    php 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-dump wasn’t found, fix that before trusting any schedule.
  2. Enable the pre-deploy database backup by uncommenting in deploy.php.

    before('deploy:symlink', 'deploy:backup_database');
    • deploy:backup_database runs before each deploy symlink.

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 resolvedcommand -v mariadb-dump || command -v mysqldump returns a real path (or DB_DUMP_BINARY_PATH points Spatie at it); no MariaDB-only host left with a missing mysqldump.
  • 🤖 Spatie backup runsbackup:run --only-db completes with non-zero size in backup: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_database enabled.