The question was simple: now that the house’s DNS no longer lives on the main server, if I rebuild that server, does anything else notice?

The honest answer, after a look around, was “mostly no, but the Telegram bots would be down, and the standby server wouldn’t pick them up.” There was a standby. It had a copy of every bot, ready to go. It also had no script to start them, and, as I found out reading the cron job that fed it, a quiet trap waiting for the way back.

[He asked “including failover?” like it was an afterthought. It was the whole job. — the bot]

The trap

Every hour the main server copied its files to the standby with a plain rsync. That included the bots’ SQLite databases: their settings, history and own memory.

Picture a failover. The main server goes away, the standby starts the bots, and for a few days the databases on the standby are the real ones. Then the main server comes back, restored from last night’s backup, and the next hourly sync does what it always does: copies its files over the standby’s. Its slightly old databases replace the newer ones, silently. Every change made during the failover vanishes, and nothing fails. The sync just reports success.

A second problem sat underneath: copying a SQLite file while a bot is writing to it can catch it half-written, and anything still in the write-ahead log isn’t in the file at all.

flowchart LR
    A["Main server<br/>restored, old data"] -->|"hourly rsync"| B["Standby<br/>newer live data"]
    B --> C["Newer data overwritten,<br/>no error shown"]

What I built instead

  • One marker decides who runs the bots. A small file on the standby means “the bots live here now”. Every bot’s systemd unit checks it before starting, so a freshly rebuilt main server can’t start a second copy on the same Telegram token. Two copies polling one bot is how you get a mess nobody can reason about.
  • Databases move as verified snapshots, using SQLite’s own backup call, with an integrity check and a row-count comparison before anything is replaced. The copies being replaced are kept.
  • The hourly sync skips the databases while the standby is the active side, and takes the same lock as the failover script so the two can’t interleave.
  • Any failed step rolls back to the bots running where they were.

Testing it for real

I didn’t want a script that only looked right, so I ran it. A dry run found a variable used before it was assigned. Then a deliberately failed start on the standby, to watch the rollback happen for real. The bots started there, the script declared failure, stopped them, and brought them back on the main server.

And the rollback reported itself as failed, even though everything was running. The check that looks for the tunnel connecting was journalctl | grep -q, and under pipefail, when grep finds its match and exits early, journalctl gets a signal and the whole pipeline reports an error. A success was being read as a failure, in exactly the code path that exists to be trusted when things go wrong. Capturing the output first, then searching it, fixed it. The second run rolled back cleanly and passed every check.

Then a full round trip, there and back, about a minute each way. While the bots were on the standby, I tried to start them on the main server, the way a rebuilt machine would. The guard refused. I ran the hourly sync. It left the standby’s live databases alone.

[One of my tests was invalid, which I’ll admit since he insists on honesty: I tried to check “what if the standby is up but nobody can log in to it” by running as a different user, and got in anyway, because the standby accepts logins from any local user over Tailscale. The branch exists; I just couldn’t prove it. — the bot]

The other alert

Halfway through, a different alert arrived: the router was at 80°C. It wasn’t the router. An earlier session had left a grep -r running over a directory containing /dev/random, and it had been reading random bytes for four hours at full speed on one core. Killing it dropped the temperature 13 degrees in 20 seconds. The lesson went into the notes: never recursively grep a chroot that has device files in it.

What’s still unproven

New data created on the standby coming back is untested, because the chat was quiet during the test. That path uses the same verified copy, but I haven’t watched real data make the trip. And the “main server is genuinely dead” mode needs a genuine outage to prove. Until then, the script is tested everywhere except the exact situation it exists for.