Your code is on GitHub. Your dependencies reinstall from package.json. The built site rebuilds in
ninety seconds. All of that survives your laptop going in a river. Three things in your project exist
in one place only, and nothing you normally do copies them anywhere.
The idea
A backup is a copy of the state that only exists in one place, kept somewhere that survives whatever destroys the original. That last clause does most of the work: a copy on the same server dies with the server. Making copies is the easy half, and the half people stop after. Restoring is the half that decides whether they were worth anything.
can rebuild from source cannot be recreated
----------------------- -------------------------------
src/ and the whole repo the database rows
node_modules/ user uploads (S3 / Supabase Storage)
.next/ the built output /etc/myapp/env (the secrets)
the deploy config |
v
back up these three,
off this server,
on a scheduleHow it works
The database is dumped, not copied. For Postgres that is pg_dump, which writes the whole
database out as a single file you can load back with psql. Hosted providers also do this: Supabase
documents daily backups on its paid plans, and Neon and Railway run their own schemes (checked
2026-08-12). Free tiers often keep nothing, so read your provider's backup page rather than assuming.
That dump file is every user record you hold, in plain text, so where it lands matters as much as whether it exists. A backup in a public bucket is a data breach that you scheduled.
Uploaded files live in object storage (an S3 bucket, Supabase Storage, Cloudflare R2), in neither your database nor your repo, so a database backup alone leaves every user's avatar and uploaded PDF unprotected. Most providers offer versioning or a scheduled bucket copy.
Secrets are the ones nobody thinks about. Your API keys, database URL and signing keys live in a file on the server or a hosting dashboard, nowhere else. If that box dies you have your data and no way to connect to it. A password manager entry is enough. Never a git repository.
The schedule is a judgement about loss: nightly means you can lose a day. Pick it by asking what you would say to a user who lost that much.
What to do
-
Write down the three copies: database, uploads, secrets. Name where each goes and how often it runs. If any line says "I think the host does it", go and confirm today.
-
Take one by hand right now, before automating anything. This is also the step to run before a migration or any other change you cannot undo. The connection string is the one your host shows as the database URL, and typing its password into the command would put it in your shell history, so read it in without echoing it:
codeprintf 'db password: ' read -s PGPASSWORD; echo export PGPASSWORD pg_dump "postgres://user@host:5432/dbname" > backup-2026-08-12.sql ls -lh backup-2026-08-12.sql head -30 backup-2026-08-12.sql unset PGPASSWORDA dump of real data runs to megabytes. A few hundred bytes means it stopped part-way, and
pg_dumpwrites its errors to the terminal rather than into the file, so scroll up to find out why. A good dump opens with a-- PostgreSQL database dumpcomment and a block ofSETlines;CREATE TABLEcomes further down. -
Automate it, and check where it lands. A nightly cron job running
pg_dumpinto a private bucket at a different provider costs almost nothing, and Claude Code will write the script and the cron entry in one session. Ask for two things by name: a private bucket, and the database password in a file only root can read rather than in the cron line, where anyone with an account on the box can see it. Verify both, because a mistake here is invisible. Confirm public access is off in the bucket's dashboard, andls -lthe password file to confirm root owns it and the mode is600. Root ownership alone still leaves it world-readable at644. -
Do a restore drill, into something that is not production. Not into a spare hosted project: that puts a plain-text copy of every user record somewhere you then have to secure. Run a throwaway Postgres on port 5433 instead. Every command below names that port, so none of them can reach your live database:
codedocker run --rm -d --name restore-test -e POSTGRES_PASSWORD=x -p 5433:5432 postgres:16 export PGPASSWORD=x createdb -h localhost -p 5433 -U postgres restore_test psql -h localhost -p 5433 -U postgres -v ON_ERROR_STOP=1 restore_test < backup-2026-08-12.sql DATABASE_URL=postgres://postgres:x@localhost:5433/restore_test npm run dev-dgives your prompt back. The container needs a few seconds before it accepts that first connection, anddocker stop restore-testends the drill and takes the copy with it.ON_ERROR_STOP=1matters more than it looks. Without itpsqlprints its errors, carries on, and still exits successfully, so a half-loaded restore looks exactly like one that worked. PassingDATABASE_URLon the command line stops the app reading your production URL out of.envand reporting a success it never tested. Then prove it. Find a row you know is in the backup, such as the newest account, in the running app. Write the date and how long it took in the repo, and repeat every few months.
Where it breaks
Backups faithfully copy corruption. If a bad A script that changes the structure of your database, such as adding a table or removing a column. It runs once, in order, against live data. silently blanks a column on Monday, by Friday every nightly backup holds the blank column and the good data is gone. Keeping a weekly and a monthly copy, not only seven dailies, is what saves you from a slow failure rather than a sudden one.
Restores are also slower and stranger than you expect. Version mismatches, missing extensions and permissions problems surface for the first time under pressure, which is why the drill matters more than the schedule.