# DB backups → DO Spaces — 2026-05-11

Task 2.8 — autonomous single-commit run on `master` (local only).
Daily DB-only backup to a private DigitalOcean Spaces bucket, 30-day
flat retention, no encryption (private bucket), no notifications yet.

## Packages installed

| Package | Version |
| --- | --- |
| `spatie/laravel-backup` | **9.3.6** |
| `league/flysystem-aws-s3-v3` | **3.32.0** |

`spatie/laravel-backup` 9.3.6 is the highest stable release compatible
with PHP 8.2 (9.3.7+ requires PHP 8.3, 10.x requires 8.4).

Composer was invoked with `--ignore-platform-req=ext-pcntl` per Pam's
authorisation — local Windows PHP doesn't ship pcntl, but prod Linux
PHP does, so deploy-time `composer install` on the droplet will not
need the flag. `ext-zip` was enabled locally (line 962 of
`C:\laragon\bin\php\php-8.2\php.ini`, leading `;` stripped) so it
satisfies the platform requirement without ignore-flag noise.

## Files changed

```
 composer.json                            |   2 +
 composer.lock                            | 704 ++++++++++++++++++++++++++++-
 config/backup.php                        | new (220 lines after edits)
 config/filesystems.php                   |  13 +
 routes/console.php                       |   8 +
 resources/lang/vendor/backup/{31 files}  | new (notification i18n; inert
                                          | while notifications stay off)
```

No `.env` edits. No secrets in any committed file.

## The `backups` disk added to `config/filesystems.php` (verbatim)

```php
        // DigitalOcean Spaces destination for spatie/laravel-backup (Task 2.8).
        // Credentials come exclusively from env vars — never commit values.
        'backups' => [
            'driver'   => 's3',
            'key'      => env('BACKUP_S3_KEY'),
            'secret'   => env('BACKUP_S3_SECRET'),
            'region'   => env('BACKUP_S3_REGION'),
            'bucket'   => env('BACKUP_S3_BUCKET'),
            'endpoint' => env('BACKUP_S3_ENDPOINT'),
            'use_path_style_endpoint' => false,
            'throw'    => true,
        ],
```

## The scheduler lines added to `routes/console.php` (verbatim)

```php
use Illuminate\Support\Facades\Schedule;

// Task 2.8: daily DB backup to DO Spaces (disk 'backups'). Cleanup runs
// 10 minutes before the new dump so the 30-day window is enforced before
// we add to it. Requires the system cron to invoke `schedule:run` every
// minute — Pam confirms that line in root's crontab on the droplet.
Schedule::command('backup:clean')->daily()->at('01:50');
Schedule::command('backup:run --only-db')->daily()->at('02:00');
```

## What `config/backup.php` was changed from the publish defaults

| Setting | Default | Now | Why |
| --- | --- | --- | --- |
| `backup.source.files.include` | `[base_path()]` | `[]` | DB-only per brief. Belt-and-braces with `--only-db` on the scheduler. |
| `backup.destination.disks` | `['local']` | `['backups']` | Ship to DO Spaces. |
| `backup.password` | `env('BACKUP_ARCHIVE_PASSWORD')` | `null` | Encryption off; PDPA review decides later. |
| `backup.encryption` | `'default'` | `'default'` (string preserved) | spatie 9.x types `encryption` as non-null `string`; the runtime gate is `password === null`, so encryption is effectively OFF. |
| `notifications.notifications.*` | all `['mail']` | all `[]` | No mail/Slack wired yet. |
| `monitor_backups[0].disks` | `['local']` | `['backups']` | Health checks must point at the same disk we write to. |
| `cleanup.default_strategy.keep_all_backups_for_days` | `7` | `30` | Brief: flat 30-day window. |
| `cleanup.default_strategy.keep_daily_backups_for_days` | `16` | `0` | No tiered retention. |
| `cleanup.default_strategy.keep_weekly_backups_for_weeks` | `8` | `0` | No tiered retention. |
| `cleanup.default_strategy.keep_monthly_backups_for_months` | `4` | `0` | No tiered retention. |
| `cleanup.default_strategy.keep_yearly_backups_for_years` | `2` | `0` | No tiered retention. |
| `cleanup.default_strategy.delete_oldest_backups_when_using_more_megabytes_than` | `5000` | `null` | Age-only cleanup; the brief said no size cap. |

## Verification commands for Pam to run on prod after deploy

```bash
ssh root@mathapi.allgifted.com
cd /var/www/html/mathapi
git fetch origin && git pull
composer install --no-dev --optimize-autoloader
php artisan config:clear && php artisan config:cache
chown -R www-data:www-data storage bootstrap/cache

# Trigger a one-off DB-only backup; --disable-notifications is redundant
# (the config silences them all) but explicit is safer for first run.
php artisan backup:run --only-db --disable-notifications

# Should now show "Reachable: ✓  Healthy: ✓  # of backups: 1"
php artisan backup:list
```

Expected: `backup:run` ends with "Backup completed!" and a
`Laravel/Laravel-YYYY-MM-DD-HH-MM-SS.zip` appears under the bucket.
`backup:list` reports 1 healthy backup on the `backups` disk.

## Crontab check (TODO for Pam — do NOT run on prod from this session)

Confirm root's crontab on the droplet contains the schedule:run hook so
the in-Laravel scheduler actually fires. **I did not run this; CLAUDE.md
keeps me out of prod write-paths.**

```bash
# On prod, as root:
crontab -l | grep schedule:run
```

Expected line (or close variant):

```
* * * * * cd /var/www/html/mathapi && php artisan schedule:run >> /dev/null 2>&1
```

If absent, add via `crontab -e`. Without this, `Schedule::command(...)`
in `routes/console.php` never fires.

## Credential hygiene reminder

The DO Spaces key/secret pair that was pasted in chat earlier in the
session is now in the conversation log. **Rotate that pair before the
first prod run.** Generate a new key in the DigitalOcean control panel,
replace the `.env` values on the droplet, then delete the old key. The
rotation is a `.env` edit + `php artisan config:clear` + `systemctl
restart apache2`. No code redeploy needed.

## Test suite — no regression

```
Tests:    28 failed, 23 passed (87 assertions)
Duration: 1.28s
```

Identical PASS/FAIL split to the post-Sentry baseline. Every test class
that was passing still passes:

```
PASS  Tests\Unit\ExampleTest
PASS  Tests\Feature\AnswerEndpointAuthValidationTest
PASS  Tests\Feature\DiagnosticSubmitEndpointTest
PASS  Tests\Feature\HealthEndpointTest
PASS  Tests\Feature\IdempotencyMiddlewareTest
PASS  Tests\Feature\LivesPurchaseEndpointTest
PASS  Tests\Feature\RateLimitTest
```

The 5 FAIL classes (`MaxileServiceTest`, `AnswerGradingTest`,
`LivesRegenerationTest`, `OtpSignupRoleAssignmentTest`, and skeleton
`Feature\ExampleTest`) fail for pre-existing reasons (pdo_sqlite not
loaded on this Windows dev box; skeleton `/` route doesn't exist).
None of those failures touch backup code paths.

## Decisions defaulted on

1. **`encryption => 'default'` kept rather than `null`.** spatie 9.x
   types the field as non-null `string`, and the actual encryption gate
   is `password === null`. With password null, the string value is
   ignored at runtime. The published config's docstring saying "set
   null to disable" is stale for the 9.x line.

2. **31 i18n files under `resources/lang/vendor/backup/` were
   committed as-is**, even though notifications are off. They're the
   natural output of `vendor:publish --provider="Spatie\Backup\BackupServiceProvider"`
   per the brief, total ~144 KB, and removing them would require
   re-publishing if notifications get wired later. Net cost is small;
   net cost of re-publishing is friction.

3. **`monitor_backups[0].disks` updated from `['local']` to
   `['backups']`** even though the brief didn't speak to it.
   Health-check disks must match destination disks or `backup:list`
   reports the wrong thing.

4. **`delete_oldest_backups_when_using_more_megabytes_than` set to
   `null`** rather than a size cap. The brief said "30 days, no
   weekly/monthly tiers" — flat age window is cleanest; a silent size
   cap would compete with retention.

5. **`composer require --ignore-platform-req=ext-pcntl` applied to
   spatie only, not to `league/flysystem-aws-s3-v3`** — the latter has
   no platform requirements that fail here.

## Stop conditions

None hit on the resumed run. The first attempt at step 1 (before
`ext-zip` was enabled + pcntl ignore was authorised) hit the
"composer install fails" stop condition and the run was paused for
Pam's call; reported separately above the resume.
