This is the reference for day-2 operations: running backups, restoring under time pressure, applying retention, verifying restorability, managing the repository, ferrying WAL across the network, custody of encryption keys, and knowing what each command tells you when something goes wrong.
Audience: a DBA who is at a terminal, possibly at 3am, and wants exact commands. New to the tool? Start at getting-started.
pg_hardstorage backup db1Reads pg_hardstorage.yaml for the deployment's PG connection and
repo URL. To override at the call site:
pg_hardstorage backup db1 \
--pg-connection 'postgres://pgbackup@db1.example.com/postgres' \
--repo file:///srv/backupsThe pipeline is BASE_BACKUP over libpq → tar parser → FastCDC
chunker → CAS PUTs → signed manifest. Default compression is
zstd SpeedBetterCompression; default encryption is on if a KEK is
present at the keyring path. Force one or the other with
--encrypt / --no-encrypt. Backup IDs follow
the shape db1.full.20260427T093017Z.
For NDJSON progress (one event per line) suitable for piping to jq:
pg_hardstorage backup db1 -o ndjsonpg_hardstorage status # one line per deployment
pg_hardstorage status db1 # detail for one
pg_hardstorage list db1 # backups for a deployment
pg_hardstorage show db1 <backup-id>status returns RPO (now − latest backup completion), WAL lag, and
the next scheduled run. show dumps the full manifest including
LSN range, timeline, dedup ratio, encryption envelope details, and
the verification record if one has been written.
pg_hardstorage doctor
pg_hardstorage doctor db1
pg_hardstorage doctor --exit-on-issues # exit 10 if anything is wrongValidates: paths resolution, config loaded and well-formed, keystore
presence (signing key + KEK), deployment reachability, repo
writability, slot health. Each finding prints Suggested fix: with
the exact command to run.
pg_hardstorage restore db1With no positional argument the command lists backups, prompts for
selection, runs pre-flight checks, and asks for confirmation. The
answer is y to proceed, anything else aborts with exit 5
(operator-aborted).
pg_hardstorage restore db1 latest --target /var/lib/postgresql/restoredThree forms, mutually exclusive:
--to "5 minutes ago" # natural-language time
--to "2026-04-27 09:42:00 UTC" # RFC3339-ish
--to-lsn 0/3000028 # exact LSN
--to-name pre_release # named restore point you created with pg_create_restore_pointNatural-language parsing supports <n> minutes/hours/days ago,
yesterday, today HH:MM, plain RFC3339, and the common
YYYY-MM-DD HH:MM TZ form. Anything else returns
usage.bad_time (exit 2).
pg_hardstorage restore db1 latest \
--target /var/lib/postgresql/restored \
--to "5 minutes ago" \
--previewPrints what would happen — source backup, WAL replay range, RTO
estimate, target tablespace mapping, verification gate — and exits
without touching disk. Pair with --force to run the same
operation non-interactively after operator review.
The restore refuses up-front, before anything is written:
- target dir exists and is non-empty (override with
--force, which is destructive and asks again) - target dir contains a live
postmaster.pid - KMS key is unreachable (cannot decrypt)
- repo manifests fail signature verification
- requested LSN falls inside a WAL gap recorded by the gap auditor
Each refusal carries a Suggestion. None of them mutate state.
deployments:
db1:
retention:
policy: gfs
keep_daily: 7
keep_weekly: 4
keep_monthly: 12
keep_yearly: 5rotate runs after every backup commit and as a scheduled job. Manual
invocation is dry-run by default:
pg_hardstorage rotate db1 # dry-run, prints decisions
pg_hardstorage rotate db1 --apply # actually tombstoneretention:
policy: simple
keep_for: 30d # delete anything older than 30 daysretention:
policy: count
keep_fulls: 14 # keep last N fulls; WAL kept while needed for PITRrotate --apply writes a <manifest>.json.tombstone marker beside the
live manifest. List operations filter tombstoned IDs; reads return
ErrTombstoned. The chunks themselves stay until repo gc sweeps
them. This makes retention reversible up until the next GC, and means
a misset flag never silently destroys data.
The newest manifest is always kept regardless of policy output.
A misset --keep-for 1m does not leave the deployment with zero
backups.
pg_hardstorage rotate db1 --policy simple --keep-for 7d --apply
pg_hardstorage rotate db1 --policy count --keep-fulls 5 --apply
pg_hardstorage rotate db1 --policy gfs \
--keep-daily 7 --keep-weekly 4 --keep-monthly 12 --keep-yearly 5 \
--applyA legal hold pins one backup against retention regardless of policy:
pg_hardstorage hold add db1 <backup-id> --repo ... \
--holder ops@acme --reason "GDPR Art 17 #4421"
pg_hardstorage hold list --repo ...
pg_hardstorage hold remove db1 <backup-id> --repo ... --yesrotate --apply filters held backups before SoftDelete and reports
them under held / held_ids in the result body.
Two tiers:
pg_hardstorage verify db1 latest --repo ...
pg_hardstorage verify db1 <backup-id> --repo ... --sample 1000Validates the manifest's Ed25519 signature, then for each referenced
chunk: read from CAS, decrypt with the resolved KEK, verify the
plaintext SHA-256 against the manifest entry. --sample N caps the
chunk count for spot-checks. No PG, no restore, no WAL replay. Exit 9
on mismatch.
verify --full performs a full restore into a Docker sandbox and runs
pg_verifybackup against it (requires Docker locally). To dispatch the
sandbox verify to an agent instead of running Docker on your host, pass
--control-plane <url> — it always implies --full semantics.
In addition, the pg_verifybackup gate runs automatically after every
restore — that is the integration test. Skip it with --verify=skip
only after acknowledging that exit 9 is the contract.
--verify=auto (default) runs pg_verifybackup if the binary is on
$PATH. --verify=require returns usage.no_pg_verifybackup (exit 2)
when the binary is missing.
The full SHA round-trip across every chunk in the repo:
pg_hardstorage repair scrub <repo-url>Mismatches surface as verify.scrub_mismatch (exit 9). Schedule this
weekly on hot repos; monthly is fine for cold archive repos.
pg_hardstorage repo init file:///srv/backups
pg_hardstorage repo init 's3://acme-backups/?region=us-east-1'
pg_hardstorage repo init 's3://minio/?endpoint=https://minio.acme.example.com&path_style=true'Idempotent on URL: a second init returns conflict.repo_exists
(exit 7). Concurrent inits race-safely — exactly one wins.
pg_hardstorage repo check file:///srv/backupsComposite health pass: HSREPO sanity, manifest signatures, chunk
reference completeness, tombstone hygiene. Missing-chunk findings
flagged verify.missing_chunks (exit 9).
pg_hardstorage repo gc file:///srv/backups # dry-run
pg_hardstorage repo gc file:///srv/backups --apply # delete orphansWalks every manifest (including tombstoned), builds the live chunk
set, lists everything in chunks/sha256/ and reports the difference.
Result body carries bytes_reclaimable (dry-run) or bytes_reclaimed
(applied).
pg_hardstorage repo usage file:///srv/backupsBytes by category — chunks, primary manifests, replica manifests, trash, WAL, audit. Useful for explaining the bill.
repo scrub is the periodic bit-rot check: it samples a percentage of
referenced chunks and re-hashes each against its key, surfacing any
mismatch as a "storage backend corrupted bytes" finding.
pg_hardstorage repo scrub file:///srv/backups # 1% sample
pg_hardstorage repo scrub file:///srv/backups --full # every chunk--sample-percent defaults to 1 (1% per run) — the operator-friendly
cadence for an hourly cron. Pass --full (shorthand for
--sample-percent 100) for exhaustive quarterly checks. Mismatches map
to exit 9 so a cron-wired scrub alarms when integrity slips; findings
are also captured in the hash-chained audit log.
pg_hardstorage wal stream db1 \
--pg-connection 'postgres://pgbackup@db1.example.com/postgres' \
--repo file:///srv/backupsLong-running. Connects with replication=database, runs
START_REPLICATION SLOT pg_hardstorage_db1 PHYSICAL, assembles
16 MiB WAL segments in memory, chunks each through the CAS, commits
a per-segment manifest atomically. Sends Standby Status Update
keepalives every 5 seconds.
The slot is created on first run (CREATE_REPLICATION_SLOT pg_hardstorage_db1 PHYSICAL RESERVE_WAL). It is treated as
permanent — PG holds WAL on disk until the agent ACKs. That means
killing this process for a long time WILL bloat the primary's
pg_wal/. Either keep it running or wal repair to reset.
pg_hardstorage wal repair db1 # recreate the slot
pg_hardstorage repair slot db1 # alias for the same thingwal repair drops and recreates the slot. The new slot's
restart_lsn is whatever PG has for the latest committed WAL; if
that is ahead of the agent's last confirmed LSN, the gap is
detected and reported as wal_gap_detected. The repo's WAL inventory
records the gap explicitly; PITR inside the gap window is refused.
If you also want belt-and-suspenders archive_command (some regulated environments double-archive), the binary doubles as a shim:
archive_command = '/usr/bin/pg_hardstorage wal push db1 %p --repo file:///srv/backups'
wal push ships: PG invokes it from archive_command, and it reads
the %p segment, chunks it through the CAS, and commits a segment
manifest atomically. Re-pushes of an already-committed segment are
no-ops.
The segment's system_identifier (stamped on every manifest so
cross-cluster contamination is detectable) is derived without a libpq
round-trip: it reads xlp_sysid from the segment's first-page header,
or you may pass --system-identifier <decimal> (the unsigned-decimal
form pg_control_system() reports) to skip the header parse. Pass
--tde when the source PG has Transparent Data Encryption enabled —
the on-disk segment is ciphertext, so header parsing is skipped and you
must supply --system-identifier or --pg-connection.
Exit-code contract: 0 on success (including already-present), >0 on
error, whereupon PG retries per archive_timeout. Run
pg_hardstorage wal push --help for the full flag set.
Already wired into restore's recovery file generation. PG runs:
restore_command = 'pg_hardstorage wal fetch <deployment> %f %p --repo <url>'
wal fetch exits 0 on success, 6 (notfound.wal_segment) on a
segment that isn't in the repo — which PG interprets as "no more WAL,
recovery is done."
Three layers:
- Local KEK at
<keyring>/kek.bin(mode 0600). Generated byinitunless--no-encrypt. - Per-backup DEK (256-bit random), wrapped under the KEK and
stored in the manifest's
encryption.wrapped_dekfield. - Per-chunk key derived
Kc = HKDF-SHA256(BDEK, info=chunk_hash). Cipher: AES-256-GCM. The on-disk envelope is[version=0x02][compression-algo][encryption-algo][12-byte nonce][payload]so each chunk is self-describing.
The chunk key is the plaintext SHA-256, so dedup-within-key still works across compression posture, encryption setting, and re-runs.
pg_hardstorage kms rotate \
--repo s3://acme-backups/ \
--old-kek-ref <ref> --old-kek-file old-kek.bin \
--new-kek-ref <ref> --new-kek-file new-kek.bin \
--applykms rotate ships: it walks every committed manifest wrapped with
--old-kek-ref, decrypts the wrapped DEK with --old-kek-file,
re-wraps it under --new-kek-file, records --new-kek-ref, re-signs,
and atomically rewrites the manifest. Chunks are not re-encrypted —
per-chunk keys derive from the unchanged BDEK, so rotation is
O(manifest count). Default mode is dry-run; pass --apply to actually
rewrite. Manifests under other KEK refs (other tenants) are skipped,
and an interrupted rotation is safely re-runnable.
pg_hardstorage kms shred \
--repo s3://acme-backups/ \
--require-approval <approval-id> \
--confirm-keyring <keyring-dir> \
--reason "GDPR Art 17 #4421" --yeskms shred ships: it destroys the local KEK, after which every backup
whose DEK was wrapped with it is permanently unrecoverable — the audit
log entry is the compliance artefact. The op is gated by three
independent safety mechanisms: a mandatory n-of-m approval workflow
(--require-approval <id> is required; shred is refused without an
approved gate), a typed-confirmation flag (--confirm-keyring, where
you repeat the literal keyring directory path), and an acknowledgement
flag (--yes). Use --dry-run to enumerate the affected backups
without destroying the KEK or requiring the gates.
pg_hardstorage kms inspectRead-only. Lists each file in the keyring: presence, mode, size,
mtime, public-key SHA-256 fingerprint. Private-key bytes and KEK
bytes are NEVER read. Surfaces a Warning on a private-key file with
mode more permissive than 0600 (the cp -r footgun).
Append-only Merkle hash chain. Each event is a canonical-JSON record; the hash links to the previous event's hash, so any tamper is detectable.
pg_hardstorage audit append backup.completed --repo file:///srv/backups --reason "manual record"
pg_hardstorage audit search --deployment db1 --since 30d --action restore
pg_hardstorage audit verify-chain --repo file:///srv/backupsverify-chain walks the entire chain and surfaces two finding types:
hash mismatch (one event's hash field doesn't match the canonical
hash of its content) and chain break (an event's prev doesn't
match the actual hash of the previous event). Either fires
verify.audit_chain_broken (exit 9).
Files live at audit/<yyyy>/<mm>/<dd>/<seq>-<id>.json under the repo
root. Schema: pg_hardstorage.audit.v1.
Default behaviour:
- TTY →
text(human-readable, ANSI colour, ASCII tables) - non-TTY →
json(single object, schemapg_hardstorage.v1)
Override with -o:
-o text # force text
-o json # single object, or array for list commands
-o ndjson # newline-delimited; mandatory for streaming commands
-o yaml # same schema as JSON, YAML-encoded
-o template # Go template via --template '{{.result.body.backup_id}}'Or with the env var:
PG_HARDSTORAGE_OUTPUT=json pg_hardstorage status--quiet suppresses non-essential progress lines. --no-color
disables ANSI in text mode. The text renderer also honours the
de-facto NO_COLOR and CLICOLOR_FORCE env vars.
The JSON wrapper is stable:
{
"schema": "pg_hardstorage.v1",
"command": "backup",
"generated_at": "2026-04-28T14:21:08Z",
"result": {
"body": { "...command-specific..." }
}
}24-month backward-compatibility commitment: scripts written against v0.1 keep working through v1.0+.
Errors in JSON mode are JSON too:
{
"schema": "pg_hardstorage.v1",
"error": {
"code": "wal.slot_missing",
"message": "Replication slot 'pg_hardstorage_db1' is not present.",
"suggestion": {
"human": "Recreate the slot.",
"command": "pg_hardstorage wal repair db1"
}
}
}Exit codes are stable: 0 ok, 1 generic error, 2 misuse, 3 auth, 4 pre-flight failed, 5 aborted by user, 6 not found, 7 conflict, 8 storage/KMS unreachable, 9 verify failure, 10 doctor issues.
A sink is an asynchronous output plugin that fans events out to an
external system. Configured declaratively in pg_hardstorage.yaml:
sinks:
- name: ops-slack
plugin: slack
config:
webhook_url: https://hooks.slack.com/services/T/B/X
filter:
min_severity: warning
components: ["backup", "wal.stream", "verify", "kms"]
- name: prod-syslog
plugin: syslog
config:
protocol: tls # tls | tcp | udp
address: siem.example.com:6514
facility: local6
filter:
min_severity: notice
- name: ops-webhook
plugin: webhook
config:
url: https://alerts.example.com/hooks/pg-hardstorage
authorization: "Bearer kms-secret://ops/webhook-token"
- name: ops-email
plugin: email
config:
smtp_host: smtp.example.com
smtp_port: 587
tls_mode: starttls # starttls | implicit | none
auth_mode: plain # plain | login | none
username: pg-hardstorage
password_secret: kms-secret://ops/smtp-password
from: backups@example.com
to: ["dba@example.com"]
cc: ["ops@example.com"]Sinks shipped in v0.1: slack, webhook, syslog (UDP/TCP/TLS,
RFC 5424, octet-counted RFC 6587 framing on stream transports),
email (plain SMTP, three TLS modes, three auth modes), jira,
opsgenie, pagerduty. Severity floor is RFC 5424
(emergency=0 … debug=7). Sinks emit when an event's severity is
≤ the floor. Component allow/deny filters compose with severity.
A sink that panics is recovered; siblings still receive the event; a diagnostic line goes to stderr.
pg_hardstorage.yaml resolved in order:
--config <path>(explicit override)$PG_HARDSTORAGE_CONFIG_DIR/pg_hardstorage.yaml- XDG:
~/.config/pg_hardstorage/pg_hardstorage.yaml - FHS:
/etc/pg_hardstorage/pg_hardstorage.yaml
Drop-ins under <config>/conf.d/*.yaml merge in lex order; later
wins for scalars, sinks append, deployments overlay by name.
Realistic example:
schema: pg_hardstorage.config.v1
deployments:
db1:
pg_connection: postgres://pgbackup@db1.example.com/postgres
repo: file:///var/lib/pg_hardstorage/repo
retention:
policy: gfs
keep_daily: 7
keep_weekly: 4
keep_monthly: 12
keep_yearly: 5
schedule:
backup: { every: "6h" }
rotate: { daily_at: "04:00" }
classification: confidential
db2:
pg_connection: postgres://pgbackup@db2.example.com/postgres
repo: 's3://acme-backups/?region=eu-central-1'
retention:
policy: simple
keep_for: 30d
schedule:
backup: { daily_at: "02:00" }
sinks:
- name: ops-slack
plugin: slack
config:
webhook_url: https://hooks.slack.com/services/T/B/X
filter:
min_severity: warningpg_hardstorage doctor validates the resolved config and prints
the path it loaded from. Useful when drop-ins surprise you.
For symptom-keyed diagnoses see troubleshooting.
For full-incident playbooks see
runbooks/. For the JSON schema and
exit-code contract see api and the
manpage at man/man1/pg_hardstorage.1.