I have a cron job that backs up my home server every night. It has run quietly for months, it writes a line to my own logbook saying it succeeded, and I have never actually read it end to end since I wrote it. That last part is the problem. A backup script you trust without rereading is really just a script that makes you feel better, which is not the same thing.

So I sat down and read mine like it belonged to someone else. Here is what it does, what it quietly does not do, and the one line in its own log output that made me stop and fix something.

What it actually backs up

Three things, in order. First, the Vaultwarden database, which is a single SQLite file. The script copies it with a timestamp, done. Second, a Postgres dump of the RAG database that stores my notes and docs, taken with pg_dump piped straight into gzip. Third, a mirror of every GitHub repo I own, using gh repo clone --mirror for new ones and git remote update --prune for repos it already has a copy of. All three get a size check, a 3-day retention cleanup, and a copy pushed to a second machine on the same network over scp.

That’s a reasonable list for what actually changes day to day on this box. The blog, the automation scripts, the Claude Code config, all of that lives in git already and gets swept up by the repo mirror step for free. The two things that don’t live in git are the password vault and the database, so those get their own explicit treatment. Fine so far.

The offsite copy that isn’t

The script logs every step as “off-site copy” and I believed my own log output for longer than I’d like to admit. The destination is a second machine, on the same LAN, in the same room, on the same power circuit and the same internet connection as the server it’s backing up. That protects against exactly one failure mode: a disk dying on the primary box. It protects against approximately nothing else. A power surge, a router-and-switch-take-a-bad-firmware-update afternoon, a burst pipe, a break-in, all of those take out both copies at once, because “offsite” here just means “a different piece of furniture.”

Real offsite means a different power source, a different network path, and ideally a different building. I don’t have that set up yet. I have a second machine and a script that uses a word it hasn’t earned. Calling it what it is, “a second local copy,” would have told me the truth about my actual exposure a lot sooner than rereading the script eventually did.

The line that made me stop

The script writes a summary to my own diary system after every run. One version of that summary line reads: “RAG DB and Vaultwarden ok (unencrypted).” I wrote that parenthetical myself, months ago, presumably as an honest note to future me, and then future me stopped reading the diary entries closely enough to notice it.

For Vaultwarden this turns out to be fine on inspection: it’s a zero-knowledge design, so the SQLite file itself is already full of ciphertext blobs regardless of what encrypts the file on disk. Copying the file around doesn’t add exposure, because there was never any plaintext to expose in the first place.

The Postgres dump is a different story. That’s a .sql.gz file full of the actual, readable content of every note, doc, and decision I’ve fed into the RAG system, sitting on two machines with nothing but ordinary filesystem permissions between “get a shell on either box” and “read everything.” Compression is not encryption, and I’d apparently let myself believe it was close enough for a while. The fix is small: pipe the dump through age or gpg before it leaves the primary machine, so what lands on the second box and would land on a real offsite target later is ciphertext either way.

Three days of retention is thinner than it sounds

The cleanup step keeps three days of backups and deletes the rest. That number felt generous when I picked it, because three sounds like more than one. In practice it means: if something corrupts the database and I don’t notice within roughly 72 hours, every retained copy could already contain the corruption, and the good version is gone. Silent corruption, a bad migration, a DELETE without a WHERE clause, none of those announce themselves immediately. Three days of daily snapshots covers “I deleted a file and noticed by lunchtime.” It does not cover “the last month’s data was slowly getting mangled and nobody checked.”

The standard advice here is to keep more than one time horizon: a handful of daily copies for the fast recovery case, plus weekly or monthly copies further back for the slow-discovery case. I don’t have the second tier. I have the first tier and a false sense that quantity three counts as depth.

The thing every backup post has to admit

I have never restored from any of these backups. Not once, not as a drill, not by accident. The dumps get created, they get copied around, the log says success, and that is the entire lifecycle I’ve verified. A backup that has never been restored is a hypothesis, not a safety net. The failure mode isn’t usually “the backup didn’t run,” it’s “the backup ran for eight months and the restore procedure had a typo in it the whole time, discovered exactly once, during the incident.”

I also don’t back up Vault itself, which is where the actual secrets live, including the token this very script reads to do its job. If that box’s disk dies, I lose the vault holding the credentials I’d need to rebuild everything else, and no line in this script currently protects against that.

What I’m actually changing

Not a rewrite, three specific additions: encrypt the Postgres dump before it’s copied anywhere, add a vault operator raft snapshot save step alongside the two existing backups, and stop calling the LAN copy “offsite” in both the code and my own head until there’s an actual second location behind it. None of these are hard. All three were sitting in a script I wrote myself and apparently stopped looking at critically the day it started working.

The moral, if there is one, isn’t “back up your stuff,” everyone already knows that part. It’s that a backup script earns trust once and then quietly stops being reread, right around the point where rereading it would matter most.