Restoring a PostgreSQL backup
By the end of this page you will have a second database, shop_drill, populated from the artifact
you produced on the previous page; plus a recorded restore execution you can point at when someone
asks whether the backups actually work.
Budget about fifteen minutes.
What you need
-
The working directory, configuration, and
backups/shop.sqlartifact from Your first PostgreSQL backup. This page edits that samesentinel.yaml. -
The
sentinel-pg-tutorialcontainer still running. If you stopped it, restart the track from the previous page; a restore needs both a live server and an artifact. -
PGPASSWORDstill exported in your shell:export PGPASSWORD=tutorial
If you need a fresh server, the repository's
infra/docker/docker-compose.yml
brings up a PostgreSQL 17 instance alongside the other supported engines.
Step 1: Describe the restore job
Restores are configured, not improvised. Append to sentinel.yaml:
restore:
staging_dir: ./staging
restores:
shop-drill:
type: postgres
enabled: true
host: 127.0.0.1
port: 5432
username: postgres
password_env: PGPASSWORD
database: shop_drill
schedule: "0 4 * * 0"
conflict_strategy: error
backup_source:
type: local
local_path: ./backups
backup_path: shop.sql
restores is a separate top-level block from databases: a restore job has its own target,
credentials, and source, because the whole point of a restore drill is that it does not run against
the database you backed up.
Three keys are worth pausing on:
scheduleis required even when you only ever run the job by hand. Omit it and validation fails withrestore 'shop-drill': restore schedule (cron) is required. It is the cron expressionsentinel schedule startwould use;sentinel restore runignores it.conflict_strategy: erroris the default and the safe one: fail rather than overwrite. The alternatives arereplaceandignore. PostgreSQLreplaceoperations that could drop dependent objects additionally requireallow_cascade: true.restore.staging_diris where artifacts are staged before being applied. The default is/tmp/sentinel; pointing it at the working directory keeps the tutorial self-contained.
Validate, then confirm Sentinel sees the job:
sentinel config validate --config sentinel.yaml
sentinel restore list --config sentinel.yaml
You should see:
Restore Jobs:
=============
Name: shop-drill
Type: postgres
Schedule: 0 4 * * 0
Status: enabled
Database: shop_drill
sentinel restore … emits structured JSON log lines regardless of the top-level log_format: text
setting, so its output looks different from backup and monitor. The human-readable report is
still printed to standard output underneath.
Step 2: Create the target database
psql -h 127.0.0.1 -U postgres -d postgres -c "CREATE DATABASE shop_drill;"
You should see CREATE DATABASE.
Restoring over shop would prove nothing and destroy your source data. A restore drill is only
meaningful against a target you are willing to lose.
Step 3: Rehearse with a dry run
A dry run resolves the job, the source, and the restore plan, and reports what would happen without touching the target:
sentinel restore dry-run shop-drill --config sentinel.yaml
You should see:
Dry-run: Job "shop-drill"
Type: postgres
Database: shop_drill
Backup Source Type: local
Backup Path: shop.sql
Restore Mode: full
Timeout: 0 seconds
NOTE: This is a dry-run. No data will be restored.
Restore Mode: full is the default when restore_mode is unset. The other two modes,
incremental and pitr, come later in this track.
Step 4: Run the restore
This writes into shop_drill and can overwrite whatever is there. Confirm the artifact you are
about to apply is intact first:
sentinel backup verify --all --config sentinel.yaml
Never point a restore job at a database you cannot afford to lose. conflict_strategy: error makes
Sentinel refuse rather than clobber, but it is a guard rail, not a substitute for choosing the right
target.
sentinel restore run shop-drill --config sentinel.yaml
You should see:
{"time":"2026-08-05T17:44:20.612807Z","level":"INFO","msg":"hash verification passed","backup_id":"shop","hash":"5d28103153eea0f1a5445ad5e5a9a99cf54ec701e77acbb19cbf7f4971c8767d"}
Restore job "shop-drill" completed successfully
The hash verification passed line is the manifest doing its job: before applying anything, Sentinel
re-read backups/shop.sql, recomputed its SHA-256, and compared it against
backups/shop.sql.manifest.json. A mismatch aborts the restore. That check is exactly why the
previous page insisted on setting output.
Step 5: Confirm the rows came back
psql -h 127.0.0.1 -U postgres -d shop_drill -c \
"SELECT c.name, o.total FROM customers c JOIN orders o ON o.customer_id = c.id ORDER BY o.id;"
You should see:
name | total
--------------+-------
Ada Lovelace | 42.00
Grace Hopper | 17.50
Alan Turing | 99.99
(3 rows)
That is the loop closed: the artifact is not just present and hash-clean, it reconstructs the data.
Step 6: Check the restore history
Restores are recorded in the same history database as backups, in their own table:
sentinel restore history shop-drill --config sentinel.yaml
You should see:
RESTORE | DATABASE | MODE | PLAN | STATUS | DURATION | TIMESTAMP | REASON | FALLBACK
shop-drill | shop_drill | full | ready | success | 102ms | 2026-08-05T17:44:20Z | - | none
PLAN and REASON are the planner's verdict. Here the plan was ready with no reason code because
a full restore has nothing to decide. On the incremental and
PITR pages those columns carry the interesting information.
For the job's current configuration rather than its history:
sentinel restore status shop-drill --config sentinel.yaml
You should see:
Restore Job: shop-drill
Type: postgres
Database: shop_drill
Schedule: 0 4 * * 0
Status: enabled
Restore Mode: full
Verify After Restore: false
Timeout: 0 seconds
Keep File: false
If it goes wrong
Three failures are likely on a first run.
failed to acquire restore lock: … mkdir /var/run/sentinel: permission denied: you left
scheduler.lock_dir unset and are not running as root. Restores always take a per-job file lock;
backups in this configuration do not. Set lock_dir to a writable path, as the configuration on the
previous page does.
failed to run psql restore command - exit status 3: the target already contains the objects
in the artifact, and conflict_strategy: error refused to overwrite them. This is what you get from
running Step 4 twice. Drop and recreate the target:
psql -h 127.0.0.1 -U postgres -d postgres \
-c "DROP DATABASE shop_drill;" -c "CREATE DATABASE shop_drill;"
verification handler is required for restore mode "full": you set verify_after_restore: true. In v1.4.0 the restore runtime does not supply a post-restore verification handler, so any job
that requests one fails at the final step. The restore itself has already completed by then: the
data is in the target, but the run is recorded as failed. Leave verify_after_restore unset and
verify with a psql query, as Step 5 does.
What just happened
Sentinel resolved the newest matching object in ./backups, staged it under ./staging, verified
its SHA-256 against the manifest, ran psql against shop_drill, removed the staged copy, and
recorded the execution. The plan (full) was computed before any of that, from the restore job's
mode and the artifact's manifest; the same planner the next two pages push harder.
See Restore for the model, and
sentinel restore for every flag.
Next
- Incremental backup chains: stop taking a full dump every time, and learn what Sentinel's chain metadata does and does not give you on PostgreSQL.
{/* sources: internal/cli/restore.go, internal/config/restore_types.go, internal/config/validator.go, internal/config/loader.go, internal/domain/restore/executor.go, internal/domain/restore/planner.go, internal/adapters/restore/runtime/executor.go, internal/adapters/restore/pg/pg_restore.go, docs/runbooks/restore-from-backup.md, docs/runbooks/restore-rehearsal.md */}