Backup
hostatlas-backup is a single-file Go binary that turns any Linux or macOS host into a backup client. It reads a YAML config, streams your sources through zstd compression and age encryption, and ships the archive to an S3-compatible bucket, SFTP server, or local directory. HostAtlas integration is optional — every run pushes a full log to your dashboard when you’re logged in, but the CLI works standalone if you’d rather keep it offline.
Install
Section titled “Install”One-liner (recommended):
curl -sSL https://install.hostatlas.app/backup.sh | sudo bashThe installer detects your OS + architecture, downloads the matching binary from the install CDN, verifies its SHA-256 checksum, and drops it at /usr/local/bin/hostatlas-backup.
Options:
| Flag | Effect |
|---|---|
--version=X.Y.Z | Pin a specific release instead of latest |
--user | Install to $HOME/.local/bin — no sudo required |
--dir=<path> | Custom install directory |
--no-verify | Skip SHA-256 verification (not recommended) |
Manual install: grab the binary for your platform from https://install.hostatlas.app/backup/latest/:
hostatlas-backup-linux-amd64hostatlas-backup-linux-arm64hostatlas-backup-darwin-amd64hostatlas-backup-darwin-arm64
Every folder also ships a files.json with filename, size, and sha256 next to the binaries, and the top-level https://install.hostatlas.app/backup/version.json carries the same list under binaries[] so a client can resolve the right download in a single JSON fetch.
The CLI detects which mode you’re in by which files are present.
Standalone — no HostAtlas account required. The CLI backs up, encrypts, ships, retains, and logs locally at /var/log/hostatlas-backup/run-<UTC>.json. No pings, no dashboard link.
HostAtlas-aware — once hostatlas-backup login has run and auth.yml exists, every run also POSTs a full run log to the HostAtlas API. The run appears in the Backup Runs dashboard with per-source bytes, duration, verification result, and the verbatim JSON log. Adding a heartbeat: <slug> line to backup.yml additionally pings the slug on start / ok / fail so an alert fires the moment a run misses.
Both modes are best-effort against the HostAtlas API: if the API is unreachable, the actual backup still runs to completion, and the run is recorded locally.
First-run login
Section titled “First-run login”hostatlas-backup loginOpens a browser (or prints a URL for headless hosts), asks you to approve the CLI on auth.hostatlas.app, then self-mints a long-lived ha_* API key and writes it to:
/etc/hostatlas/backup/auth.ymlwhen running as root (mode 0600)$XDG_CONFIG_HOME/hostatlas/backup/auth.ymlotherwise
The key is revocable from Settings → API Keys in the HostAtlas dashboard at any time.
Commands
Section titled “Commands”| Command | Purpose |
|---|---|
hostatlas-backup login | OAuth Device-Code flow → self-mint a ha_* API key |
hostatlas-backup logout | Forget the stored API key |
hostatlas-backup heartbeat list | List all heartbeats on this account |
hostatlas-backup heartbeat new <name> | Create a heartbeat, print the slug + ping URL |
hostatlas-backup run | Execute all sources in backup.yml, ship the archive, retention pass |
hostatlas-backup run --dry-run | Pre-flight only — validate config, probe destination, print the plan, exit 0 |
hostatlas-backup restore --to <dir> | Restore the newest archive at the destination into <dir> |
hostatlas-backup restore --to <dir> --from <key> | Restore a specific archive by key |
hostatlas-backup restore --to <dir> --identity <file> | Override the config’s encryption.identity_file |
hostatlas-backup status | Last run summary + heartbeat status + destination snapshot |
hostatlas-backup version | Version + short commit SHA |
Global flags:
--config <path>— override the config path (default:/etc/hostatlas/backup/backup.ymlroot,$XDG_CONFIG_HOME/hostatlas/backup/backup.ymlnon-root)--debug— escalate log level to TRACE
Exit codes:
0— success1— pipeline failure (a source or destination errored during the run)2— pre-flight failure or lock collision (config problem, not a runtime problem — useful for ops scripts to distinguish)
Sources
Section titled “Sources”Pluggable interface (Name / Extension / Preflight / Stream). All three sources stream to the pipeline through io.Pipe — a multi-GB backup never materialises as a temp file on disk.
files — streaming tar with exclude globs. Never follows symlinks or mounts past the source root.
sources: - type: files name: webroot paths: - /var/www - /etc/nginx exclude: - "**/node_modules" - "**/vendor" - "**/.git" - "*.log"postgres — streams pg_dump --format=custom --no-owner --no-acl. Preflights with pg_isready and checks that pg_dump is in $PATH before starting a multi-hour dump. Password via env var or 0600 file.
sources: - type: postgres name: production-db host: 10.0.0.2 port: 5432 user: hostatlas password_env: PGPASSWORD # OR password_file: /etc/hostatlas/backup/pg-pass database: hostatlas command_timeout_seconds: 21600 # default 6h extra_args: - "--compress=0" # 11× smaller archive when zstd follows (see below)mysql / mariadb — streams mysqldump --single-transaction --quick --routines --triggers --events --hex-blob --set-gtid-purged=OFF. MYSQL_PWD is passed via subprocess env (never argv). Preflights with mysql -e 'SELECT 1'.
sources: - type: mysql name: production-db host: 10.0.0.2 user: backup password_file: /etc/hostatlas/backup/mysql-pass database: app_production command_timeout_seconds: 21600--compress=0 for Postgres
Section titled “--compress=0 for Postgres”pg_dump’s default is --compress=9 (zlib inside the custom-format dump). When zstd follows in the pipeline, that pre-compressed binary is basically incompressible — the zlib pass burns CPU AND the archive is bigger. Real measurement on a 7.3 GB HostAtlas production DB:
| Setting | Archive size |
|---|---|
| default pg_dump (zlib-9 inside) → zstd-3 → age | 3.5 GiB |
--compress=0 pg_dump → zstd-3 → age | 333 MiB (11× smaller) |
Add extra_args: ["--compress=0"] unless you specifically need zlib inside the dump format.
Destinations
Section titled “Destinations”Only one destination per config file. Run multiple configs if you need multiple destinations.
S3-compatible — works against Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage, MinIO, AWS S3, Storj. Uses minio-go with 16 MiB multipart parts. Atomicity is guaranteed by multipart-upload semantics, not a .partial+rename pattern — the object is invisible at the configured key until CompleteMultipartUpload fires.
destination: type: s3 endpoint: https://abc.r2.cloudflarestorage.com region: auto bucket: my-backups prefix: "hostatlas/{{ hostname }}/{{ date }}/" access_key_id: ... secret_access_key: ...SFTP — pkg/sftp over golang.org/x/crypto/ssh. Key or password auth. known_hosts verification is mandatory — there is no InsecureIgnoreHostKey path reachable from config. Atomic write via .partial + PosixRename (falls back to Rename if the server doesn’t support POSIX rename).
destination: type: sftp host: backups.example.com port: 22 user: backup identity_file: /root/.ssh/backup_ed25519 known_hosts_file: /root/.ssh/known_hosts path: /srv/backups/{{ hostname }}Local filesystem — same .partial + Rename atomic pattern. Useful for pre-shipping to a mounted NAS or a local staging directory that another process picks up.
destination: type: local path: /mnt/backups/{{ hostname }}Path templates support {{ hostname }} and {{ date }} (YYYY-MM-DD).
Encryption
Section titled “Encryption”Age encryption is mandatory. There is no code path that ships an unencrypted archive — a missing encryption block fails pre-flight.
encryption: type: age recipient: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # OR use an identity file (private key) as a recipient: # identity_file: /etc/hostatlas/backup/age.keyGenerate a keypair with:
age-keygen -o /etc/hostatlas/backup/age.keychmod 600 /etc/hostatlas/backup/age.key# Extract the public recipient line to paste into backup.yml:grep 'public key:' /etc/hostatlas/backup/age.keyKeep a copy of the private key off-host. If you lose the identity file you cannot restore. The CLI cannot recover from this — that is the point of the encryption.
Compression
Section titled “Compression”Zstd, single option:
compression: type: zstd level: 3 # default; range 1 (fastest) to 22 (smallest)Level 3 is the sweet spot for backup workloads. Bumping to 9 typically shrinks the archive another 5–10% at 3-4× the CPU cost — worth it when bandwidth is the bottleneck, not worth it when the backup window is.
Retention
Section titled “Retention”Currently only keep_last: N. The delete pass runs after the new backup uploaded cleanly, never the reverse — a failed upload can never eat the N-K backup.
retention: keep_last: 7Retention pattern-matches hostatlas-backup-*.zst.age under the configured prefix — objects you manually uploaded to the same bucket are never deletion candidates.
Pre-flight checks
Section titled “Pre-flight checks”Every check runs even if an earlier one fails, so a broken config surfaces all its problems in one pass instead of one-at-a-time:
- Source paths exist and are readable
- Database credentials resolve (env var present, or
password_fileexists and is 0600) - Source-specific:
pg_dumpin$PATH+pg_isreadyconnect /mysqldumpin$PATH+mysql -e 'SELECT 1' - Staging directory has ≥ estimated source size × 1.2 free
- Destination writable — S3:
HEADbucket + probePutObject+DeleteObject; SFTP: probe file open/close; local: probe write - Encryption recipient parses as a valid age recipient
- Pre/post hook commands resolve in
$PATH - Heartbeat slug reachable (warning only — never fails the run)
A failed pre-flight exits with code 2 (distinct from a runtime failure at code 1), pings the heartbeat with ?fail, and prints the offending check clearly.
hooks: pre: - "systemctl stop my-app" post: - "systemctl start my-app"Each command is run through /bin/sh -c with a per-command timeout (default 5 min, override in config). A failed pre-hook aborts the backup — this is deliberate, since a pre-hook that failed to stop the app leaves the backup in an inconsistent-source state. A failed post-hook is logged but does NOT mark the run failed — the backup itself succeeded, and turning post-hook failures into backup failures would incorrectly ping ?fail when the archive is actually safe.
Verification
Section titled “Verification”Optional but strongly recommended — turns “we wrote bytes” into “we tried the restore path”:
verification: mode: size # none | size (default) | deepsize — HEADs the uploaded object and confirms ContentLength matches what we shipped. ~100 ms. Catches destination-side truncation.
deep — downloads the archive back, age-decrypts, zstd-decompresses, then format-specific verification: .tar runs tar.NewReader and counts entries; .pgdump pipes through pg_restore --list - (reads the custom-format TOC without connecting to a DB); .sql runs a first-64KiB mysqldump-header smell test. A failed deep-verify flips the source result to errored and pings ?fail.
Deep mode needs encryption.identity_file on the backup host. Documented trade-off: most backup-only hosts should NOT carry the decryption identity. Multi-recipient encryption with a short-lived “verify recipient” is on the roadmap to close this gap.
Single-instance lock
Section titled “Single-instance lock”Every run and restore acquires flock(LOCK_EX | LOCK_NB) on /var/lock/hostatlas-backup-<config-stem>.lock (root) or $XDG_RUNTIME_DIR/hostatlas-backup-<config-stem>.lock (non-root). This prevents:
- Cron kicking off run #2 while run #1 is still going (three parallel
pg_dumps, racing.partialuploads, retention seeing inconsistent state) - Manual
runcolliding with the scheduled run - Two crashed runs both retrying at once
Crash-safe — the kernel releases the flock on process exit regardless of cause (crash, OOM, kill -9). If a collision happens the error message names the PID and RFC3339 start time of the current holder.
Different config files have distinct stems (backup.yml → backup, backup-prod.yaml → backup-prod), so a host can run multiple configs in parallel — the lock is per-config, not per-host.
Run log
Section titled “Run log”Every run writes a structured JSON log to disk:
/var/log/hostatlas-backup/run-<UTC-timestamp>.json(root)$XDG_STATE_HOME/hostatlas-backup/run-<UTC-timestamp>.json(non-root)
One run = one JSON object: pre-flight results, per-source breakdown (bytes-in / bytes-out / verification / duration), destination upload result, heartbeat result, hook results, retention deletes, total duration.
hostatlas-backup status reads the newest run log to show the last-run summary — no HostAtlas connectivity needed for that layer.
HostAtlas dashboard integration
Section titled “HostAtlas dashboard integration”When auth.yml is present, every run POSTs its full run log to POST /api/v1/cli/backup-runs after the backup has completed (success or failure). The run appears at /backup-runs in the dashboard with:
- KPIs (total / ok / failed) across the whole fleet
- Search by hostname or profile
- Status filter (ok / failed)
- Per-run detail page: destination card, per-source table (name / type / bytes-in / bytes-out / duration / verification badge / status), inline error row for failed sources, archive keys list, collapsible raw-JSON viewer
The push is best-effort by contract:
- HostAtlas unreachable / 5xx /
auth.ymlmissing → warning in the run log, backup result unchanged - Dry-runs skip the push entirely
- Cross-tenant
server_uuidlink is silently ignored — a misconfigured CLI keeps recording
If a run “didn’t appear in the dashboard” the answer is almost always: auth.yml missing, stale API key, or --dry-run was used. Never a failed backup that was hidden.
Config template
Section titled “Config template”Full-ish config for a Postgres backup to R2, with hooks + heartbeat:
schedule: "0 3 * * *" # optional — use systemd timer or cron if absent
sources: - type: postgres name: production-db host: 10.0.0.2 user: hostatlas password_file: /etc/hostatlas/backup/pg-pass database: hostatlas extra_args: - "--compress=0"
destination: type: s3 endpoint: https://abc.r2.cloudflarestorage.com region: auto bucket: hostatlas-backups prefix: "{{ hostname }}/{{ date }}/" access_key_id: ... secret_access_key: ...
encryption: type: age recipient: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
compression: type: zstd level: 3
retention: keep_last: 14
verification: mode: size
heartbeat: slug: hb_a3f9c8e2d5
hooks: pre: - "systemctl stop worker.service" post: - "systemctl start worker.service"Real-world validated
Section titled “Real-world validated”Against the HostAtlas production DB (7.3 GB on disk) shipped to Cloudflare R2 in June 2026:
- Backup run end-to-end (pg_dump → zstd → age → R2 multipart): 4m54s, archive 333 MiB
- Restore from R2 (Get → age decrypt → zstd decompress): 2m00s → 23 GB raw
.dump - Integrity via
pg_restore --list: 2129 TOC entries, 260 TABLE DATA blocks, 670 TimescaleDB hypertable chunks
Related
Section titled “Related”- Backup Runs dashboard — where CLI runs surface when logged in
- Agent — for full-fleet server monitoring; the agent has its own separate backup-monitoring feature (see the Backup Monitoring page) that watches file-level backups on the host, distinct from what
hostatlas-backupproduces