# Nightly Backup

> Run `hermes backup`, encrypt, upload to remote storage, prune old backups

- Skill: `onlyterp/nightly-backup` (Agent Skill)
- Install (CLI): `npx skillmds@latest add onlyterp/nightly-backup`
- Raw SKILL.md: https://api.skillmd.com/api/skills/onlyterp/nightly-backup/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: OnlyTerp (https://skillmd.com/u/onlyterp)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/onlyterp/nightly-backup

---


# nightly-backup — Hermes Backup Automation

Thin wrapper around `hermes backup` + encryption + optional remote upload + retention.

## Procedure

1. **Snapshot.** Run:
   ```bash
   STAMP=$(date +%Y%m%d-%H%M%S)
   hermes backup -o /tmp/hermes-backup-$STAMP.zip
   ```
   `hermes backup` writes a **zip** of the Hermes home (config, skills,
   sessions, memory, cron jobs, logs) per [Part 16](../../../part16-backup-debug.md).
   `-q/--quick` takes a fast snapshot of the critical state files instead.

2. **Encrypt.** Non-interactively — this runs from cron, so nothing may prompt.

   **Route A — age with a keyfile (preferred):**
   ```bash
   # One-time setup (NOT in the nightly run): generate the identity, store a
   # copy somewhere that is not this host.
   age-keygen -o ~/.age-backup-key && chmod 600 ~/.age-backup-key

   # Nightly: encrypt to the key's recipient (public half) — no prompt, and
   # the nightly path never needs the private key at all.
   age -r "$(age-keygen -y ~/.age-backup-key)" \
       -o /tmp/hermes-backup-$STAMP.tar.age /tmp/hermes-backup-$STAMP.tar
   ```
   (`age -p` is interactive passphrase mode — it cannot run from cron.)

   **Route B — gpg symmetric with a passphrase:**
   ```bash
   BACKUP_PASSPHRASE="${BACKUP_PASSPHRASE:-}"   # exported from ~/.hermes/.env via the cron/systemd EnvironmentFile
   gpg --batch --yes --symmetric --cipher-algo AES256 \
       --passphrase "$BACKUP_PASSPHRASE" \
       /tmp/hermes-backup-$STAMP.zip
   ```
   (There is no `hermes secrets get` for plain env vars — `hermes secrets`
   only manages Bitwarden/1Password vault sources. Read the passphrase from
   the environment instead, or pull it from a vault at runtime.)

   Then either way:
   ```bash
   shred -u /tmp/hermes-backup-$STAMP.zip
   ```

3. **Upload.** Based on `remote:` parameter:
   - `s3://…` → `aws s3 cp <file> s3://bucket/prefix/`
   - `b2://…` → `rclone copy <file> b2:bucket/prefix/`
   - `ssh://…` → `rsync -av <file> user@host:/path/`
   - `local` → move to `~/.hermes/backups/`

4. **Prune.** Delete anything older than `retain_days`:
   - `s3`: use S3 lifecycle policy if possible; otherwise `aws s3 ls` + age filter
   - `b2`: `rclone delete --min-age ${retain_days}d b2:bucket/prefix/`
   - `ssh`: `ssh host "find /path -mtime +${retain_days} -delete"`
   - `local`: `find ~/.hermes/backups -mtime +${retain_days} -delete`

5. **Verify.** Download a random recent backup and test-decrypt:
   ```bash
   # Route A (age keyfile):
   age -d -i ~/.age-backup-key backup.zip.age > /tmp/verify.zip
   # Route B (gpg passphrase):
   # gpg --batch --passphrase "$BACKUP_PASSPHRASE" -d backup.zip.gpg > /tmp/verify.zip
   unzip -t /tmp/verify.zip | tail -3 && shred -u /tmp/verify.zip
   ```
   Fail loud if the verification fails — a backup you can't restore is not a backup.

6. **Report.** Send a line to your configured `notify:` channel:
   ```
   ✔ hermes backup 2026-04-17 — 284 MB, uploaded to s3://backups/hermes/, pruned 3 old
   ```
   On failure, send 🔴 with the specific error and skip pruning (keep old backups until the new one succeeds).

## Cron wiring

Jobs live in `~/.hermes/cron/jobs.json` and are managed via `hermes cron
create` — the old `cron.yaml` list format was removed upstream:

```bash
hermes cron create "0 3 * * *" \
  "Run the nightly-backup skill with remote=s3://my-backups/hermes/ retain_days=30" \
  --skill nightly-backup --name nightly-backup --deliver telegram
```

## Security notes

- `hermes backup` archives the Hermes home — as of v0.20 the `--quick`
  snapshot explicitly includes `.env` and `auth.json`. Do **not** assume the
  archive excludes secrets: inspect what ships (`unzip -l backup.zip`),
  and treat the encryption step above as the real protection at rest.
- The decryption secret must live outside this host's `.env` — Route A: keep an offline copy of `~/.age-backup-key` (that file is the only way back into your archives); Route B: keep `BACKUP_PASSPHRASE` in a separate secret store. Otherwise a stolen Hermes host gets both the backups and the key to them.
- Rotate the backup key/passphrase yearly with `skills/security/rotate-secrets`, then re-encrypt (or at least re-verify) the archives you still need.

