Backup target URLs¶
A target is somewhere backups are kept that is not this machine. See backups for what pushing one means; this page is the grammar and the options.
The URL¶
| Scheme | Form | Credential |
|---|---|---|
file |
file:///absolute/path |
none |
ssh |
ssh://user@host[:port]/absolute/path |
required |
s3 |
s3://bucket[/prefix] |
usually |
Three slashes in file://, because two would name a host. file://nas/backups
is refused rather than quietly interpreted: it is legal URL syntax that means
something under SMB, and accepting it here would write somewhere other than the
path you read in your own configuration.
A URL that carries a password is refused. It is stored in installation.yaml,
printed by morzer doctor, and quoted in error messages — so a password in one
is a password in your support tickets. Credentials go in a secret.
https:// is refused with a specific message: it is a release source scheme.
There is no standard way to push over HTTPS; use s3:// for an object store.
The credential document¶
One secret per target, holding a small YAML document. One document rather than one secret per field, because a target needs several values at once and three secrets that must be rotated together is three chances to rotate two of them.
morzer secret set backup_s3
morzer backup target add s3://acme-backups/demo --credentials backup_s3
| Field | Used by | Meaning |
|---|---|---|
access_key_id |
s3 |
object store access key |
secret_access_key |
s3 |
its secret half |
session_token |
s3 |
for temporary credentials |
region |
s3 |
defaults to us-east-1 |
endpoint |
s3 |
host, or a full https:// URL; defaults to AWS |
private_key |
ssh |
an OpenSSH private key, in PEM form |
passphrase |
ssh |
when private_key is encrypted |
known_hosts |
ssh |
the pinned host key, required |
Supplying an access key with no secret — or the reverse — is refused. Supplying neither is fine: that is an instance role.
What is always verified¶
TLS, for s3://. No flag disables it. A bare host in endpoint means TLS;
http:// is the only way to ask for plaintext and you have to write it out.
The host key, for ssh://. known_hosts is not optional and no flag skips
checking it. An impostor cannot read your backups — they are encrypted to your
own recipients — but it can accept every push and answer every listing, and you
would believe you had off-site backups you do not have.
The algorithms offered during the handshake are derived from what you pinned. A host with both an ed25519 and an RSA key offers whichever it prefers, so a pin covering only one of them would otherwise produce a mismatch against a server doing nothing wrong — a refusal indistinguishable from a real attack.
An ssh-rsa pin is accepted with rsa-sha2-256 and rsa-sha2-512 signatures,
because SHA-1 is refused by every current OpenSSH.
Object stores that are not AWS¶
s3:// speaks to anything that speaks the S3 API. Point endpoint at it:
| Store | endpoint |
|---|---|
| MinIO | minio.internal:9000, or http:// for a plaintext one |
| Cloudflare R2 | <account>.r2.cloudflarestorage.com |
| Backblaze B2 | s3.<region>.backblazeb2.com |
| Google Cloud Storage | storage.googleapis.com (interoperability mode) |
There is no native GCS adapter. Interoperability mode covers it, and a second large SDK for a second API waits until somebody needs a feature that mode lacks.
The bucket must already exist¶
The manager does not create buckets. A typo would silently make a new one and your backups would go somewhere nobody is watching, which is worse than the error you get instead.
Layout on a target¶
<path or prefix>/
20260804T174743Z/
backup.json plaintext
database.sql.age encrypted to your recipients
secrets.sops.yaml.age
The same layout on all three, and the same two rules everywhere:
- The manifest is written last. A push interrupted halfway leaves a directory nothing lists and nobody can restore from.
- The manifest is deleted first. A removal interrupted halfway leaves the same, rather than a backup that looks whole and is missing a component.
Only what the manifest names is uploaded. A backup directory can hold other files — an interrupted restore leaves decrypted components in a staging directory beside the encrypted ones — and copying those would put a plaintext database dump on a second machine.
Recovering an identity without downloading the backup¶
Every backup carries the deployment's identity, and reading it does not require fetching the archive around it:
morzer --product demo installation import --from-backup \
--target s3://backups.example/demo \
--credentials-file ./bucket.yaml \
--identity ~/recovery.key
That transfers the identity document and the backup's manifest, and nothing else — a few kilobytes rather than however large the data is. The manifest comes too because a named key is not a bound file: it records each component's digest over the stored bytes, so the manager can check that what it fetched belongs to the backup it asked for rather than to whatever someone put under that name.
--credentials-file exists for the circle a rebuilt machine is in: the bucket
credentials are in the secret state, the secret state is in the backup, and the
backup is in the bucket.
Hardening a bucket¶
Give the manager PutObject and GetObject, withhold DeleteObject, run with
--no-prune-remote, and let the bucket's own lifecycle rules do retention. The
manager then cannot delete a backup even if the host is compromised.
Withholding DeleteObject is necessary and not sufficient
On S3 a PutObject to an existing key replaces it. Credentials that can
write can therefore destroy history without ever calling delete, so this
bounds the damage rather than preventing it.
The property you actually want is immutability: Object Lock, or versioning with a lifecycle policy the backup credentials cannot alter. Without one of those, treat put+get as a speed bump rather than a guarantee.