HostAtlas / Tools / Backup
v0.2.0 Single static binary · Linux + macOS · works without an account

hostatlas-backup - a backup you can prove.

One binary, one YAML file. It streams your files or your database through compression and encryption straight to S3, SFTP or a local disk — and then it checks that what landed can actually be read back.

$ curl -sSL https://install.hostatlas.app/backup.sh | sudo bash

--user installs to $HOME/.local/bin without sudo. Documentation.

7.3 GB
production DB, shipped in 4m54s
333 MiB
what actually landed in R2
0
temp files on disk
db-prod-01 · hostatlas-backup run
$ hostatlas-backup run
preflight 7 checks · all pass
lock /var/lock/hostatlas-backup-backup.lock
 
postgres hostatlas_prod
pg_dump → zstd → age → s3://ha-backups/prod/
in 7.34 GB · out 333 MiB · 4m54s
verify deep · 2129 TOC entries
 
files /srv/uploads
ransomware_check clean · 12 480 files · 0.0%
in 2.10 GB · out 1.31 GB · 1m12s
 
retention keep_last 7 · deleted 1
heartbeat ?ok
→ recorded in HostAtlas: /backup-runs/8f2c… _
— The pipeline

Four stages, one stream.

Source to destination is a single io.Pipe chain. A 7 GB database never becomes a 7 GB temp file waiting for the upload to finish.

1. source

files · postgres · mysql

streamed, never staged

2. zstd

level 3, configurable 1–22

3.5 GiB → 333 MiB

3. age

X25519, encryption required

refuses to ship plaintext

4. destination

S3 · SFTP · local

atomic on all three

A pg_dump that dies halfway through surfaces as a pipeline error, not as a silently truncated archive — the subprocess is reaped on EOF and its exit code travels back up the read path.

— Ransomware check

It refuses to back up your encrypted files.

The moment ransomware runs, your next backup is the thing that overwrites the last good one. Preflight samples the entropy of every file and compares it to a baseline written on the first run. Files that used to be text and are suddenly noise get flagged.

Three modes

off · warn · block. In block mode, crossing the threshold fails preflight with exit code 2 — before a single byte ships. The last good backup stays the last good backup.

Baseline, not guesswork

Low entropy is under 6.0, high is over 7.5, and only a path that moved from one to the other counts. Already-compressed extensions are ignored. The baseline refreshes every 30 days, and rebaseline resets it after a legitimate rewrite like a deploy or a migration.

ransomware_check:
  mode: block
  threshold_pct: 5.0
  entropy_low: 6.0
  entropy_high: 7.5

A triggered run raises a critical incident and fires backup.ransomware.suspected through the normal notification pipeline — Slack, Discord, email, webhook, PagerDuty.

— Preflight

Seven checks, all of them, every time.

Every check runs even after one fails, so a misconfigured backup tells you everything that is wrong in one pass instead of one problem per attempt.

01

Source paths exist and are readable

02

Database credentials resolvable — env, or a 0600 password file

03

pg_dump / mysqldump on $PATH, and the server answers

04

Staging has the estimated source size × 1.2 free

05

Destination writable — proved with a probe write

06

Encryption recipient parses as a valid age key

07

Pre- and post-hook commands resolve on $PATH

Exit codes are distinct on purpose: 0 success, 1 pipeline failure, 2 preflight failure or lock collision — so a cron wrapper can tell a broken config from a broken run.

— Verification

"Backup OK" should mean someone tried to read it.

Writing bytes to a bucket proves the network worked. It does not prove the archive opens.

none

Opt out. The run finishes when the bytes are gone.

size default

HEAD the object and compare ContentLength against what was shipped. Roughly 100 ms.

deep

Pull the archive back, decrypt, decompress, then read it: tar entries counted, pg_dump TOC listed via pg_restore --list, mysqldump header checked. A failed verify errors the source and fires ?fail.

Deep verification needs the decryption identity on the host that runs it. Most backup-only hosts should not carry that key — a documented trade-off, not an oversight.

— Restore

The same pipeline, backwards.

$ hostatlas-backup restore --to /var/restore

Fetches the newest archive, decrypts, decompresses and unpacks it. --from targets a specific key, --identity overrides the key file. Tar extraction rejects absolute paths and ...

It tells you the next command

A Postgres archive lands as a .dump and prints the pg_restore line to run. MySQL lands as .sql with its mysql line. Restores are measured: 333 MiB from R2 back to a 23 GB dump took two minutes.

— Two ways to run it

It does not need us.

Standalone

No account, no sign-in, no network calls to HostAtlas. A YAML file and a cron entry. Run logs land on disk as JSON; the exit code tells your wrapper what happened.

HostAtlas-aware

Sign in once with login and each run pings a heartbeat and posts its log to the dashboard. Best effort by contract — if we are unreachable, your backup still succeeds and still says so.

Back it up, then prove it.

One binary. Encrypted by default, ransomware-aware, and honest about what it verified.

Read the docs Get early access