Madalin
Development enhanced by AI

Backups You Can Actually Restore — and Nobody Can Delete

PostgreSQL backups for small businesses with Barman, a restore habit, and a WORM copy.

A server rack with PostgreSQL database icons, backup symbols, and Barman logo arranged in a network diagram showing data replication and archival flow.

Every small business I work with has backups. Almost none of them have ever restored one. And until about two years ago, almost none of them had asked the question that now matters most: if an attacker gets root on your database server tonight, can they delete your backups too?

This article is the setup I use for small PostgreSQL deployments — one to a handful of primaries, the kind of scale where you don’t have a DBA team, you have a person. It covers three things, in increasing order of how often they’re skipped:

  1. A boring, reliable Barman setup
  2. Restore testing as a scheduled habit, not an aspiration
  3. An immutable off-site copy that survives a full compromise

The third one is the point. Backups that can be deleted by the same credentials that run your infrastructure are not disaster recovery — they’re a convenience feature. Ransomware crews know this, which is why the first thing they look for after landing on a server is the backup system.

Why Barman

Barman is open source (GPLv3), maintained by EnterpriseDB, and has been around long enough to be genuinely boring. For a small operation, three properties matter:

It’s pull-based. Barman runs on its own server and connects out to your PostgreSQL primaries. The database server holds no credentials for the backup server. This is your first layer of immutability for free: an attacker who owns your PG host has nothing that lets them touch the backup host.

It’s just files. Base backups and WAL segments sit in /var/lib/barman as plain files. No proprietary format, no console you need a license for. You can copy them, checksum them, and ship them anywhere with standard tools.

It scales down. One small VM can back up several primaries. Adding a new client’s database is one config file in /etc/barman.d/.

The base setup, briefly

I use the streaming method: backup_method = postgres takes base backups over a replication connection with pg_basebackup, and streaming_archiver = on continuously receives WAL with pg_receivewal. No SSH between the machines at all — just a replication-capable PostgreSQL user over your private network.

For zero RPO I also enable the traditional archiver as a second, independent WAL channel. If streaming hiccups, archive_command catches what falls through. One caveat worth knowing: archive_command pushes from the PG host to the Barman host, which means it needs some credential in that direction. Keep it a restricted, forced-command SSH key that can only invoke barman-wal-archive — not a shell. If your RPO tolerance is “a few minutes is fine,” you can skip the second channel entirely and keep the PG host completely credential-free, which is the cleaner security posture.

A per-server config looks like this:

[pg-main]
description = "Main PostgreSQL primary"
conninfo = host=10.0.0.10 dbname=postgres user=barman
streaming_conninfo = host=10.0.0.10 dbname=postgres user=barman
backup_method = postgres
streaming_archiver = on
slot_name = barman_pg_main
create_slot = auto

retention_policy = RECOVERY WINDOW OF 7 DAYS
minimum_redundancy = 1
last_backup_maximum_age = 1d

A recovery window of 7 days means: at any moment, you can restore to any point in time within the last week. For most small businesses that’s the right default — long enough to catch “we deleted the wrong thing on Friday and noticed on Wednesday,” short enough to keep storage honest. (We’ll come back to retention when we talk about GDPR, because immutability and erasure obligations have to be reconciled deliberately.)

Wire barman check pg-main into whatever monitoring you already have. It exits non-zero when something’s wrong — WAL lag, stale backups, broken redundancy. If nothing alerts on it, you don’t have monitoring, you have logs.

The restore habit

Here’s the uncomfortable part. barman check passing every day for a year tells you that backups are being taken. It tells you nothing about whether they restore. Corrupt WAL chains, missing timeline files, a recovery target that doesn’t behave as expected — you find these at restore time, and you get to choose whether that’s during a calm Tuesday drill or during an incident with a client on the phone.

So: schedule a restore. Monthly. Scripted.

The drill is small enough to automate end-to-end on a scratch VM or a throwaway container:

barman recover pg-main latest /var/lib/postgresql/17/restore-test \
  --target-time "2026-08-27 14:00:00+02" \
  --local-staging-path /var/lib/barman/staging

# start a temporary instance on the restored data dir, then verify:
psql -p 5433 -c "SELECT max(created_at) FROM orders;"

Verification is the part people skip. “Postgres started” is not verification. Pick one or two queries that prove recent business data is present — the newest order, the newest user, a row count that should be monotonically increasing — and have the script fail loudly if the answer is older than your RPO. Then tear it down.

Ten to twenty minutes of machine time a month buys you the only evidence that matters: a log line that says restored and verified on this date. If a client or an auditor ever asks how you know your backups work, that log is the answer. Not the backup tool’s marketing page.

Immutability: the layer that changed from optional to mandatory

Now the real subject.

The classic 3-2-1 rule (three copies, two media, one off-site) was written for a world where the threat was fire and disk failure. The modern threat is different: an adversary with your credentials, deliberately destroying backups before encrypting production. Off-site doesn’t help if the off-site copy accepts a delete command. The property you need is that nobody — including you, including root, including your cloud account’s admin — can delete or modify the backup before its retention period expires.

That property is called WORM (write once, read many), and on S3-compatible object storage it’s implemented as Object Lock.

Barman ships with cloud tooling that makes this straightforward. barman-cloud-backup pushes base backups to an S3-compatible bucket, and barman-cloud-wal-archive does the same for WAL. You can run them from the Barman host as a hook, so the flow becomes:

PG primary ──▶ Barman server ──▶ object storage (Object Lock, EU region)
   (pull)        (local copy)         (immutable copy)

Three copies, two locations, and the third copy is untouchable. Setup on the bucket side is two decisions:

1. Enable versioning + Object Lock at bucket creation. On most providers, Object Lock can only be enabled when the bucket is created — plan for it, don’t retrofit.

2. Choose the lock mode. Governance mode lets specially-privileged accounts bypass the lock; compliance mode lets nobody, not even the account owner, until the retention expires. For ransomware protection, compliance mode is the honest choice — governance mode just moves the target to a different credential. The trade-off: if you misconfigure a 90-day lock on the wrong bucket, you’re paying storage for 90 days, full stop. Start with a lock period matched to your recovery window (7–14 days) and extend once the pipeline is proven.

The credential used to write to the bucket should be able to do exactly that: put objects. No delete, no lifecycle changes, no lock configuration. Then even the Barman host being compromised — your second blast radius — costs you nothing but the local copy.

For an EU small business, pick an EU-region provider so residency is not a discussion. Several S3-compatible providers support Object Lock at prices where a small database’s immutable tier costs less per month than a coffee. Check the specific provider’s Object Lock support before committing — S3-compatible does not automatically mean lock-compatible, and this is exactly the feature you don’t want to discover missing during an incident.

The GDPR wrinkle

Immutability and Article 17 (right to erasure) look like they collide: how do you delete someone’s data from a backup nobody can modify?

The reconciliation is retention discipline. Your immutable copies exist for a finite, documented period — the lock expires, lifecycle rules remove the object, and the data is gone on schedule. An erasure request is fulfilled in production immediately and propagates to backups as they age out; your records of processing state exactly that, with the retention period and the justification (integrity and availability, Article 32 — which, note, obliges you to be able to restore. Immutable backups are not in tension with GDPR; they’re arguably required by it). What you cannot do is keep locked backups indefinitely — infinite retention turns a security control into a compliance liability.

This tension — and the patterns for resolving it, including crypto-shredding for longer retention needs — deserves its own article. It’s coming.

What this costs

For a typical small-business database (tens of GB, moderate write volume): one small VM for Barman, a few euros a month of object storage for the immutable tier, and the monthly restore drill running unattended. The entire stack is open source. There is no per-server licensing, no agent fleet, no vendor whose acquisition you need to worry about.

Compare that against the going rate of a ransomware incident where the backups went down with the ship, and this is the cheapest insurance you will ever configure.

The habit, restated

Backups are taken by software. Restores are proven by habit. Deletion is prevented by architecture. A small business doesn’t need an enterprise backup platform — it needs those three sentences implemented, and everything in this article fits on one small VM and one locked bucket.

Next in this thread: what “immutable” actually means across the rest of your stack — object lock modes in depth, append-only patterns for file-level backups, and the GDPR erasure question done properly.


Notes for editing (delete before publish):

  • Verify current Barman version and whether barman-cloud-* hook behavior has changed in the latest release before publishing.
  • If naming a specific object-storage provider, confirm their Object Lock support and EU region on their current docs — I have not verified any specific provider’s current feature set here.
  • Optional: add your real (anonymized) storage numbers from the WAL-amplification modeling as a sidebar — the 75/25 vs 25/75 write/update scenarios make a nice concrete table.
Madalin

Madalin

AI integrator

🚀 Senior Architect | SRE & Database Expert | AI Orchestrator 👋 Building the future at the speed of thought. ⚡️ I don't just write code; I architect high-performance, bulletproof ecosystems. With a foundation in Systems Engineering and a mastery of Go and TypeScript, I bridge the gap between heavy-duty backend reliability and seamless, high-conversion frontends.

Continue the conversation

If this article reflects the challenges your organisation is navigating, explore more practical guidance across Madalin.