docs /Backups

Backups

A service that holds state has to be able to come back. ply's answer is the service's own: the registry's Postgres dumps itself on a schedule to wherever you point it, keeps a window, and restores from there. ply's verbs drive that, inside the instance, with the instance's own environment, so the destination and its credentials are set once.

#Turning it on

Three variables on the service. In a stack file:

[[app]]
run = "postgres@17"
e = [
  "BACKUP_DEST=:s3:my-bucket/pg",     # an rclone target
  "BACKUP_INTERVAL=86400",            # seconds between dumps (default: a day)
  "BACKUP_KEEP_DAYS=14",              # prune older dumps (default: 14)
  "RCLONE_S3_PROVIDER=AWS",
  "RCLONE_S3_REGION=eu-central-1",
  "RCLONE_S3_ACCESS_KEY_ID=AKIA…",
  "RCLONE_S3_SECRET_ACCESS_KEY=enc:v1:…",   # sealed for this host — see Sealed secrets
]
egress = { allow = ["s3.eu-central-1.amazonaws.com"] }

BACKUP_DEST is any rclone target: an S3 bucket on AWS, Cloudflare R2, Backblaze, MinIO, DigitalOcean Spaces; or :local:/backups for a directory you --link in from the host. The credentials are rclone's environment variables; seal the secret one (Sealed secrets) and the stack file can live in git.

The Postgres image declares egress = [], so a destination has to be allowed by the operator, as above; under enforce, a backup to an unlisted host fails and shows in ply egress db --blocked.

From the first boot the service dumps POSTGRES_DB with pg_dump, gzipped, every BACKUP_INTERVAL seconds, named <db>-<UTC timestamp>.sql.gz, and prunes anything older than BACKUP_KEEP_DAYS. Each run prints one line to the service's log and writes the last outcome to /run/ply/self/backup.

#Driving it

ply backup now db          # a dump outside the schedule; prints its name
ply backup ls db           # the dumps at BACKUP_DEST, oldest first
ply restore db --to check  # the latest dump, into a database named `check`,
                           # beside the live one — look at yesterday next to today
ply restore db app-20260908-030001.sql.gz --replace
                           # that dump, OVER the live database

--replace is the disaster path. Connections to the live database are terminated, it is dropped and recreated, and the dump loads; the app reconnects, as database clients do, and everything written since the dump is gone. That is what a restore means, and the flag says so.

Both verbs run inside the instance through ply exec, so they work wherever ply exec does, rootful or rootless, Linux or macOS, and need nothing on the command line that the service does not already know.

#Coming back from nothing

The volume is gone, or the host is. Start the same service on an empty volume with BACKUP_RESTORE=latest (or a dump's name) and the same BACKUP_DEST and credentials: on first boot it fetches the dump and loads it before the server takes connections. A failed restore wipes the data directory so the next start retries cleanly, rather than leaving a half-restored database that looks initialised.

ply run postgres@17 -e POSTGRES_DB=app -e BACKUP_DEST=:s3:my-bucket/pg \
  -e BACKUP_RESTORE=latest -e RCLONE_S3_… --publish internal:5432

BACKUP_RESTORE only ever applies to an empty volume; on a volume with data it is ignored, so it is safe to leave in a stack file.

#Prove it before you need it

A backup nobody has restored is a hope. Once a month, or in CI against a scratch bucket:

ply backup now db
ply restore db --to verify
ply exec db psql -U postgres -d verify -c 'select count(*) from your_table'

#Other services

The contract is small and any service can follow it: read BACKUP_DEST, BACKUP_INTERVAL, BACKUP_KEEP_DAYS and BACKUP_RESTORE; ship backup.sh (one dump, now) and restore.sh (a dump on stdin, --to NAME or --replace) beside the entrypoint; depend on rclone. ply backup and ply restore then work unchanged. Plain volumes with no service to dump them are not covered here: snapshot the host's filesystem, as the Volumes guide says.