The backup that exists so the stack can be rebuilt. It captures what cannot be recreated from this repository — secrets, keys, certificates, the Keycloak identity database, operator-edited config — and deliberately nothing else.
For a deliberate reset of a working host see RECOVERY.md;
for restoring onto a replacement host see
analysis/RECOVERY.md.
In, roughly 23 MB uncompressed as of 2026-08-23:
homeserver/env/*.env |
all 41 Arcane/Dockge stack .env files |
homeserver/secrets/ |
secret files kept beside a stack rather than in its .env |
homeserver/wireguard/ |
wg0.conf including the private key |
homeserver/installer/ |
install-homeserver.conf — the installer's answers file, which exists only on the root filesystem a reinstall wipes |
homeserver/technitium/ |
hand-maintained Technitium DNS config |
homeserver/keycloak/keycloak.sql.gz |
pg_dump of the identity DB — realm, clients, client secrets, users |
homeserver/volumes/ |
dashboard-state, arcane-data, evebox-config, canarytokens-redis-data, es-importer-state |
vps/env/vps.env, vps/secrets/, vps/traefik/, vps/wireguard/ |
the VPS's entire config surface, including the Traefik origin certificates |
*/manifest/ |
host reference notes — disks, volumes, containers, WireGuard, nftables |
repo/docs/, repo/scripts/, repo/analysis/ |
this repository's runbooks and operational scripts |
Out, on purpose. These are bulk capture data, and a rebuilt stack does not need any of them to come back up (sizes measured live 2026-08-23):
es-data |
30 GB | Elasticsearch store — events, alerts |
state/elasticsearch-snapshots |
14 GB | the ES snapshot repository |
dionaea-lib |
90 GB | captured malware samples |
/var/dockge/sandbox |
105 GB | Windows sandbox golden images and ISOs |
ghidra_ollama_models |
18 GB | re-pullable model weights |
arcane-trivy-cache |
5.6 GB | rebuildable scanner cache |
reporter-data |
2.8 GB | generated reports |
ghidra_ghidra_projects |
158 MB | analysis output derived from payloads |
| Technitium query logs, stats DBs | varies | recompiled blocklist hits, DNS query log |
| sensor logs, PCAP, Filebeat registries |
The runbooks ride along on purpose. Without them a rebuild that starts from one of these sticks has every secret in the fleet and no instructions — the restore procedure, the stack start order, the disk layout and the Keycloak runbook would all live only in git, on a host you are trying to reach from a machine you have not rebuilt yet. It costs 3 MB.
Each destination also gets a plaintext RESTORE-README.txt beside the
archives. The runbooks are inside the archive, which is the wrong side of the
encryption if what you have forgotten is how to open it; that one file is
unencrypted, holds no secret, and carries the gpg -d command and the reading
order.
A stack restored from this comes back with its own identity and configuration intact and an empty event history. That is the trade. If captured data ever needs preserving, give it its own job with its own retention — do not couple it to this one. Coupling is precisely what broke the previous arrangement (see History below).
Three locations, all written by the workstation, which is the backup host:
| # | Location | Filesystem | Notes |
|---|---|---|---|
| 1 | /run/media/xore/<uuid>/apiary-backups |
ext4 (Crucial X8 USB) | udisks auto-mount — only present while plugged in |
| 2 | ~/apiary-backups |
XFS (internal) | always available |
| 3 | homeserver:/mnt/usb-recovery/apiary-backups |
ext4 (Samsung T7, label APIARY-BACKUP) |
mounted from fstab by UUID with nofail |
Location 3 was a Ventoy stick formatted exfat until 2026-08-23, mounted
read-only and absent from /etc/fstab — so every write to it failed and it
would not have survived a reboot. It is now a single ext4 partition with a
proper nofail fstab entry, which also means it preserves ownership and the
archive's 0600; exfat could express neither.
A destination that is unavailable is a warning, not a failure — one missing copy must not stop the other two. All three missing does fail the run.
Retention is 30 archives per destination, pruned oldest-first after each successful write.
Every archive is GPG symmetric AES256. It contains WireGuard private keys, TLS private keys, OIDC client secrets and the full Keycloak user database, and two of the three copies live on removable drives — plaintext there would mean anyone who walks off with a stick owns the fleet.
Symmetric with a passphrase, not a keypair, so that recovery depends on one string a human can keep in a password manager and nothing else. No key file that itself needs backing up, no keyring, no dependency on this repo:
gpg -d apiary-essentials-<stamp>.tar.gz.gpg | tar xzscripts/install-backup-essentials.sh generates the passphrase on first run,
stores it at /etc/apiary-backup.pass (0600, owned by the run user) and prints
it once. Save it somewhere other than the machine being backed up — a
passphrase that only exists on the dead host is not a passphrase.
BACKUP_ENCRYPT=0 writes plaintext archives. Only reasonable if all three
destinations are trusted, which today they are not.
If the passphrase file is missing but archives from a previous passphrase
already exist on any of the three destinations (local disk, local USB, or
the homeserver's USB), the installer refuses to mint a new one — that would
leave those archives permanently unreadable. An unreachable homeserver counts
as "unknown, refuse" rather than "none found", since failing closed is the
point of the guard. Confirm the old passphrase is saved in the password
manager, then either write it back to /etc/apiary-backup.pass yourself or
re-run with CONFIRM_NEW_PASSPHRASE=1 to accept minting a fresh one.
scripts/backup-essentials.sh # collect and fan out
scripts/backup-essentials.sh --dry-run # collect, print the tree and size, discard
scripts/backup-essentials.sh --list # what each destination currently holds
scripts/backup-essentials.sh --check # exit non-zero if the newest archive
# anywhere is missing or stale (>48h,
# override with MAX_AGE_HOURS)Nothing runs --check on a schedule today; wire it into whatever host health
sweep exists, or a separate timer, if the timer silently disappearing (as it
did after the #1609 rebuild — see #3016) needs to be caught automatically.
Requirements on the workstation: SSH aliases homeserver and vps working
(see ~/.ssh/config), passwordless sudo on the homeserver — the stack .env
files are root-owned and unreadable without it — and gpg.
The script only ever reads from the two servers.
sudo scripts/install-backup-essentials.shInstalls apiary-backup-essentials.timer — daily, ±30 min jitter,
Persistent=true so a run missed while the workstation was powered off fires
at the next boot instead of being skipped.
The installer copies the script to /usr/local/libexec/apiary-backup-essentials.sh
and points the unit there, so re-run the installer after a git pull to
pick up a new version.
That is the opposite of analysis/install-backup-timer.sh, which deliberately
runs backup-honeypot.sh in place — but that script tars up the very checkout
it lives in, so a copied-out version would drift from what it backs up.
backup-essentials.sh has no such coupling; it only talks to remote hosts.
It is also forced here. The backup host runs Rocky with SELinux enforcing,
where a systemd unit cannot exec a file labelled user_home_t:
avc: denied { execute } ... scontext=system_u:system_r:init_t:s0
tcontext=unconfined_u:object_r:user_home_t:s0
Running in place from ~/Github/APIARY fails with 203/EXEC. Anything under
/usr/local/libexec gets bin_t and works.
It runs as the workstation user (User=xore), not root — the SSH identity, the
~/.ssh/config aliases and the udisks USB mount all belong to that user. A
system unit with User= does not need lingering enabled, which a --user
timer would (Linger=no on this box).
systemctl start apiary-backup-essentials.service # run now
systemctl list-timers apiary-backup-essentials.timer # when next
journalctl -u apiary-backup-essentials.service # what happenedThe archive is a plain tar.gz behind a symmetric GPG envelope on purpose: the restore path must not need this repository, this script, or any tool that is not already on a rescue system.
gpg -d apiary-essentials-<stamp>.tar.gz.gpg | tar xz -C /tmp/restore
cat /tmp/restore/MANIFEST.txtThen, onto a rebuilt host:
Before any of the below, put homeserver/installer/*-install-homeserver.conf
back at the path you intend to pass to scripts/install-homeserver.sh --config.
The installer will not run without it, and it is not reconstructible from the
repository — install-homeserver.conf.example carries only placeholders.
- Stack
.envfiles. Copy eachhomeserver/env/<stack>.envback to/var/dockge/stacks/<stack>/.env. Restore ownership and mode (root:deploy-runner,0600for most). Never overwrite a live.envwholesale without reading it first — the same rule that applies to any edit on these hosts. - Secrets.
homeserver/secrets/<stack>/secrets/*back to the matching/var/dockge/stacks/<stack>/secrets/,0600. - WireGuard.
homeserver/wireguard/wg0.confto/etc/wireguard/,0600root. Reusing the original private key means the derived public key still matches the peer record on the far side, so the VPS needs no WireGuard change at all for the tunnel to come back. - Keycloak. Bring up
hp-keycloak-postgresalone, thengunzip -c keycloak/keycloak.sql.gz | docker exec -i hp-keycloak-postgres psql -U keycloak -d keycloak. The dump is--clean --if-exists, so it drops and recreates. Starthp-keycloakonly once the restore has finished. This is what saves the 8-client OIDC secret resync that a fresh realm import otherwise forces — the clients come back with their original secrets, matching the ones invps/secrets/oidc/. - Volumes. For each
homeserver/volumes/<name>.tar.gz, with the stack stopped, create the volume and unpack into it through a networkless container:Never unpack into a running container's volume.docker volume create <name> docker run --rm --network none -v <name>:/dst -v "$PWD/homeserver/volumes:/src:ro" \ busybox:1.36 tar -C /dst -xzf /src/<name>.tar.gz
- Technitium DNS.
homeserver/technitium/*back into the stack'sconfig/. Query logs and stats DBs are not in the archive by design — they rebuild themselves as the server runs. Re-apply the admin password and upstream (DoH/DoT) settings if the config dir was not captured. - VPS.
vps/env/vps.envto/root/vps/.env;vps/secrets/andvps/traefik/back to/root/vps/;vps/wireguard/wg0.confto/etc/wireguard/. The Traefik origin certificates matter — they are valid until 2028 and cannot be regenerated from this repo, which is whydeploy.ymlrefuses to overwritetraefik/on a normal deploy.
Then follow STACK-REBUILD.md's start order —
honeypot-elk healthy before honeypot-init, everything else after.
Verify with analysis/verify-stack.py — DASHBOARD_SERVICE_TOKEN from the
stack's .env; it judges source-health through dashboard-next's /bff
passthrough and exits nonzero when anything is wrong.
analysis/backup-honeypot.sh captures the same material on the homeserver
itself, into /opt/backups/honeypot/<stamp>/, on the backup-honeypot.timer
schedule. Same scope, same exclusions. It is faster to reach and useless if the
box dies; the two are complements, not alternatives.
Validate one with analysis/verify-backup.sh <directory>.
/var/benchmarks on the homeserver (sweep scripts, rosters, run-state
JSON, status markers — the same class of "irreplaceable, double-digit MB"
data #2985 lost once) is not in this backup's fan-out. It is out of
scope on purpose: everything in it either belongs in git (drivers, rosters —
enforced separately) or is small run-state that changes every few minutes
while a sweep is live, which does not suit a daily three-destination archive
cycle.
Mirror of record: ~/apiary-bench-snapshots/homeserver-workarea/ on
this workstation (hermes, 192.168.42.253) — not xps13 / 192.168.42.15,
which is retired and unroutable. Kept current by
scripts/mirror-benchmark-workarea.sh, pulled (never pushed — homeserver has
no SSH key back to hermes) every 2 hours by the
apiary-benchmark-mirror.timer/.service pair on this workstation.
scripts/mirror-benchmark-workarea.sh # pull now
systemctl list-timers apiary-benchmark-mirror.timerRestore is a plain reverse rsync — no GPG envelope, since this mirror holds scripts and JSON, not secrets:
rsync -a ~/apiary-bench-snapshots/homeserver-workarea/ <dest>Dry-run restore verified 2026-09-08 against a scratch directory: 5,489 files,
154 MB, transferred clean; a full non-dry-run copy into the same scratch
directory came back byte-identical to the source (diff -rq).
The Elasticsearch snapshot that analysis/backup-honeypot.sh used to take is
gone, for two reasons that happen to point the same way:
- It was the bulk ES data this backup is explicitly not meant to hold; the
honeypot-fsrepository it wrote into had reached 14 GB. - It had never once succeeded. Every scheduled run since the timer was
installed on 2026-08-16 failed with
curl: (22) ... error: 400from the snapshot API. That call sat underset -e, so the run aborted right there — the volume archives never ran, retention never ran, and the service sat infailed. Seven consecutive days of "backups" held one config tarball, an emptyvolumes/directory and a zero-byteelasticsearch-snapshot.json.
Also found and worth knowing: honeypot-keycloak/.env carries a full set of
RESTIC_* variables pointing at /mnt-2/apiary-keycloak, but that repository
directory does not exist, its password file (secrets/restic-password) does
not exist, restic is not installed on the homeserver and no unit references
it. It is dead configuration — no Keycloak restic backup has ever run. The
keycloak.sql.gz dump in both scripts here covers that gap.
All of the above is tracked in #1694.