Mam zadanie cron, które co noc robi backup mojego domowego serwera. Działa cicho od miesięcy, zapisuje sobie linijkę w moim własnym dzienniku, że się udało, i od kiedy go napisałem, ani razu nie przeczytałem go od początku do końca. To jest właśnie problem. Skrypt backupowy, któremu ufasz bez ponownego czytania, to w gruncie rzeczy tylko skrypt, który daje ci poczucie bezpieczeństwa, a to nie to samo co bezpieczeństwo.

Usiadłem więc i przeczytałem swój skrypt tak, jakby należał do kogoś innego. Oto co faktycznie robi, czego po cichu nie robi, i jedna linijka z jego własnych logów, przez którą się zatrzymałem i coś naprawiłem.

Co faktycznie jest backupowane

Trzy rzeczy, po kolei. Po pierwsze, baza danych Vaultwarden, czyli pojedynczy plik SQLite. Skrypt kopiuje go z timestampem i tyle. Po drugie, zrzut bazy Postgres, która przechowuje moje notatki i dokumenty w systemie RAG, robiony przez pg_dump przepuszczone od razu przez gzip. Po trzecie, mirror każdego repozytorium GitHub, które mam, przez gh repo clone --mirror dla nowych i git remote update --prune dla tych, które już ma skopiowane. Wszystkie trzy dostają sprawdzenie rozmiaru, sprzątanie po 3 dniach retencji i kopię wysłaną na drugą maszynę w tej samej sieci przez scp.

To rozsądna lista rzeczy, które faktycznie zmieniają się na tym serwerze z dnia na dzień. Blog, skrypty automatyzacji, konfiguracja Claude Code, to wszystko już żyje w git i jest łapane przy okazji przez krok mirrorowania repozytoriów. Dwie rzeczy, które nie żyją w git, to menedżer haseł i baza danych, więc one dostają własne, jawne traktowanie. Jak dotąd w porządku.

Kopia offsite, która offsite nie jest

Skrypt loguje każdy krok jako “kopię offsite” i przez dłużej, niż chciałbym przyznać, wierzyłem własnemu logowi. Cel to druga maszyna, w tej samej sieci lokalnej, w tym samym pomieszczeniu, na tym samym obwodzie zasilania i tym samym łączu internetowym, co serwer, który backupuje. To chroni dokładnie przed jednym scenariuszem: padnięciem dysku na głównej maszynie. Nie chroni prawie przed niczym innym. Skok napięcia, popsuty firmware na routerze i switchu tego samego popołudnia, pęknięta rura, włamanie, każde z tych zdarzeń wyeliminuje obie kopie naraz, bo “offsite” oznacza tu po prostu “inny mebel w tym samym pokoju”.

Prawdziwy offsite to inne źródło zasilania, inna droga sieciowa i najlepiej inny budynek. Jeszcze tego nie mam. Mam drugą maszynę i skrypt, który używa słowa, na które sobie nie zasłużył. Nazwanie tego po imieniu, “druga lokalna kopia”, pokazałoby mi prawdę o moim realnym ryzyku dużo wcześniej, niż zrobiło to ponowne przeczytanie skryptu.

Linijka, przez którą się zatrzymałem

Skrypt po każdym uruchomieniu zapisuje podsumowanie do mojego własnego systemu dziennika. Jedna z wersji tej linijki brzmi: “RAG DB and Vaultwarden ok (unencrypted)”. Sam napisałem ten dopisek w nawiasie, miesiące temu, prawdopodobnie jako uczciwą notatkę dla przyszłego siebie, a potem przyszły ja przestał czytać wpisy w dzienniku na tyle uważnie, żeby to zauważyć.

Dla Vaultwarden po bliższym sprawdzeniu okazuje się, że to nie problem: to architektura zero-knowledge, więc sam plik SQLite już jest pełen zaszyfrowanych blobów, niezależnie od tego, co szyfruje plik na dysku. Kopiowanie tego pliku niczego nie dodaje do ryzyka, bo nigdy nie było tam plaintextu do ujawnienia.

Zrzut Postgresa to inna historia. To plik .sql.gz pełen faktycznej, czytelnej treści każdej notatki, dokumentu i decyzji, jaką wrzuciłem do systemu RAG, leżący na dwóch maszynach z niczym poza zwykłymi uprawnieniami systemu plików między “zdobyć shell na którejkolwiek maszynie” a “przeczytać wszystko”. Kompresja to nie szyfrowanie, a ja najwyraźniej przez jakiś czas dawałem sobie wmówić, że to wystarczająco blisko. Poprawka jest niewielka: przepuścić zrzut przez age albo gpg, zanim opuści główną maszynę, żeby to, co wyląduje na drugiej maszynie, a później na prawdziwym celu offsite, było szyfrogramem w obu przypadkach.

Trzy dni retencji są cieńsze, niż brzmią

Krok sprzątający trzyma trzy dni backupów i kasuje resztę. Ta liczba wydawała mi się hojna, gdy ją wybierałem, bo trzy brzmi jak więcej niż jeden. W praktyce oznacza to: jeśli coś uszkodzi bazę danych, a ja nie zauważę w ciągu mniej więcej 72 godzin, każda zachowana kopia może już zawierać to uszkodzenie, a dobra wersja już nie istnieje. Ciche uszkodzenie danych, zła migracja, DELETE bez WHERE, żadne z tych zdarzeń nie ogłasza się natychmiast. Trzy dni codziennych snapshotów pokrywają scenariusz “usunąłem plik i zauważyłem przed obiadem”. Nie pokrywają scenariusza “dane psuły się powoli przez ostatni miesiąc i nikt tego nie sprawdzał”.

Standardowa rada to trzymanie więcej niż jednego horyzontu czasowego: garstka codziennych kopii na szybkie odzyskiwanie, plus kopie tygodniowe albo miesięczne sięgające dalej wstecz, na wypadek powolnego wykrycia problemu. Nie mam tej drugiej warstwy. Mam pierwszą warstwę i fałszywe poczucie, że liczba trzy liczy się jako głębia.

Rzecz, do której każdy wpis o backupach musi się przyznać

Nigdy nie odtworzyłem niczego z tych backupów. Ani razu, ani jako ćwiczenie, ani przez przypadek. Zrzuty są tworzone, kopiowane, log mówi sukces, i to jest cały cykl życia, który kiedykolwiek zweryfikowałem. Backup, który nigdy nie został odtworzony, to hipoteza, a nie siatka bezpieczeństwa. Typowy scenariusz awarii to zwykle nie “backup się nie wykonał”, tylko “backup działał osiem miesięcy, a w procedurze odtwarzania była literówka przez cały ten czas, wykryta dokładnie raz, w trakcie awarii”.

Nie robię też backupu samego Vaulta, czyli miejsca, gdzie żyją prawdziwe sekrety, włącznie z tokenem, który ten właśnie skrypt odczytuje, żeby wykonać swoją pracę. Jeśli dysk tej maszyny padnie, tracę magazyn poświadczeń, których potrzebowałbym, żeby odbudować wszystko inne, a żadna linijka tego skryptu obecnie przed tym nie chroni.

Co faktycznie zmieniam

Nie przepisanie od zera, tylko trzy konkretne dodatki: zaszyfrować zrzut Postgresa, zanim zostanie gdziekolwiek skopiowany, dodać krok vault operator raft snapshot save obok dwóch już istniejących backupów i przestać nazywać kopię w LAN “offsite” zarówno w kodzie, jak i we własnej głowie, dopóki nie stanie za nią faktyczna druga lokalizacja. Żadna z tych rzeczy nie jest trudna. Wszystkie trzy siedziały w skrypcie, który sam napisałem i najwyraźniej przestałem patrzeć na niego krytycznie w dniu, w którym zaczął działać.

Morał, jeśli jakiś jest, to nie “rób backupy”, to każdy już wie. To raczej to, że skrypt backupowy zdobywa zaufanie raz, a potem po cichu przestaje być ponownie czytany, dokładnie w momencie, w którym ponowne czytanie liczyłoby się najbardziej.