Backup and restore
Polo keeps its product state in PostgreSQL. The scripts in scripts/ops/ take a portable dump of that database, restore it, and rehearse the whole cycle. They take every secret from the environment and never print URLs, passwords or roles.
What to back up
Section titled “What to back up”| What | Where | Why |
|---|---|---|
| Product database | PostgreSQL database polo_product |
Workspaces, tasks, approvals, connections, knowledge, audit records |
| Encryption keys | POLO_API_OPERATION_KEY, POLO_API_SSO_KEY (in .env or the Kubernetes Secret) |
Without them, sealed credentials and SSO configuration in the database are unreadable |
| The rest of the environment | .env (Compose) or the Secrets (Helm) |
Role passwords, worker tokens, storage keys |
| Artifacts and snapshots | The S3 bucket, and the worker’s snapshots volume |
Task artifacts and workspace snapshots |
| Temporal | Its databases (temporal, temporal_visibility on the same server in Compose) |
The state of tasks in flight |
| Runner storage | Each customer runner’s storage directory | Receipts and runner state; owned by whoever runs the runner |
A managed database’s snapshots, WAL archiving or point-in-time recovery remain the first line of defence. The dump described here is the portable copy.
Which role can take a backup
Section titled “Which role can take a backup”Every tenant table forces row-level security, which binds the table owner as well. A dump therefore needs a role that bypasses it: a superuser, or a role with BYPASSRLS. For any other role pg_dump stops at the first tenant table. backup.sh checks the connecting role first and exits with code 2, before writing anything, when row-level security applies to it.
-
Compose and the local launcher:
polo_migrationis the PostgreSQL container’s bootstrap superuser, so its URL works as is. -
An external server where
polo_migrationis a plain owner: either give it the attribute, or create a read-only backup role (PostgreSQL 14 and later). GrantingBYPASSRLSnormally takes a superuser.ALTER ROLE polo_migration BYPASSRLS;-- orCREATE ROLE polo_backup LOGIN BYPASSRLS PASSWORD '...' IN ROLE pg_read_all_data; -
A managed service that grants neither: rely on its snapshots and point-in-time recovery.
-
Never the application role. It cannot bypass row-level security by design, and the API refuses to start otherwise.
Back up
Section titled “Back up”scripts/ops/backup.sh runs pg_dump in custom format and writes polo-<database>-<UTC timestamp>.dump (mode 0600) with a .sha256 file beside it. It prints one JSON line with the file, size, checksum and whether it was uploaded.
POLO_BACKUP_DATABASE_URL='postgresql://polo_migration:<password>@<db-host>:5432/polo_product' \POLO_BACKUP_DIR=/var/backups/polo \POLO_BACKUP_S3_URI=s3://<backup-bucket>/product \POLO_BACKUP_RETENTION_DAYS=14 \scripts/ops/backup.sh| Variable | Purpose |
|---|---|
POLO_BACKUP_DATABASE_URL |
The database to dump. Required. |
POLO_BACKUP_DIR |
Local directory for dumps. Default ./backups, created with mode 0700. |
POLO_BACKUP_S3_URI |
Optional s3://bucket/prefix destination. |
POLO_BACKUP_MC_ALIAS |
Upload with mc cp to this alias, when mc is installed. |
POLO_BACKUP_S3_ENDPOINT |
Endpoint for aws s3 cp, for MinIO. AWS credentials come from the environment. |
POLO_BACKUP_REQUIRE_UPLOAD |
true fails the run when a destination is set but no upload tool is available. Otherwise the script keeps the local copy and warns. |
POLO_BACKUP_RETENTION_DAYS |
Removes local dumps older than this many days after a successful run. |
POLO_PG_CLIENT_CONTAINER |
A Docker container that provides pg_dump, pg_restore and psql when the host has no client tools, for example the PostgreSQL container itself. |
POLO_PG_CLIENT_HOST, POLO_PG_CLIENT_PORT |
Where that container reaches the server. Default 127.0.0.1:5432. |
Record the row counts next to each backup, so a restore can be checked against them:
scripts/ops/rowcounts.sh 'postgresql://polo_migration:<password>@<db-host>:5432/polo_product'Schedule the backup from cron or a Kubernetes CronJob at least daily.
Compose
Section titled “Compose”Either run backup.sh with POLO_PG_CLIENT_CONTAINER set to the PostgreSQL container, or dump directly:
docker compose exec -T postgres pg_dump -U polo_migration -Fc polo_product > polo_product.dumpBack up .env with it.
Helm with the dependencies chart
Section titled “Helm with the dependencies chart”deploy/helm/polo-dependencies can take a daily dump to S3 itself. Set backups.enabled, backups.bucket, backups.region and backups.existingSecret (a Secret holding AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY of a user that may only put objects there). The schedule defaults to once a day (backups.schedule), and objects are written under backups.prefix. Expire old dumps with a lifecycle rule on the bucket.
Restore
Section titled “Restore”scripts/ops/restore.sh takes the dump as its first argument (or POLO_RESTORE_FILE). It:
- verifies the
.sha256file when one is present; - optionally creates or replaces the target database;
- runs
pg_restore --exit-on-error; - runs the migration check against the target — a complete backup prints
"migrations_applied":[], and a backup older than the code applies the missing migrations and lists them; - prints
table,countrows for every Polo table.
| Variable | Purpose |
|---|---|
POLO_RESTORE_DATABASE_URL |
The target database. Required. Restore as the migration owner so ownership, row-level security policies and functions come back intact. |
POLO_RESTORE_CREATE |
true creates the target database first (the role needs CREATEDB). |
POLO_RESTORE_REPLACE |
true replaces an existing target. Refused for a database named polo_product unless POLO_RESTORE_ALLOW_PRODUCTION=true. |
POLO_RESTORE_NO_OWNER |
true restores without owners and privileges, for a server that lacks the original roles. Run provisioning afterwards. |
POLO_RESTORE_JOBS |
Parallel jobs. Default 2; forced to 1 when restoring through a container. |
POLO_RESTORE_SKIP_MIGRATION_CHECK, POLO_RESTORE_SKIP_COUNTS |
true skips that step. |
The migration check runs node api/dist/src/platform/database/migrate.js, so the API must be built (cd api && npm run build) on the machine that runs the restore. The restore itself needs no BYPASSRLS.
Production procedure
Section titled “Production procedure”Use this for a new server or a damaged database.
-
Stop the API, the relay, the scheduler, the watch processes and the runtime worker. The relay and the worker would otherwise write to a half-restored database.
-
Restore:
Terminal window POLO_RESTORE_DATABASE_URL='postgresql://polo_migration:<password>@<db-host>:5432/polo_product' \POLO_RESTORE_CREATE=true POLO_RESTORE_REPLACE=true POLO_RESTORE_ALLOW_PRODUCTION=true \scripts/ops/restore.sh /var/backups/polo/polo-polo_product-<stamp>.dump -
Compare the printed counts with the counts recorded at backup time.
-
If roles were recreated, run provisioning (
polo-api provision, ornode api/dist/src/platform/database/provision.js) with the operator environment so the application and dispatch roles get their grants back. -
Start the API and check
GET /readyz. Then start the relay, the scheduler, the watch processes and the worker.
Make sure the restored deployment runs with the same POLO_API_OPERATION_KEY and POLO_API_SSO_KEY as the one that took the backup.
Rehearse a restore
Section titled “Rehearse a restore”scripts/ops/restore-drill.sh dumps a source database, restores it into a scratch database on the same server (default polo_restore_drill), compares the row counts table by table, and then removes the scratch database and the dump.
POLO_DRILL_SOURCE_URL='postgresql://<role>:<password>@<host>:<port>/<source database>' \POLO_DRILL_SCRATCH_DB=polo_restore_drill \scripts/ops/restore-drill.shA successful run ends with a JSON line that includes "counts_match":true.
| Variable | Purpose |
|---|---|
POLO_DRILL_SOURCE_URL |
The database to rehearse with. Required. |
POLO_DRILL_SCRATCH_DB |
Scratch database name. It cannot be polo_product. |
POLO_DRILL_BACKUP_DIR |
Where the dump is written. Default a fresh temporary directory. |
POLO_DRILL_KEEP |
true keeps the dump and the scratch database for inspection. |
What the drill assumes:
- One role does everything on one server, so the source URL names a superuser, or the migration owner with
BYPASSRLSandCREATEDB. - The roles the dump grants to (
polo_application,polo_dispatch) exist on that server, and the API is built for the migration check. - It does not exercise a restore onto a server without the original roles, or onto a different PostgreSQL major version.
Repeat the drill monthly, and after every release whose migration adds a schema, function or role.
Before an upgrade
Section titled “Before an upgrade”Take a backup before every upgrade. Migrations are forward-only, so rolling back a release whose migration has run means restoring that backup. See the upgrade order.