Point-in-Time Recovery (PITR)
Configure WAL archiving, take a base backup, and recover to the exact second before a destructive statement — then promote the recovered cluster to writable
Every backup this block has covered so far restores to exactly one point in time: whenever the backup was taken. Point-in-time recovery removes that limitation. By continuously archiving every WAL segment the server generates, alongside a base backup taken at any earlier point, you can replay WAL forward from that backup to any instant you choose — including, critically, the instant one second before someone ran a statement they should not have.\n\nThe scenario is the one every DBA eventually lives through: someone runs DELETE FROM orders WHERE true at 14:23. Every row is gone, and it is gone in every replica within seconds. The base backup from last night has the data — but it also has none of the legitimate orders placed between last night and 14:23. Neither extreme is what you want; you want the database exactly as it was at 14:22:59.\n\nThis lab builds the whole chain: enable archiving, take a base backup, capture a precise timestamp before deliberately deleting data, then recover to that exact second and promote the result to a normal, writable server — the same recovery a real production incident would demand, done here as a rehearsal.
Step 1: Enable WAL Archiving
mkdir -p /tmp/wal_archive && chmod 777 /tmp/wal_archive
echo "archive_mode = on" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.conf
echo "archive_command = 'cp %p /tmp/wal_archive/%f && chmod 644 /tmp/wal_archive/%f'" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.conf
as-postgres pg_ctl restart -D /var/lib/postgresql/18/data -l /var/lib/postgresql/18/data/pg_log/startup.log
archive_mode is a restart-only parameter, same category as wal_level. archive_command runs once per completed WAL segment, as the postgres OS user — %p is the segment's full path, %f is just its filename. chmod 777 on the archive directory is what lets the postgres OS user write into a directory the student created.
Step 2: A Base Backup Is the PITR Starting Point
pg_basebackup -U postgres -h /tmp -D /tmp/pitr_base -Fp -Xs -c fast
PITR always replays forward from a base backup — there is no way to recover to a point in time using WAL alone, with nothing to apply it to first.
Step 3: Simulate the Disaster, at a Known Timestamp
-- Capture the exact instant, just before the destructive statement
SELECT clock_timestamp();
-- note the value, then:
DELETE FROM orders WHERE true;
Step 4: Write the Recovery Configuration
cp -r /tmp/pitr_base /tmp/pitr_recover
touch /tmp/pitr_recover/recovery.signal
cat >> /tmp/pitr_recover/postgresql.auto.conf <<EOF
restore_command = 'cp /tmp/wal_archive/%f %p'
recovery_target_time = '2026-06-22 14:22:59.000000+00'
EOF
recovery.signal is what tells PostgreSQL, on startup, to enter recovery mode and look for a target instead of doing ordinary crash recovery. restore_command is the mirror image of archive_command — it tells the recovering server how to fetch each WAL segment it needs from the archive. The default recovery_target_action is pause: once the target is reached, the server stops applying WAL and sits in a paused, read-only state, giving you a chance to verify the data before doing anything irreversible.
Step 5: Start, Verify, Promote
pg_ctl start -D /tmp/pitr_recover -o "-p 5434" -l /tmp/pitr_recover/startup.log
psql -h /tmp -p 5434 -U postgres -d beer_db -c "SELECT count(*) FROM orders;" # verify data is back
pg_ctl promote -D /tmp/pitr_recover # now make it writable
Promotion is a one-way door: once promoted, the recovered instance starts generating its own new WAL history, diverging from the original timeline. Verify first; promote second.
WAL Archiving (archive_command)
A shell command PostgreSQL runs once for every WAL segment it finishes writing, copying that segment somewhere durable outside pg_wal/ before the server is allowed to recycle it. Archived WAL is what makes recovery to any point in time possible — without it, only a base backup's exact moment is ever recoverable.
recovery_target_time
Tells a recovering server to replay archived WAL only up to (and not past) a specific timestamp, then stop. Combined with a base backup taken earlier and the WAL archived since, this is what makes it possible to recover to literally any instant covered by that archived WAL — not just whenever the base backup happened to be taken.
pg_ctl promote
Ends recovery mode and makes a paused or standby server into a normal, independent, writable primary. It is a one-way operation: once promoted, the server begins writing its own new WAL history, and can no longer continue replaying the original timeline it recovered from.
📂 Create and Open the Archive Directory
Create a WAL archive directory that both the student and the postgres OS user (running archive_command) can write into.
mkdir -p /tmp/wal_archive && chmod 777 /tmp/wal_archivestudent@lab:~$ mkdir -p /tmp/wal_archive && chmod 777 /tmp/wal_archive
⚙️ Enable Archiving in postgresql.conf
Append archive_mode and archive_command to postgresql.conf using the as-postgres helper — the student account cannot write to PGDATA directly. This cluster also starts at wal_level = minimal, and archive_mode refuses to enable on top of that, so raise wal_level in the same edit.
echo "archive_mode = on" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.confecho "archive_command = 'cp %p /tmp/wal_archive/%f && chmod 644 /tmp/wal_archive/%f'" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.confecho "wal_level = replica" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.confecho "max_wal_senders = 10" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.confstudent@lab:~$ echo "archive_mode = on" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.conf archive_mode = on student@lab:~$ echo "archive_command = 'cp %p /tmp/wal_archive/%f && chmod 644 /tmp/wal_archive/%f'" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.conf archive_command = 'cp %p /tmp/wal_archive/%f && chmod 644 /tmp/wal_archive/%f' student@lab:~$ echo "wal_level = replica" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.conf wal_level = replica student@lab:~$ echo "max_wal_senders = 10" | as-postgres tee -a /var/lib/postgresql/18/data/postgresql.conf max_wal_senders = 10
🔄 Restart to Apply archive_mode
archive_mode requires a full restart, not a reload — the same restart-vs-reload distinction from Lab 2.1.4.
as-postgres pg_ctl restart -D /var/lib/postgresql/18/data -l /var/lib/postgresql/18/data/pg_log/startup.logstudent@lab:~$ as-postgres pg_ctl restart -D /var/lib/postgresql/18/data -l /var/lib/postgresql/18/data/pg_log/startup.log waiting for server to shut down.... done server stopped waiting for server to start.... done server started
✅ Confirm Archiving Is Active
Force a WAL segment switch and confirm pg_stat_archiver shows it was archived.
psql -U postgres -c "SELECT pg_switch_wal();"sleep 2psql -U postgres -c "SELECT archived_count, last_archived_wal, failed_count FROM pg_stat_archiver;"student@lab:~$ psql -U postgres -c "SELECT pg_switch_wal();" pg_switch_wal --------------- 0/1A000148 (1 row) student@lab:~$ sleep 2 student@lab:~$ psql -U postgres -c "SELECT archived_count, last_archived_wal, failed_count FROM pg_stat_archiver;" archived_count | last_archived_wal | failed_count ----------------+--------------------------+-------------------- 1 | 000000010000000000000019 | 0 (1 row)
📸 Take the PITR Base Backup
Take a base backup — this is the fixed starting point every recovery in this lab replays forward from.
pg_basebackup -U postgres -h /tmp -D /tmp/pitr_base -Fp -Xs -c faststudent@lab:~$ pg_basebackup -U postgres -h /tmp -D /tmp/pitr_base -Fp -Xs -c fast
🧾 Seed Orders and Capture the Pre-Disaster Timestamp
Create an orders table with real data, then capture the exact instant just before deleting it.
psql -U postgres -d beer_db -c "CREATE TABLE orders (id serial PRIMARY KEY, customer text, total numeric(10,2), placed_at timestamptz DEFAULT now());" -c "INSERT INTO orders (customer, total) VALUES ('acme corp', 412.50), ('brewhouse ltd', 89.00), ('taproom inc', 1205.75);"export TS=$(psql -U postgres -tAc "SELECT clock_timestamp();" | tail -n1)echo "Recovery target: $TS"student@lab:~$ psql -U postgres -d beer_db -c "CREATE TABLE orders (id serial PRIMARY KEY, customer text, total numeric(10,2), placed_at timestamptz DEFAULT now());" -c "INSERT INTO orders (customer, total) VALUES ('acme corp', 412.50), ('brewhouse ltd', 89.00), ('taproom inc', 1205.75);" SET CREATE TABLE INSERT 0 3 student@lab:~$ export TS=$(psql -U postgres -tAc "SELECT clock_timestamp();" | tail -n1) student@lab:~$ echo "Recovery target: $TS" Recovery target: 2026-06-26 16:02:55.516795+00
💥 Run the Disaster — Then Force the WAL Switch That Archives It
Run the destructive statement from the scenario, then force a WAL segment switch. The DELETE lands in whichever segment is currently active, and archive_command only ever runs on a completed segment — without forcing the switch, the WAL recovery actually needs would still be sitting unarchived in the primary's pg_wal/.
psql -U postgres -d beer_db -c "DELETE FROM orders WHERE true;"psql -U postgres -c "SELECT pg_switch_wal();"sleep 2student@lab:~$ psql -U postgres -d beer_db -c "DELETE FROM orders WHERE true;" SET DELETE 3 student@lab:~$ psql -U postgres -c "SELECT pg_switch_wal();" SET pg_switch_wal --------------- 0/8009418 (1 row) student@lab:~$ sleep 2
📝 Write the Recovery Configuration
Copy the base backup to a recovery directory, add recovery.signal, and configure restore_command plus recovery_target_time using the exact timestamp captured before the delete.
mv /tmp/pitr_base /tmp/pitr_recovertouch /tmp/pitr_recover/recovery.signalprintf "restore_command = 'cp /tmp/wal_archive/%%f %%p'\nrecovery_target_time = '$TS'\n" >> /tmp/pitr_recover/postgresql.auto.confstudent@lab:~$ mv /tmp/pitr_base /tmp/pitr_recover student@lab:~$ touch /tmp/pitr_recover/recovery.signal student@lab:~$ printf "restore_command = 'cp /tmp/wal_archive/%f %p'\nrecovery_target_time = '$TS'\n" >> /tmp/pitr_recover/postgresql.auto.conf
▶️ Start the Recovery Instance
Start the recovery directory as its own instance on port 5434 and watch it replay WAL toward the target.
pg_ctl start -D /tmp/pitr_recover -o "-p 5434" -l /tmp/pitr_recover/startup.logstudent@lab:~$ pg_ctl start -D /tmp/pitr_recover -o "-p 5434" -l /tmp/pitr_recover/startup.log waiting for server to start.... done server started
🔍 Verify the Data, Then Free Up Disk Space
Confirm the orders are actually back, and the instance is still read-only, before doing anything irreversible. Then clear out the WAL archive — recovery has already replayed everything it needed from it, and promotion needs to write a fresh WAL timeline, which this VM does not have room for otherwise.
psql -h /tmp -p 5434 -U postgres -d beer_db -c "SELECT pg_is_in_recovery();" -c "SELECT count(*) FROM orders;"rm -rf /tmp/wal_archive/*student@lab:~$ psql -h /tmp -p 5434 -U postgres -d beer_db -c "SELECT pg_is_in_recovery();" -c "SELECT count(*) FROM orders;" SET pg_is_in_recovery -------------------- t (1 row) count ------- 3 (1 row) student@lab:~$ rm -rf /tmp/wal_archive/*
🚀 Promote to Writable
Promote the recovered instance to a normal, independent, writable server.
pg_ctl promote -D /tmp/pitr_recoverstudent@lab:~$ pg_ctl promote -D /tmp/pitr_recover waiting for server to promote.... done server promoted
Lab 2.3.5 complete — and Block 2.3 complete. You built the full point-in-time recovery chain, end to end:\n\n\n WAL archiving : ✅ archive_mode + archive_command, restarted\n pg_stat_archiver : ✅ archiving confirmed live, zero failures\n Base backup : ✅ fixed PITR starting point\n Precise timestamp capture : ✅ clock_timestamp() before the disaster\n recovery.signal + target : ✅ restore_command + recovery_target_time\n Verified before promoting : ✅ data confirmed, still read-only\n pg_ctl promote : ✅ writable, recovery complete\n
Enable JavaScript to run the live terminal and track your progress.