Why Every Side Project Should Have a Backup Plan (And How to Build One)
The unglamorous insurance policy that turns a disaster into an inconvenience

Contents
Every side project starts the same way: a burst of enthusiasm, a database, and absolutely no backups. This is fine, right up until the moment it is catastrophically not fine — the disk fills, the migration goes sideways, you rm -rf the wrong directory at midnight, or the VPS provider has a bad week and your droplet evaporates with it.
I have lost data this way. Not recently, because losing data once is an education you only need to pay for the once. The lesson was not “back things up more” — everyone knows that. The lesson was that a vague intention to back things up is worth precisely nothing, and only an automated, tested, restorable backup counts. So let’s build one that actually works for a small project, without enterprise budgets or enterprise faff.
What you’re actually protecting
Before any tooling, decide what matters. Side projects usually have three categories, and conflating them is how people back up the wrong thing:
- State you cannot regenerate — the database, user uploads, anything a human typed. This is sacred.
- State you can rebuild but would rather not — generated thumbnails, caches, search indexes.
- Code and config — already in Git. If it isn’t, that’s your first job, not your backup strategy’s.
You back up the first category religiously, the second only if it’s cheap, and the third by pushing to a remote you don’t also host yourself.
The discipline here is knowing which bucket each thing falls into before disaster, because under pressure everyone over-values the wrong data. The thumbnails feel important because they are visible; the database feels abstract because it is a file you never look at. But you can regenerate every thumbnail in an afternoon from originals you still have, whereas the row a user typed at 3 a.m. is gone forever if you lose it. Spend your backup budget — time, storage, and attention — in proportion to what is genuinely irreplaceable, and be honest that most of a running system is reconstructible from code plus data. That clarity is also what keeps the backup small enough to be cheap and fast enough to actually restore.
The 3-2-1 rule, scaled down
The old advice is 3-2-1: three copies, on two types of media, with one off-site. For a side project you don’t need a tape robot, but the off-site part is non-negotiable. A backup sitting on the same machine as the original is not a backup; it’s a copy waiting to die in the same fire.
In practice, for a single VPS or homelab box, that means: the live data, a local snapshot for fast restores, and an encrypted copy pushed somewhere else entirely — another machine, a friend’s NAS, or cheap object storage.
Off-site matters more than people give it credit for, and not only for fires. A ransomware run, a compromised host, a fat-fingered rm, or a provider suspending your account all take out everything on that machine at once, backups included if the backups live there too. The whole value of the off-site copy is that it is reachable by a different set of failures than the original. This is the same reliability instinct that makes me nervous about single points of failure elsewhere in a stack — the reason a Kubernetes cluster tends to fall over at 2 a.m. is usually that nothing had any headroom or redundancy, and a backup with no off-site leg is exactly that same bet made about your data.
Dump the database properly
The single most common mistake is copying a database’s files while it’s running and hoping for the best. Use the tool the database ships for exactly this. For Postgres:
| |
pg_dump gives you a consistent snapshot even while the app is live, which a file copy does not. Run something analogous for MySQL (mysqldump) or just copy the file for SQLite once the app is quiesced.
Two subtleties bite people here. First, that --format=custom flag is not decoration: it produces a compressed archive that pg_restore can restore selectively — a single table, or with parallel workers — whereas a plain SQL dump is an all-or-nothing text stream that is slower to restore and harder to work with. Take the custom format from day one. Second, for SQLite specifically, do not just cp the database file out from under a running application; with write-ahead logging enabled you can capture a torn, inconsistent copy. Use SQLite’s own .backup command, which takes a proper online snapshot:
| |
The general rule across every engine is the same one: let the database export itself with the tool built for the job, rather than treating its on-disk files as ordinary bytes you can copy while it is mid-write. A file copy of a live database is the classic way to end up with an archive that restores cleanly and then falls over the first time the application touches a half-written page.
Automate it, then push it off-site
A backup you have to remember to run is a backup you will forget to run. Hang it off cron and send the result somewhere else. I lean on restic for the off-site leg because it’s encrypted, deduplicated and dead simple:
| |
Encrypted at rest, deduplicated so fourteen daily dumps don’t cost fourteen times the space, and gone from the building.
Two things to get right the first time, both of which have bitten me. First, initialise the repository once before the cron job ever runs — restic init creates the encrypted structure, and your credentials and repository password belong in an environment file the job sources, not inline in the crontab where they leak into process listings and logs:
| |
Second, backing up forever fills the bucket. Add a retention pass so old snapshots age out on a policy instead of accumulating until the storage bill wakes you up:
| |
That keeps a week of dailies, a month of weeklies, and half a year of monthlies, then reclaims the space from everything older in one pass. Guard that RESTIC_PASSWORD with your life — restic encrypts client-side, which is exactly what you want against a nosy storage provider, but it also means that if you lose the password, the backup is unrecoverable ciphertext and no support ticket will save you. Store the password somewhere that is itself backed up and not only on the box you are protecting.
The part everyone skips: restore
Here is the uncomfortable truth. A backup you have never restored is not a backup — it’s a hope. I have seen perfectly diligent nightly jobs that produced corrupt, empty, or unrestorable archives for months, discovered only on the day they were needed.
The reason this step gets skipped is that it feels like paranoia right up until the day it isn’t, and by then it is too late to start caring. A nightly job that runs green for a year builds a false confidence that is worse than having no backup at all, because at least someone with no backup knows they are exposed. The failure I have seen most often is not a missing backup but a diligent one that had been producing subtly broken archives for months — a schema change nobody re-tested against, a permissions error that truncated the dump, a storage credential that silently expired — discovered only in the one moment it mattered. The restore drill converts that latent, invisible failure into a loud, harmless one you catch on an ordinary Tuesday.
So once a quarter, do the drill. Spin up a throwaway container, pull the latest backup, restore it, and check the data is actually there:
| |
If it restores and the row counts look right, you have a backup. If it doesn’t, you found out today instead of on the worst day of the project’s life.
When a restore fails, the causes are depressingly consistent, so it is worth knowing what to check. A truncated or zero-byte archive almost always means the backup script died halfway and cron swallowed the error — which is why set -euo pipefail at the top of the dump script matters, so a failed pg_dump aborts the run instead of piping an empty stream into gzip and cheerfully “succeeding”. A restore that errors on missing extensions or roles means you dumped the data but not the surrounding database objects; test against a genuinely empty database, not a pre-populated one, or you will paper over exactly the gap that bites you in a real recovery. And a restic restore that cannot find the repository is nearly always the credentials env file not being sourced by the cron shell, which has a stripped environment — a bug that stays invisible until the day you need the data and discover the nightly job has been silently failing for a month.
That last failure mode is the argument for one more cheap safeguard: monitor the backup itself. A job that stops running is worse than one that never existed, because it lulls you into false confidence. Have the script report success or failure somewhere you will actually notice — a ping to a dead-man’s-switch service, a line in a dashboard — so a run that quietly stops firing raises an alarm. It is the same principle behind pointing a self-hosted uptime monitor at everything that matters: the failure you never hear about is the one that hurts.
The honest verdict
Is all this overkill for a hobby app with four users? Slightly — and I’d still do it, because the cost is about an hour to set up and near-zero to run, while the cost of not having it is the project itself. You don’t need the full enterprise ceremony: skip the off-site leg only if you genuinely don’t mind starting over.
For anything with users, anything you’ve put real hours into, or anything you’d be gutted to lose, this is the cheapest insurance you’ll ever buy. Automate the dump, push it off the box, and actually test the restore. Set it up once, and then leave the future to look after itself. The version of you reading this calmly is doing a quiet favour for the version of you who, one day, at the worst possible moment, will be very, very glad it’s there.




