Building a backup pipeline for my KeePass databases

"It's just a few password databases. I'll back them up to Google Drive."

Famous last words.

I set out to write a small background utility. Watch a folder, hash some files, copy them somewhere else if they change. The kind of thing that takes an afternoon. By the end of the day I'd built the tool, broken it twice, fixed it twice, and then spent longer reasoning about a circular dependency than I'd spent writing any of the actual code.

This is the story of both halves.


The problem

I have five KeePass databases. Four change constantly - passwords, TOTP seeds, new accounts. The fifth is the master database: it barely changes, but it holds passwords to the other four, cloud account credentials, and recovery codes. Treating all five the same way felt wrong from the start.


The first terrible idea

Originally everything lived together. Including the credentials for the cloud providers. Then I asked one question:

What happens if my laptop dies?

Easy. Download the latest databases from the cloud. Except - how do I log into the cloud? The credentials are inside the databases, which are in the cloud. A circular dependency.

Need database → Need cloud account → Need database

Recovery was impossible. Not because I lost data. Because I couldn't reach it.


Building the watcher

The brief was deliberately narrow: watch a directory for .kdbx files, keep timestamped, deduped local backups. No cloud, no sync, no GUI, no database. Just: did the file actually change, and if so, copy it somewhere safe with a timestamp.

I started in Python with watchdog and TOML config. Then I remembered the actual constraint: this needs to run with zero visible footprint - no taskbar icon, no console flash. That ruled out Python (needs a dependency install) and plain PowerShell (briefly flashes a window even hidden). The answer was already sitting in an older project - csc.exe, the C# compiler that ships with Windows, producing a /target:winexe binary with zero console, zero dependencies, zero install step.

The shape of the logic

  • A FileSystemWatcher on the source folder, filtered to *.kdbx
  • A per-file debounce timer, so a single save collapses multiple filesystem events into one check
  • A hash comparison against an in-memory baseline - if content's identical, do nothing
  • If it changed, snapshot all .kdbx files into a timestamped folder. A point-in-time backup of the whole set, not a diff
databaseCopies/
└── 07/
    └── 02/
        └── 154312/
            ├── db1.kdbx
            ├── db2.kdbx
            ├── db3.kdbx
            ├── db4.kdbx
            ├── db5.kdbx
            └── SHA256SUMS.txt

The folder hierarchy is MM/dd/HHmmss - zero-padded numbers, naturally chronological by string sort. No date parsing needed to find the most recent.

The manifest file (SHA256SUMS.txt) got added because I asked my own obvious question: there's no way to see the full hash anywhere. add one. The hash algorithm is configurable - swap to MD5 or SHA1 in config.ini and the manifest filename changes to match (MD5SUMS.txt, etc.).

Each hash in the manifest comes from the copied file, not the source original. This is deliberate: the manifest proves what actually landed in the snapshot folder, not what was in the source at copy time. A copy corrupted in transit - disk error, AV interference - would produce a different hash and be caught.

Bug one: redundant snapshots

I ran the build, closed it, ran it again. Two new snapshot folders, two minutes apart, no files changed in between. The cause: the baseline snapshot was unconditional. Every startup hashed and copied everything, because state lived in memory only and was lost on exit. Good principle (no JSON state file to go stale). Just incomplete - every restart looked like day one.

The fix: before copying, read the most recent snapshot's own manifest (SHA256SUMS.txt) and compare against current hashes. If they match, skip the copy and load those hashes into memory. The manifest I'd added for visibility turned out to solve this exact bug.

Bug two: double-click spawned a new instance

Run the .exe twice, get two independent watchers, two timer dictionaries, two processes racing to write the same log. The fix is a named mutex, checked before anything else:

bool isNewInstance;
SingleInstanceMutex = new Mutex(true, @"Global\kdbxWatchSingleInstance", out isNewInstance);
if (!isNewInstance) return;

Second launch detects the lock and exits before touching config or the log file. That exit happens before logging, on purpose - a second instance shouldn't race the first to append to the same file over "I got double-clicked."

Both bugs shared a root cause: I'd designed for the one-process, one-startup case and never asked what happens outside it. Restarting and double-clicking are normal use, from a slightly wider angle.

Pruning

After every snapshot, the watcher checks how many snapshot folders exist under databaseCopies/. If the count exceeds 15 (configurable via MaxSnapshots), the oldest folders are deleted. This keeps local disk usage bounded while the cloud retains everything.


Breaking the dependency

Once the tool worked, I added cloud redundancy - three providers for genuine independence. The actual config: Google Drive, Dropbox, and Koofr, all pre-existing rclone remotes. Then I hit the question that derailed the rest of the afternoon: the passwords for those accounts live inside the master database. If backups live inside those same accounts, losing local access locks you out of everything meant to save you.

This has a name - the bootstrap problem - and it shows up anywhere a system's recovery path depends on the system being recovered.

The fix was noticing a structural fact I'd been sitting on without realizing it mattered: the master database's password isn't written down anywhere, on any device or in any file. It's just remembered, and has been for years. That's already the strongest possible root of trust.

Once that's the anchor, the problem shrinks: not "how do I protect three cloud passwords," but "how do I make sure one specific file - the master database - is reachable from somewhere after losing everything local."

And one more property made the rest easy: the master database never changes. A six-month-old copy is as good as a five-minute-old one. No version skew, no "which copy is authoritative." That single fact makes it safe to scatter copies carelessly, without a sync schedule - something that would be dangerous with the four working databases underneath it, which do change and can go stale.

The recovery structure

The final design uses two genuinely different kinds of recovery path:

  1. Physical copies - the master database on USB drives, kept separately, deliberately not kept in sync (staleness doesn't matter)
  2. Identity-based recovery - one of the three cloud providers (Google) is recoverable via phone-based OTP across two independent phone numbers, without needing the stored password at all. A fundamentally different mechanism that doesn't loop back through the KeePass chain
  3. Acceptance - the residual risk of all the above failing simultaneously is small enough to stop optimizing against

The four subordinate databases are not kept on USB - they change too often, and a stale copy could be misleading. Their authoritative copies are the cloud backups, reachable once the master database is recovered via either path.


Why rclone copy and not rclone sync

This is the most important design decision in the uploader. rclone sync mirrors source to destination exactly, including deleting from the remote anything not present locally. Since the watcher enforces MaxSnapshots=15 locally, old snapshot folders get pruned from disk. Sync would replicate those deletions to the cloud, making it worthless as a long-term archive.

rclone copy only uploads what's missing. It never deletes. The cloud becomes an append-only archive of every snapshot that ever existed locally, regardless of local pruning.

rclone copy   → cloud grows indefinitely, local stays capped  ✅
rclone sync   → cloud mirrors local, pruning applies to both  ❌

Snapshots instead of versioning

Every snapshot contains all five databases - not just the one that triggered the copy. I don't care which database changed. I care about recovering an entire consistent state. Storage is cheap. Confusion during recovery isn't.

The debounce works like this:

Filesystem event → Wait 10 seconds → Hash the file → Compare hash
  → Actually changed? → Snapshot everything

Ten seconds absorbs write patterns from editors that save multiple times or write temp files then rename into place. If the file is still locked after the debounce (e.g. a long write), the watcher reschedules rather than failing.


The cloud is intentionally dumb

No bidirectional sync, no conflict resolution, no cloud versioning, no metadata. The cloud is storage. Nothing more.

KeePassXC → kdbxWatch.exe → databaseCopies/
  → Task Scheduler → kdbxPushToRemote.exe → rclone copy
    → Google Drive / Dropbox / Koofr

kdbxWatch runs continuously as a daemon (started at logon via Task Scheduler). kdbxPushToRemote is run-to-completion on a schedule - starts, syncs all three remotes sequentially, logs results, exits. Failed remotes are logged and skipped; the next scheduled run retries. rclone copy is idempotent, so already-uploaded folders are skipped.


Failure scenarios

Scenario Mitigation
SSD dies Recover from cloud
Google Drive disappears Dropbox still has a copy; Koofr still has a copy
Dropbox disappears Google and Koofr still have copies
Koofr disappears Google and Dropbox still have copies
Forget cloud passwords They're in the master database
Laptop dies Master database lives on USBs (separate location)
Database corrupted Restore yesterday's snapshot from local or cloud
Cloud syncs garbage Doesn't matter - authoritative history exists locally
Watcher starts twice Named mutex kills the duplicate before any I/O

Each question either changed the architecture or confirmed it didn't need changing.


Where I stopped

You can always build something more complicated. Incremental backups, deduplication, compression, databases, GUIs, notifications, Docker. None of those solved my problem. They solved different problems. So I stopped.

The code isn't clever. Two C# files totaling fewer than 600 lines, built with a compiler that's been on every Windows machine since 2010. The interesting part was discovering that a "simple backup" only stays simple after you've spent time thinking about everything that could go wrong. The weak point was never the .kdbx encryption - it was a redundancy plan that, followed one layer further than I initially looked, quietly closed a loop on itself.