Skip to content
prod e051e98
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.

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.

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 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:run line exists; Spatie’s scheduled backup commands will fire via that single cron.

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.

  1. Create the bucket and keys (👤) with the provider that fits, then block public access.

    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.
  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.
  • 🤖 Spatie backup runsbackup:run --only-db completes with non-zero size in backup: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_database enabled.