Skip to content
Last updated July 2, 2026

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.

One-liner (recommended):

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

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

FlagEffect
--version=X.Y.ZPin a specific release instead of latest
--userInstall to $HOME/.local/bin — no sudo required
--dir=<path>Custom install directory
--no-verifySkip SHA-256 verification (not recommended)

Manual install: grab the binary for your platform from https://install.hostatlas.app/backup/latest/:

  • hostatlas-backup-linux-amd64
  • hostatlas-backup-linux-arm64
  • hostatlas-backup-darwin-amd64
  • hostatlas-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.

Terminal window
hostatlas-backup login

Opens 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.yml when running as root (mode 0600)
  • $XDG_CONFIG_HOME/hostatlas/backup/auth.yml otherwise

The key is revocable from Settings → API Keys in the HostAtlas dashboard at any time.

CommandPurpose
hostatlas-backup loginOAuth Device-Code flow → self-mint a ha_* API key
hostatlas-backup logoutForget the stored API key
hostatlas-backup heartbeat listList all heartbeats on this account
hostatlas-backup heartbeat new <name>Create a heartbeat, print the slug + ping URL
hostatlas-backup runExecute all sources in backup.yml, ship the archive, retention pass
hostatlas-backup run --dry-runPre-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 statusLast run summary + heartbeat status + destination snapshot
hostatlas-backup versionVersion + short commit SHA

Global flags:

  • --config <path> — override the config path (default: /etc/hostatlas/backup/backup.yml root, $XDG_CONFIG_HOME/hostatlas/backup/backup.yml non-root)
  • --debug — escalate log level to TRACE

Exit codes:

  • 0 — success
  • 1 — 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)

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

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:

SettingArchive size
default pg_dump (zlib-9 inside) → zstd-3 → age3.5 GiB
--compress=0 pg_dump → zstd-3 → age333 MiB (11× smaller)

Add extra_args: ["--compress=0"] unless you specifically need zlib inside the dump format.

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

SFTPpkg/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).

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

Generate a keypair with:

Terminal window
age-keygen -o /etc/hostatlas/backup/age.key
chmod 600 /etc/hostatlas/backup/age.key
# Extract the public recipient line to paste into backup.yml:
grep 'public key:' /etc/hostatlas/backup/age.key

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

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.

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

Retention pattern-matches hostatlas-backup-*.zst.age under the configured prefix — objects you manually uploaded to the same bucket are never deletion candidates.

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:

  1. Source paths exist and are readable
  2. Database credentials resolve (env var present, or password_file exists and is 0600)
  3. Source-specific: pg_dump in $PATH + pg_isready connect / mysqldump in $PATH + mysql -e 'SELECT 1'
  4. Staging directory has ≥ estimated source size × 1.2 free
  5. Destination writable — S3: HEAD bucket + probe PutObject + DeleteObject; SFTP: probe file open/close; local: probe write
  6. Encryption recipient parses as a valid age recipient
  7. Pre/post hook commands resolve in $PATH
  8. 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.

Optional but strongly recommended — turns “we wrote bytes” into “we tried the restore path”:

verification:
mode: size # none | size (default) | deep

size — 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.

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 .partial uploads, retention seeing inconsistent state)
  • Manual run colliding 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.ymlbackup, backup-prod.yamlbackup-prod), so a host can run multiple configs in parallel — the lock is per-config, not per-host.

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.

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.yml missing → warning in the run log, backup result unchanged
  • Dry-runs skip the push entirely
  • Cross-tenant server_uuid link 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.

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"

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
  • 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-backup produces
Was this page helpful?