Skip to content
Last updated July 3, 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 rebaselineReset the ransomware-detection baseline from the current source contents
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).

The CLI can watch for the signature of a ransomware attack in the files it is about to back up: previously-normal files that have suddenly turned into high-entropy blobs (i.e. encrypted by something that isn’t you). Available on all plans that use the backup CLI.

The check is a first-run-baseline / next-run-diff model. On the first run against a given source, the CLI records a small entropy fingerprint per file. On every subsequent run it recomputes the fingerprint and flags files that jumped from a “normal” entropy range into a “looks encrypted” range. If the share of suspicious files crosses your configured threshold, the run either warns loudly or refuses to ship — your choice.

The baseline is stored next to the CLI’s other state:

  • /var/lib/hostatlas-backup/ransomware-baseline-<config-stem>.json (root)
  • $XDG_STATE_HOME/hostatlas-backup/ransomware-baseline-<config-stem>.json (non-root)
ransomware_check:
mode: warn # off | warn | block
threshold_pct: 5.0
entropy_low: 6.0
entropy_high: 7.5
sample_bytes: 65536
ignore_ext:
- ".zip"
- ".gz"
- ".jpg"
- ".png"
- ".mp4"
baseline_ttl_days: 90
OptionDefaultPurpose
modewarnoff disables the check. warn still runs the backup but flags the run. block refuses to ship when the threshold trips.
threshold_pct5.0Percent of sampled files that must look suspicious before the check trips.
entropy_low6.0Files with baseline entropy ≤ this are treated as “normal” (text, source, small binaries).
entropy_high7.5Current entropy ≥ this is treated as “looks encrypted / compressed”.
sample_bytes65536How many bytes per file to sample when computing entropy. Bigger = slower + more accurate.
ignore_ext(see above)Extensions that are already high-entropy by nature (media, archives). Never flagged.
baseline_ttl_days90The baseline auto-expires after this many days — a stale baseline is worse than no baseline.
  • off — the check does not run. Nothing is written to the baseline, nothing appears in the run log.
  • warn — the check runs, results appear in the run log and dashboard. A tripped threshold marks the run as flagged but does not stop the backup. Recommended default.
  • block — same as warn, plus a tripped threshold aborts the upload. Use this only once you trust the baseline and your ignore-list.

If you legitimately mass-transformed files (rolled a new deploy, imported a large archive, re-encoded a media library) the baseline will look wrong on the next run. Reset it with:

Terminal window
hostatlas-backup rebaseline

This clears the current baseline file and treats the next run as the new first-run — no diff is produced, and a fresh fingerprint is captured.

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?