Engineering docs
Backups (2026-10-24)
Last updated Oct 24, 2026 · 7 sections
What is backed up, how to take a manual copy, where to keep it, how to prove it restores, and how long to keep it. Two things hold all our state: the Postgres database on Railway (every table, including the append-only play ledger and the wallet) and the media volume mounted at /data on the backend service (creative files and game images, under /data/media).
Never run any of this against production from a laptop without the founder's go-ahead, and never on a Friday evening before a campaign weekend.
What Railway already does
Railway takes automatic daily backups of the Postgres service and keeps them for a limited window on paid plans. That protects against a disk failure, not against us deleting the wrong rows or losing the volume. Confirm it is on: Railway project respectful-adaptation, the Postgres service, Backups tab. Note the retention it shows here: (to be filled in after checking).
Volumes are not included in those backups. The media volume needs the manual copy below until a scheduled job exists.
Manual database backup through railway ssh
The database is reachable only from inside Railway's network, so the dump runs inside the backend container and is streamed out.
- From a machine with the Railway CLI logged in, go to
backend/(it is linked to projectrespectful-adaptation, servicedooh-platform, environmentproduction):cd backend && railway statusmust show exactly that. If it shows anything else, stop. - Check that
pg_dumpexists in the container:railway ssh -- which pg_dump. If it prints nothing, the image lacks the Postgres client. Install it for this session only:railway ssh -- bash -c 'apt-get update -qq && apt-get install -y -qq postgresql-client'(the change does not survive a redeploy, which is fine). - Take the dump and stream it to your machine, compressed:
railway ssh -- bash -c 'pg_dump --no-owner --no-privileges --format=custom "$DATABASE_URL" | gzip -c | base64 -w0' | base64 -d > dooh-prod-$(date +%Y%m%d-%H%M).dump.gzThe base64 step keeps the binary safe across the SSH text stream. Expect a few MB today. - Check it:
gunzip -c dooh-prod-*.dump.gz | pg_restore --list | head. You should see the table list (plays,audience_summaries,wallet_users, ...). An empty or error output means the dump failed; do not keep it. - Record it in the backup log (date, size, who took it, where it was stored).
If railway ssh cannot stream (older CLI versions), write the dump to /data/backups/ inside the container in step 3 and fetch it in a second step with railway ssh -- cat /data/backups/<file> | .... Delete it from the volume afterwards so the volume does not fill up.
Manual media volume backup
- Same linked
backend/directory and the samerailway statuscheck. - Stream a tar of the media directory:
railway ssh -- bash -c 'tar -czf - -C /data media' > dooh-media-$(date +%Y%m%d-%H%M).tar.gz - Check it:
tar -tzf dooh-media-*.tar.gz | wc -lshould be close to the number of creatives plus game images (compare with the Approvals board). - Record it in the backup log.
Media files are content-addressed in practice (one file per upload, never edited), so an older media backup plus a newer database dump is consistent for everything uploaded before the media backup; anything newer must be re-uploaded by the advertiser.
Where to store backups
- A private object-storage bucket to be created (any S3-compatible provider with a region in India is fine; name it
freebee-backups). Private, versioning on, no public access, access key kept only in the founder's password manager. - Folder layout:
db/YYYY/MM/<file>andmedia/YYYY/MM/<file>. - Until the bucket exists: an encrypted external drive kept off-site, plus the founder's laptop. Not a shared drive, not email, not a chat upload: the database contains hashed and encrypted phone numbers and advertiser emails.
- The
SECRET_KEYof the backend must be backed up separately (password manager). Without it the encrypted phone numbers in a restored database cannot be decrypted and the wallet lookups will not match. Rotating the key requires a migration, not a backup.
Retention rule
- Manual database dumps: keep weekly dumps for 3 months, monthly dumps for 2 years, and a yearly dump for 8 years (the play ledger is the invoice record, and Indian company and tax law expect accounting records to be kept for 8 years; confirm with the accountant).
- Media backups: keep the latest 3 monthly copies; older creatives that no booking references can be dropped with the advertiser's deletion request.
- Delete expired backups on the first working day of each month and note it in the log. A backup that holds an erased wallet number is deleted when it expires; the privacy notice says we keep backups for a limited time and do not restore erased data from them.
Monthly restore drill
Do this once a month, on a laptop or a throwaway Railway environment, never on production. It takes about 30 minutes.
- Take or download the latest database dump and media tar.
- Start a local Postgres (Docker:
docker run --rm -e POSTGRES_PASSWORD=drill -p 5433:5432 postgres:16). - Restore:
pg_restore --no-owner --no-privileges -d postgresql://postgres:drill@localhost:5433/postgres <dump>. - Untar the media into a temp folder and start the API against both:
DATABASE_URL=postgresql://postgres:drill@localhost:5433/postgres MEDIA_STORE_DIR=/tmp/drill/media uvicorn app.main:app --port 8199. - Sign in as ops (the production ops password) and check the four things on the checklist below.
- Write the drill result in the backup log: date, dump date, size, how long the restore took, anything that failed, and the fix.
Checklist for a good drill:
- Row counts for
plays,audience_summaries,bookings,creatives,wallet_usersmatch what the dump listed (pg_restore --listplus aSELECT count(*)each). - One approved creative's file opens from the restored media folder.
- The ops Today page loads and shows the screens with their last check-in times from the dump date.
- A wallet sign-in with a test number (not a real shopper's) still works, proving the
SECRET_KEYused matches the data.
The script: backend/scripts/backup.sh
A small helper for the local side and for use inside the container:
scripts/backup.sh <target-dir>with noDATABASE_URLor a SQLite URL copiesvar/dev.dband themedia_storefolder into a timestamped folder under the target. Safe to run on a laptop any time.- With a Postgres
DATABASE_URLset (inside the Railway container) it runspg_dumpinto the same layout, plus a tar ofMEDIA_STORE_DIR. - It refuses to run if the target directory does not exist or is not writable, and it never deletes anything.
Set up a scheduled run (Railway cron or a GitHub Action with the Railway CLI) only after the bucket exists; until then the monthly manual run plus Railway's own backups is the rule.