Vortos
Backup

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 SecretsEnvelopeStreamCipher 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 overrode

Two invariants make "delete the only good copy" structurally impossible, not just unlikely:

  1. The most recent minKeepFloor artifacts are always kept, regardless of what the GFS buckets decide.
  2. The single most-recent artifact is never placed in delete — if a rule would select it, the engine moves it to refused with 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:retention

Object 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 deleted

ImmutabilityVerifier 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 replay

WalChain 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 %p

Containerized 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=backend

It 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=staging

Declaring 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:

config/backup.php
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:worker

Run 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-backup

Preflight 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=postgres

The 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=production

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

On this page