Operations

Backup & restore

Disaster-recovery runbook for self-hosted Externa: what to copy, restore order, and downtime expectations. Pair with Upgrade before every release bump.

What to back up

AssetRequired?Notes
DatabaseYesAll CMS data, users, jobs tables
.envYesSecrets + config — store separately from public backups
storage/Yes (local disks)Uploads, zips, logs, framework files under storage/app
S3 / MinIO objectsIf FILES_DISK=s3Objects live in the bucket — use bucket versioning / lifecycle; still backup DB + .env
Code treeOptionalPrefer Git tag / Packagist release over tarring vendor/

Downtime: DB dump is consistent if you freeze writes (php artisan down or brief maintenance). File copy can run warm; restore expects matching DB + files.

Backup — databases

PostgreSQL

pg_dump -Fc -h "$DB_HOST" -U "$DB_USERNAME" -d "$DB_DATABASE" -f externa-$(date +%F).dump

MySQL / MariaDB

mysqldump -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" \
  --single-transaction --routines --triggers "$DB_DATABASE" \
  > externa-$(date +%F).sql

SQLite

php artisan down
cp database/database.sqlite "backups/database-$(date +%F).sqlite"
# also copy WAL/SHM if present while down
php artisan up

Backup — files and env

# from project root
tar -czf "backups/storage-$(date +%F).tar.gz" storage/app storage/logs
cp .env "backups/env-$(date +%F).env"   # keep offline / encrypted

Never commit backup tarballs or .env copies to git.

Optional script

Repo helper (core): scripts/backup.sh — dumps DB (when CLI tools exist) + tars storage/app into ./backups/ (gitignored). Review before cron.

./scripts/backup.sh

Docker Compose volumes

Local: externa_pgsql, externa_storage, … — see compose.yaml volumes:.

Prod: externa_prod_pgsql, externa_prod_storage, externa_prod_redis.

# example: snapshot Postgres volume via running service
docker compose -f compose.prod.yaml exec -T pgsql \
  pg_dump -U externa -d externa -Fc > externa-prod-$(date +%F).dump

# storage volume → tar from app container
docker compose -f compose.prod.yaml exec -T app \
  tar -czf - -C /var/www/html storage/app > storage-$(date +%F).tar.gz

Restore order

  1. Provision empty host / containers; restore .env (fix secrets if the incident was a breach).
  2. Restore database first.
  3. Restore storage/app (and link php artisan storage:link).
  4. composer install / image rebuild for the same app version as the dump.
  5. php artisan migrate --force only if upgrading past the dump’s schema — otherwise skip.
  6. php artisan optimize:clear; restart workers.
  7. Smoke: /health/ready, login, one collection, one file download.

Postgres restore

pg_restore -h "$DB_HOST" -U "$DB_USERNAME" -d "$DB_DATABASE" --clean --if-exists externa-YYYY-MM-DD.dump

MySQL restore

mysql -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < externa-YYYY-MM-DD.sql

SQLite restore

php artisan down
cp backups/database-YYYY-MM-DD.sqlite database/database.sqlite
php artisan up

S3 caveat

With FILES_DISK=s3, DB rows point at object keys. Backup the bucket (or rely on provider versioning) separately from the DB dump. Restoring an old DB against a newer bucket (or vice versa) yields missing objects.

Dry-run note

Operators should practice restore once on a non-prod copy before relying on backups for GA. Record date/host in your runbook when you do.

Previous
Upgrade