Backup
Streaming database backups with envelope encryption, GFS retention with hard safety floors, an append-only catalog, and 3-2-1 replication.
Backup
vortos-backup exists because a backup you've never restored is a hope, not a backup. Every piece of this package is built around that idea: backups are streamed (never buffered to disk where they could be incomplete or tampered with), checksummed, cataloged in a way that can't be silently rewritten, retained according to rules that mathematically cannot delete your only good copy, and — critically — periodically restored for real in restore drills so "can we actually recover" is a tested fact, not an assumption.
3-2-1, enforced structurally
Three copies, two different media, one offsite. The catalog tracks a primary and secondary location per artifact; backup:replicate is the reconciliation loop that copies any artifact missing its secondary, and the catalog read model can tell you exactly which artifacts are currently under-replicated.
Backup targets — what gets dumped
BackupTargetInterface is the Ops Kit port for "knows how to produce a streamed dump of one database engine":
interface BackupTargetInterface extends DriverInterface
{
public function dump(BackupRequest $request): BackupStream;
public function engine(): DatabaseEngine;
}dump() carries a hard requirement in its contract: it must not buffer the whole dump in memory or write it to a tracked disk path first — bytes flow straight from the database engine's dump process to the consumer (the encryption stream, then the store). A multi-gigabyte database dump never sits as a temp file something could read, and a backup of a database too large for available RAM doesn't crash the host. postgres and mongo drivers ship in-core; anything else is a make:driver away.
Stores — where backups land
BackupStoreInterface is the matching port for storage. ObjectStoreBackupStore is the shipped driver, writing to any Object Store-backed target (S3-compatible or otherwise) — the backup pipeline never talks to a specific cloud SDK directly.
Envelope encryption
Every backup payload is encrypted before it leaves the process, using the same EnvelopeCipher pattern documented in Secrets — EnvelopeStreamCipher adapts it to a streaming interface so a multi-gigabyte dump is encrypted chunk-by-chunk rather than requiring the whole plaintext in memory at once. The data key is wrapped under a KeyProviderInterface (the same age off-host custody Secrets uses), so a stolen backup artifact is useless without the private identity, which never lives anywhere near the backup itself.
Retention — GFS with hard safety floors
RetentionPolicy::plan() implements grandfather-father-son retention: keep the most recent backup in each of the N most recent hourly/daily/weekly/monthly/yearly buckets, plus an absolute age cap.
$policy = new RetentionPolicy(
hourly: 0,
daily: 7,
weekly: 4,
monthly: 6,
yearly: 1,
maxAgeDays: 400,
minKeepFloor: 1,
);
$plan = $policy->plan($artifacts, now: new \DateTimeImmutable());
$plan->keep; // list<BackupArtifact> — retained
$plan->delete; // list<BackupArtifact> — eligible for deletion
$plan->refused; // entries a rule wanted to delete, but a safety floor overrodeTwo invariants make "delete the only good copy" structurally impossible, not just unlikely:
- The most recent
minKeepFloorartifacts are always kept, regardless of what the GFS buckets decide. - The single most-recent artifact is never placed in
delete— if a rule would select it, the engine moves it torefusedwith an explicit reason (floor_protected_most_recent) instead.
plan() is pure — same artifacts, same $now, same plan, every time, with zero I/O. This is what lets you unit-test a retention policy's behavior against years of synthetic history without touching a real store.
You declare the policy in config/backup.php (->retention(fn ($r) => $r->hourly(8)->daily(7)...)) — the constructor shown above is the underlying value object. Note the hourly default of 0: leave ->hourly(...) unset and the framework derives an hourly bucket from your declared backup cadence when it is sub-daily, so a 6-hourly schedule keeps its intra-day restore points instead of collapsing to one per day.
# see what would be deleted, change nothing
php bin/console backup:retention --plan-only
# apply
php bin/console backup:retentionObject lock — WORM retention
ObjectLockPolicy layers immutable, write-once-read-many storage on top of retention: an artifact under object lock cannot be deleted before its retention window expires, full stop — compliance mode means not even an account administrator can override it early; governance mode allows an explicit, audited exception.
$lock = new ObjectLockPolicy(mode: 'compliance', retentionDays: 90);
$lock->isWithinRetention($artifact->createdAt, now: new \DateTimeImmutable()); // true → cannot be deletedImmutabilityVerifier and ObjectLockProbe periodically confirm the underlying store's object lock is actually configured and active — catching a misconfiguration (lock silently disabled at the bucket level) before it matters, not after a ransomware incident proves it.
The catalog — append-only by construction
BackupCatalogRepositoryInterface::record() records a verified artifact exactly once; a duplicate id throws BackupAlreadyExistsException rather than overwriting. Immutability is enforced at the storage layer — a database trigger, not a convention someone could forget to follow in application code. This mirrors the same write-once guarantee Release's manifest repository and the audit hash chain use elsewhere in the framework.
Point-in-time recovery (PITR)
For Postgres, PostgresWalArchiver archives WAL segments continuously, and WalChain represents the ordered, contiguous sequence of segments needed to replay forward from a base backup to any point in time:
$chain = new WalChain(base: $baseBackup, segments: $walSegments);
$chain->replayable(); // segments at or after the base — the only ones that can actually replayWalChain is pure and detects a missing or non-contiguous segment before a restore is attempted, not midway through one. backup:wal-archive is the hook wired into Postgres's archive_command to push each completed segment as it's produced.
php bin/console backup:wal-archive %pContainerized PITR (Postgres in its own container)
archive_command = 'vortos backup:wal-archive %p' only works where the Vortos CLI (PHP) runs. A stock postgres:18-alpine image has no PHP, so use the recipe generator:
php bin/console vortos:backup:pitr:recipe --postgres-service=postgres --backend-service=backendIt emits a pure cp archive_command (archives WAL to a shared volume — no PHP in the DB image), a wal-shipper worker that runs in your app image and ships segments off-host via backup:wal-archive, a scheduled base-backup worker, and a docker-compose.pitr.yaml fragment wiring the shared volume. Use --dry-run to preview.
Restore drills are opt-in
DrillRunner and backup:drill register only when VORTOS_BACKUP_DRILL_DSN is set (an ephemeral-database endpoint). Without it, backup:drill stays visible and fails with remediation — installing vortos-backup no longer requires the DSN just to boot the console.
Default engine
Set VORTOS_BACKUP_ENGINE (postgres or mongo) to make --engine optional on backup:run / backup:doctor and in the scheduled job. Resolution is fail-closed: if neither --engine nor VORTOS_BACKUP_ENGINE is set, the command refuses rather than guessing which database to dump.
CLI
Every environment-scoped backup command takes --env, and it defaults to production — the same
canonical label the deploy and release manifests use. Backups are cataloged under this label, so
backup:list / backup:retention / backup:drill only see what backup:run wrote under the same
--env. Keep them consistent (the default already lines them up); only pass --env for a non-production
environment.
# preflight the backup toolchain before the first real backup (fail-closed)
php bin/console backup:doctor --engine=postgres --env=production
# run a backup — dump, store, verify, catalog
php bin/console backup:run --engine=postgres --env=production
# verify a cataloged backup's integrity without restoring it
php bin/console backup:verify <backup-id> --env=production
# list cataloged backups
php bin/console backup:list --engine=postgres --env=production
# reconcile 3-2-1: copy any artifact missing its secondary location
php bin/console backup:replicate
# run the containerized backup lifecycle (backup/retention/drill) on their crons
php bin/console vortos:backup:worker
# generate a host cron fragment instead (non-containerized hosts)
php bin/console backup:schedule
# operator-driven restore to a target database
php bin/console backup:restore <backup-id> --target=stagingDeclaring the lifecycle — config/backup.php
Everything — engine, store, when each verb runs (backup / retention / drill), the retention policy,
and alerting — is declared as configuration. The framework owns the runtime; you own only this file.
It is loaded like config/scheduler.php (base, then config/{env}/backup.php overrides it), and simply
returns a BackupConfig:
use Vortos\Backup\Config\BackupConfig;
return BackupConfig::create()
->engine('postgres')
->store('object-store')->keyPrefix('backups')
->schedule(fn ($s) => $s
->backup('0 */6 * * *', kind: 'logical_full') // every 6h
->retention('0 3 * * *') // prune daily at 03:00
->drill('0 4 * * 0')) // restore drill weekly
->retention(fn ($r) => $r
->hourly(8)->daily(7)->weekly(4)->monthly(6)->maxAgeDays(90))
->alerts(fn ($a) => $a->onFailure()->channel('slack'));No #[Scheduled] class, no CommandSpec, no DI override — all three cadences and the retention policy
are plain config. If you omit ->hourly(...) while declaring a sub-daily backup cadence, the hourly
bucket is derived from that cadence (about two days of restore points) so sub-daily backups are never
silently collapsed to one per day the moment retention runs.
Step-by-step: your first backup schedule
Declare the lifecycle in config/backup.php (see above).
Run it. On a lean, containerized deploy the framework provides the whole runtime — a single
long-running worker that fires the declared lifecycle on its crons (the containerized replacement for
host cron). It is single-flight, keeps a durable watermark so a restart never double-fires, backs off
and dead-man-alerts on repeated failure, and shuts down cleanly on SIGTERM:
php bin/console vortos:backup:workerRun it on a backup-role image that carries the DB client (pg_dump/pg_restore), not the lean app
color. Set VORTOS_BACKUP_WORKER_SUPERVISED=true on that image to have vortos-docker emit it as a
supervisord program. For a non-containerized host you can instead install a cron fragment — it now emits
the correct verb per schedule type (backup:run / backup:retention --apply / backup:drill):
php bin/console backup:schedule > /etc/cron.d/vortos-backupPreflight the toolchain — this fails closed if the engine's client binaries (pg_dump, pg_restore, pg_basebackup) are missing from PATH or older than the server major, turning a first-backup crash into an actionable check. In the reference stack backups run from a dedicated sidecar image that ships the matching PostgreSQL client, so run this there:
php bin/console backup:doctor --engine=postgresThe same toolchain probe also gates deploy:doctor (as backup.toolchain) whenever VORTOS_BACKUP_ENGINE is set, so a broken backup toolchain is caught before cutover rather than at the first real backup. Because backups run from a dedicated role image and not the lean deploy image, set VORTOS_BACKUP_TOOLCHAIN_EXTERNAL=true (or config/deploy.php ->backupToolchainExternal()) so that check passes informationally instead of demanding pg_dump inside the deploy image — the toolchain is still verified, by backup:doctor on the backup role's own image.
Run one manually to confirm the pipeline works end to end:
php bin/console backup:run --engine=postgres --env=productionVerify it landed in the catalog and passes integrity verification:
php bin/console backup:list --engine=postgres --env=production
php bin/console backup:verify <backup-id>Schedule a restore drill — the only way to know a backup is actually restorable is to restore it, on a schedule, automatically.