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.

code
  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 schedule

How 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

  1. 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.

  2. 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:

    code
    printf '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 PGPASSWORD

    A dump of real data runs to megabytes. A few hundred bytes means it stopped part-way, and pg_dump writes 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 dump comment and a block of SET lines; CREATE TABLE comes further down.

  3. Automate it, and check where it lands. A nightly cron job running pg_dump into 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, and ls -l the password file to confirm root owns it and the mode is 600. Root ownership alone still leaves it world-readable at 644.

  4. 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:

    code
    docker 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

    -d gives your prompt back. The container needs a few seconds before it accepts that first connection, and docker stop restore-test ends the drill and takes the copy with it. ON_ERROR_STOP=1 matters more than it looks. Without it psql prints its errors, carries on, and still exits successfully, so a half-loaded restore looks exactly like one that worked. Passing DATABASE_URL on the command line stops the app reading your production URL out of .env and 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 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.