Zum Hauptinhalt springen

Encrypted Offsite Backup (restic)

The offsite service pushes an encrypted, deduplicated copy of a project's database and filestore off the machine. Encryption happens here, before anything leaves the container — the storage side only ever sees ciphertext.

Since 2026-08 this runs on restic. It replaced BorgBackup; there is deliberately only one mechanism, so nobody has to work out which of two is active on a given machine.

The shape of it

Odoo machine backup server
┌───────────────────────────────┐ ┌──────────────────────────┐
│ offsite container │ │ rest-server │
│ restic backup │──── HTTPS ────▶│ --append-only │
│ (encrypts + deduplicates) │ port 8000 │ --private-repos │
└───────────────────────────────┘ └───────────┬──────────────┘
sources (read-only): │
/source/pgbackrest pgBackRest repo (WAL + backups) ▼
/source/filestore this database's filestore <area>/db
/source/dumps optional / the fresh dump <area>/files

Two repositories per area

A run writes into two repositories under the same customer area:

RepositoryContentsSnapshot tags
<area>/db/pgBackRest repository, or the database dumpzodoo,db
<area>/files/the filestore of this databasezodoo,files

The reason is monitoring, not tidiness. With everything in one repository, an arriving filestore hides a database dump that stopped coming: the area looks freshly written, and the part that matters is missing. Split, each stream has its own age, and the backup server alarms per stream — the mail then says which half stopped.

Both live in the same area and therefore share access credentials and passphrase. It stays one secret per project; two passphrases would be two things to lose, neither protecting anything the other does not.

OFFSITE_LAYOUT=flat restores the old single-repository behaviour. It exists for legacy installations that should not be moved, not as a preference.

Three properties matter, and they are the reason for this setup:

  • The storage side cannot read the backup. restic encrypts on the source machine. Whoever holds the disk — a provider, a foreign NFS export, a stolen drive — sees ciphertext.
  • The source machine cannot delete the backup. rest-server --append-only accepts writes and refuses removals. A compromised Odoo host can therefore not destroy the history. This is the part a plain NFS mount can never provide, and it is the main reason the target is our own server rather than a share.
  • One customer cannot see another. With --private-repos every account is confined to the path matching its username; anything else answers 401.

The trade-off of append-only is that the client can no longer clean up. Retention has to run on the server side, and it has to actually run there — see Retention.

Targets

OFFSITE_REPOWhat it is
rest:https://10.222.0.106:8000/<area>/Our backup server. The normal case, append-only, one area per customer.
sftp:u12345@u12345.your-storagebox.de:23/…Hetzner Storage Box or any SSH target. Key at $HOST_RUN_DIR/offsite/id_ed25519 (mode 0600).
/mnt/somewhere/repoA mounted filesystem. Also set OFFSITE_LOCAL_DIR so the path exists inside the container.

For a path target the run refuses to start when the parent directory is missing. That is on purpose: an unmounted disk looks exactly like an empty repository, and restic would happily create a fresh one on the local disk — which is only discovered when the backup is needed.

What enrollment hands out

The backup server issues no repository key. odoo offsite register receives:

OFFSITE_REST_USER / OFFSITE_REST_PASSWORDan upload account for this area
OFFSITE_WO_URLthe write-only receiver
OFFSITE_WO_RECIPIENT / OFFSITE_WO_DB_RECIPIENTthe two age public keys

Public means these may sit in a settings file and travel over the wire: they encrypt, they decrypt nothing. So a machine set up this way holds no secret that can read its own backup.

That also makes the handover far less delicate than it used to be. The access password is replaceable (restic-area passwd) — lose it and you set a new one. The only irreplaceable secrets are the private age keys, and those are never created here: they live in 1Password and nowhere else.

An older backup server that still issues repo_url and repo_key keeps working — register takes them if they arrive, and the run then uses restic as before.

Setting it up against our backup server

Do not wire this by hand. The machine asks for an area, a human approves it:

odoo offsite register
  1. The first call files a request (area name derived from the project name) and remembers request id + pickup token in $HOST_RUN_DIR/offsite/enroll.json. No credentials exist yet.
  2. An admin opens the backup server's admin page, checks the name and approves. Access password and repo key are created at that moment.
  3. Both are shown exactly once. The admin files them in 1Password and confirms that with a checkbox. Without that confirmation the machine does not get its credentials — a repo key that exists only on the source machine is worthless precisely when it is needed.
  4. Run odoo offsite register again: it collects credentials and the server certificate, writes them into the settings and sets RUN_OFFSITE=1. The server then forgets the repo key.
odoo reload && odoo build offsite
odoo offsite backup # first run, creates the repository

On first contact there is no server certificate on the machine yet. It is fetched, pinned to $HOST_RUN_DIR/offsite/rest-server.crt and its fingerprint printed — the same trust-on-first-use as ssh accept-new. Any later change aborts the connection instead of being silently accepted.

If the machine already has a passphrase — zCICD and the shop generate one per project and keep it in the backend — that passphrase stays the truth and the server creates no second key. Two keys for one repository is a trap, not redundancy.

What gets backed up — and the check that must not be lost

A run collects, from read-only mounts:

  • /source/pgbackrest — the pgBackRest repository (WAL archive + backups), only present with RUN_PGBACKREST=1
  • the filestore of this database (filestore/$DBNAME), not the host-wide pool; on a machine with several instances the pool holds other customers' attachments
  • /source/dumps only with OFFSITE_INCLUDE_DUMPS=1, or the single fresh dump that odoo offsite backup pulls when pgBackRest is off

The run aborts when no database state would end up in the snapshot. The filestore is always there, the database is not — and a snapshot of nothing but attachments looks like a backup until someone tries to restore. This is the failure this system had before; it must survive every future refactor.

OFFSITE_ALLOW_WITHOUT_DB=1 switches the check off. Only use it when the database is provably backed up elsewhere.

And the run aborts when no filestore would end up in the snapshot — for the same reason, in the other direction. A database restores fine without attachments; it is just incomplete, and in Odoo that shows up the first time someone clicks an invoice PDF. An empty or missing filestore directory is what an unmounted volume looks like, not what an empty instance looks like, so an empty directory does not count as a filestore. OFFSITE_ALLOW_WITHOUT_FILES=1 is the deliberate way out for an instance that genuinely has no attachments yet.

Both streams are attempted even when one fails, and the run then reports which. Otherwise a broken database upload would mask that the filestore did not go either, and one alarm would arrive where two belong.

The recommendation is RUN_PGBACKREST=1: it costs nothing extra (it runs on a disk that is already paid for) and adds point-in-time recovery on top.

Write-only filestore backup

The restic path has one property that cannot be configured away: a machine that can back up can also read its own backup history, because deduplication needs the repository index and the index is encrypted (see Keys). A compromised Odoo host therefore reaches not just the live database but every older state as well.

For the filestore that is avoidable, and cheaply, because Odoo already does the hard part: every attachment is named after the SHA-1 of its content (filestore/<db>/05/055ffc5c…). A file is written once and never changes, and "same content" is already "same name" — the deduplication is in the naming. So "what is missing at the far end?" is a pure name comparison, answerable from a local ledger without reading the target at all.

Set both of these and the filestore leaves the restic path:

SettingWhat it is
OFFSITE_WO_URLThe write-only receiver, e.g. https://10.222.0.106:8444/<area>/
OFFSITE_WO_RECIPIENTAn age public key (age1…). Generate with age-keygen; the private key belongs in 1Password and nowhere else
odoo offsite filestore # or automatically as part of `odoo offsite backup`

What the machine can then do, and what it cannot:

restic pathwrite-only path
uploadyesyes
read what it uploadedyes, all of itno — encrypted to a public key it has no private half of
deleteno (append-only)no

When a write-only target is configured it replaces the restic files stream rather than running beside it. Two copies of the same attachments in two places is cost without redundancy, and it makes "which one do I restore from?" a question during an incident.

What travels, and what does not

New files go up as one bundle per run (targzipage), not one object per file: an instance with a million attachments would otherwise mean a million HTTP requests. Alongside it goes a manifest:

{ "run": "20260821T173222Z", "kind": "filestore",
"bundle": "filestore-20260821T173222Z-85914f45a55c.tar.gz.age",
"sha256": "85914f45…", "size": 665623,
"files_added": 56, "files_total": 56, "ledger_sha256": "8f73c96f…" }

The manifest lists bundles, never file names. That is deliberate: a file name is the hash of its content, so a name list at the target would let someone confirm whether a particular known document is in the backup. Bundle names and checksums are enough for the receiving side to notice a missing bundle, which is what completeness means here.

The ledger

$HOST_RUN_DIR/offsite.state/filestore.ledger — one file name per line, appended only after a successful upload. A crash between upload and ledger write costs a repeated upload, never a file that is believed safe but never arrived.

Losing the ledger (volume wiped, machine rebuilt) means the next run uploads the whole filestore again. That is the price of never asking the target what it has; it costs traffic, not data.

Restoring

Needs the age private key from 1Password — this machine cannot do it:

age -d -i filestore.age-key -o bundle.tar.gz filestore-<run>-<sum>.tar.gz.age
tar xzf bundle.tar.gz -C $ODOO_FILES/filestore/<db>/

Unpack every bundle, oldest first. And the filestore checks itself: because each name is the SHA-1 of its content, sha1sum over the restored tree against the file names is a complete integrity check — no manifest, no checksum list, no key required.

The window against the database

WAL/PITR can recover the database to any moment; the filestore is pushed once a night. So an attachment created at 14:00 is in the backup once the night has passed, not before. The rule that follows: the filestore must be at least as new as the database recovery target. An older database with a newer filestore is always safe (a superset); the other way round, attachments are missing. Run the sync more often if the window matters — it is cheap, because it works on names.

Write-only database backup (WAL)

The same move as the filestore, for the half that matters more. With RUN_PGBACKREST=1 the database is captured as backups plus WAL segments, and both are immutable: a WAL object is written once and never changed, a backup directory never changes once pgbackrest has recorded its label in backup.info. So there is nothing to deduplicate — and therefore no need to read the target, which is what forces a readable key onto the machine in the restic path.

Note this is not pgBackRest's own repo-host topology. That one also keeps the key and the delete rights off this machine, but it needs pgbackrest running on the far side. This path uploads to a receiver that only ever stores what it is given and knows nothing about postgres — see 12-pgbackrest.md.

SettingWhat it is
OFFSITE_WO_DB_RECIPIENTage public key for the database stream. Deliberately a different key from the filestore one
OFFSITE_WAL_CRONhow often WAL is pushed. * * * * * — every minute
odoo offsite db # backups + WAL (after the nightly pgbackrest backup)
odoo offsite wal # WAL only, every minute via CRONJOB_OFFSITE_WAL

Two modes because they have different rhythms. WAL goes up every minute, so losing the machine costs a minute of transactions rather than a night. The run is cheap: nothing new means no upload, and it is silent, because it runs 1440 times a day.

Why a separate key from the filestore: the two have different value and are needed separately. "Somebody may restore the filestore but not the database" is a real request, and one shared key cannot express it.

What this buys beyond confidentiality

  • Completeness is checkable without a key. WAL names are a sequence and the manifest declares, in the clear, which segments belong to which backup (wal_start/wal_stop, plus the backup type). In a restic repository the file names are encrypted, so only a key holder could ever notice a broken chain. On the backup server wo-check verifies: every declared object present and the right size, every base backup's begin_wal present, no gap inside a timeline, and manifests that only ever grow — a compromised machine cannot quietly declare less than it did yesterday.
  • Retention becomes possible without a key. Whole generations can be dropped by name. An append-only restic repository cannot be pruned at all, because forget --prune needs the repo key.

Sizes, measured

WAL compresses extraordinarily well because a segment is a fixed 16 MiB regardless of how much is in it:

rawtransferred
quiet WAL segment16 MiB~16 KB (factor ~1000)
busy WAL segment16 MiB~630 KB
base backup (small test DB)9.6 MB datadir5.8 MB

A base backup is a full copy every time, so its frequency is the only real lever on growth. WAL volume is the irreducible part — it is the actual write volume of the database.

The *.partial trap

pg_receivewal writes the segment currently being filled as streaming/<name>.partial. It is incomplete by definition, and uploading it would store a half-written segment under a name that is supposed to mean "complete". Only wals/ is ever read, and *.partial is excluded on top of that.

At a hundred instances

Measured on the real backup server, because guessing is worthless here: it writes 410 MB/s to its local disk and 8.5 MB/s to the NFS store (reads: 70 MB/s), consistent across 1M/8M/32M block sizes. That write figure is the ceiling for all customers together — 30 GB/hour, 184 GB in a six-hour night.

WAL is irrelevant at that scale (16 KB per segment). Base backups are the whole problem: 100 instances × 5 GB compressed = 16 hours, which collides with the next night. Incremental base backups are therefore the condition for a hundred instances, not an optimisation.

It is bandwidth, not locking: 100 concurrent uploads all answered 201, and the areas have separate ledgers and locks. They queue on throughput.

Two things follow for the client:

  • Every instance uploaded in the same second, because they all run * * * * *. The WAL job now waits a stable offset derived from the project name — stable rather than random, so each machine keeps its own second and runs cannot overtake each other.
  • One manifest per run does not scale: hundreds of files per day per area on a busy database, and the completeness check reads the manifests of all areas. Manifests are now one file per day (<date>-db.jsonl), one JSON line per run, appended. Appending fits the write-only model — adding yes, changing and deleting no — so earlier lines stay untouchable and "a source may never declare less than before" still holds.

Concurrency

The minutely WAL job gets its own container name, because docker rejects a duplicate name with a hard error and a job running every minute must not fail every minute while a nightly base backup upload is still going. Serialisation happens inside, on a lock in the state directory, where a busy lock is a quiet success rather than an error.

Nothing is spooled to disk

A base backup and the first filestore bundle are streamed: tar | gzip | age goes straight onto the wire. Spooling them to a file first would need their own compressed size in scratch space - for a 600 GB database that is ~170 GB, and it would land on the container's writable layer, i.e. the system disk.

Streaming means the checksum cannot be known in advance, because age is deliberately not reproducible (a fresh ephemeral key per encryption). So the source hashes what it streams, the receiver hashes what it received, and the two are compared - an end-to-end check that costs no disk. Where a file genuinely is needed (WAL segments, manifests), the scratch directory sits inside the state directory, next to the data rather than on the system disk.

Repeats and starting over

Before uploading, the source asks the receiver whether the object name is already taken (HEAD). That makes a repeat cheap: after a crash between upload and ledger write, or after a deliberate reset, what is already there is recognised and not re-sent.

odoo offsite reset # forget the ledgers; next run offers everything again
odoo offsite reset db # only base backups + WAL
odoo offsite reset filestore # only the filestore

The reset deletes nothing on the receiver - this machine cannot, by design. The old ledgers are renamed rather than removed; they are the only record of what this machine ever reported.

One subtlety worth knowing: an object that was already there is not declared again in the new manifest. Its stored ciphertext was produced by an earlier encryption and therefore has a different checksum than one computed now - declaring our value for those bytes would make the completeness check report a mismatch that is not one. It stays declared by the run that uploaded it, and manifests are read cumulatively.

HEAD answers only whether a name is taken - no content, no size. There is still no GET.

Local space on the machine

pgBackRest writes into its local repository before anything is uploaded, so local space is needed for the local retention window. Three settings decide how much:

SettingDefaultEffect
PGBR_COMPRESS_TYPEzstroughly a third of the raw size at low CPU cost
PGBR_RETENTION_FULL14 (days, ..._TYPE=time)how far back a continuous point-in-time window reaches
PGBR_FULL_CRON / ..._DIFF_CRONweekly / dailyweekly full plus daily differentials, rather than a full every night

The schedule is the real lever. A daily full of a 45 GiB database is about 16 GiB compressed, i.e. 5.8 TiB a year for one instance. Weekly full plus daily differentials costs a fraction of that and restores just as fast, because restore --delta only fetches the files that differ.

Retention needs no job of its own: expire runs as the last step of every backup. That is deliberate — a cleanup that has to be scheduled separately is a cleanup that eventually is not.

Ledger and restoring

$HOST_RUN_DIR/offsite.state/wal.ledger and base.ledger, appended only after a successful upload. Both are written by the container as root, so reading them from the host needs sudo.

Restoring needs the age private key from 1Password:

age -d -i db.age-key -o backup.tar backup-<label>.tar.age && tar xf backup.tar
for f in wal-*.age; do age -d -i db.age-key -o "${f%.age}" "$f"; done

The result is a pgBackRest repository again: put the backup directories under backup/<stanza>/ and the segments under archive/<stanza>/<version>-<n>/, point a pgbackrest.conf at it and run a normal restore.

Unlike the barman path the segments are already compressed by pgbackrest, so there is no gunzip step and the old "a segment must be exactly 16 777 216 bytes" sanity check no longer applies — the compressed size varies.

Switching the restic path off entirely

Once both streams are write-only, restic is not used at all — and then a run must no longer demand OFFSITE_REPO or OFFSITE_PASSPHRASE. That is the point of arriving here: the passphrase is the most expensive secret in the setup, and whoever does not need it should not have to hold it.

odoo setting OFFSITE_REPO=
odoo setting OFFSITE_PASSPHRASE=
odoo reload
odoo offsite backup # runs both write-only streams, nothing else needed

On the backup server the old repositories have to be taken out of monitoring — otherwise nobody writes to them any more and the per-stream "no backup for 48 h" alarm fires from the next day onwards, for ever:

restic-area retire <area>

That moves db/ and files/ aside (.retired-db-<ts>), it does not delete them. Deleting would be irreversible, and the old stock is still worth having until the write-only path has proven itself over time. The area's access stays — the write-only receiver needs it.

Commands

CommandWhat it does
odoo offsite registerRequest a customer area, then pick up credentials after approval.
odoo offsite filestorePush the filestore to the write-only target. Needs no repository key.
odoo offsite dbPush base backups + WAL to the write-only target.
odoo offsite walPush newly archived WAL only. Runs every minute.
odoo offsite backupRun a backup now. Same run as the nightly cron; a quiet no-op without RUN_OFFSITE=1.
odoo offsite initCreate the repository (the first backup does this anyway).
odoo offsite listList snapshots.
odoo offsite infoRepository stats (size, deduplication).
odoo offsite checkVerify integrity by re-reading the data. Takes time and costs traffic.
odoo offsite pruneApply retention. Refused against append-only targets, with an explanation.
odoo offsite restic <args>Escape hatch: any restic command. OFFSITE_STREAM=db|files picks the repository (default db).

list, info, check and init run over both repositories and label which one they are reporting on.

The nightly run is OFFSITE_BACKUP_CRON (default 04:00), deliberately after the pgBackRest backup at 02:00 so it picks up the fresh state instead of yesterday's.

Retention

OFFSITE_KEEP_DAILY / _WEEKLY / _MONTHLY describe what should be kept.

  • Against sftp: or a path target, the run applies them itself after each backup.
  • Against our backup server (append-only) the client cannot — odoo offsite prune says so instead of failing silently. Retention runs on the server, in a maintenance window, with a separate access that is not append-only.

This is the point that gets forgotten in append-only setups: without server-side retention the repository grows without bound — at our volumes in months, not years.

Restoring

Two things are needed: the repository address and the passphrase. There is no separate key file to lose.

export RESTIC_PASSWORD="<passphrase from 1Password>"
BASE="rest:https://<user>:<password>@10.222.0.106:8000/<area>"

# the database
export RESTIC_REPOSITORY="$BASE/db/"
restic --cacert rest-server.crt snapshots
restic --cacert rest-server.crt restore <snapshot> --target /restore

# the attachments
export RESTIC_REPOSITORY="$BASE/files/"
restic --cacert rest-server.crt restore latest --target /restore

Same passphrase for both. Then restore the database from the dump or the pgBackRest catalog, and put the filestore back into $ODOO_FILES/filestore/<db>.

Check the dates of the two snapshots against each other. A filestore much newer than the database is harmless; a database much newer than the filestore means attachments are missing for everything created in between.

A backup that has never been restored is not a backup. Plan the rehearsal; do not wait for the emergency to be the first attempt.

Keys

SecretOperational copyAuthoritative copy
OFFSITE_PASSPHRASEproject settings on the machine (0600)1Password — without it the backup cannot be opened
OFFSITE_REST_PASSWORDproject settings on the machine1Password (same item)
SSH key for sftp: targets$HOST_RUN_DIR/offsite/id_ed25519access credential only, not a data key
Server certificate$HOST_RUN_DIR/offsite/rest-server.crtpublic, gets distributed

One passphrase per project — a single shared one means one leak opens every customer. The passphrase has to sit on the source machine because the cron runs unattended; that is a deliberate, defensible trade-off, since whoever owns the machine already has the live database. The protection is aimed at the storage location and at the integrity of the history.

Troubleshooting

SymptomCause / fix
OFFSITE_REPO is emptyNo target configured. Run odoo offsite register.
OFFSITE_REST_USER is emptyArea credentials missing — the registration never completed.
restic refuses the connection / certificate errorrest-server.crt missing or the server certificate changed. Re-run register; if the fingerprint really changed, find out why before trusting it.
Enrollment service … is unreachableThe enrollment service is only reachable over the zebroo VPN. Is this machine in a VPN group with the backup server?
Backup aborts with "no database state in the backup"Working as intended. Set RUN_PGBACKREST=1, or use odoo offsite backup (pulls a dump itself).
Stale lock after a crash / rebootThe run breaks a hanging lock itself before starting. rest-server permits lock removal even in append-only mode.
Offsite target is a path and the run refuses to startParent directory missing — the disk is probably not mounted. Do not "fix" this by creating the directory.

Internal documentation on the backup server itself (isolation, the enrollment service, monitoring, server-side retention, the 1Password vault) lives in Odoo Knowledge under Backup Plan → restic-backup — zentraler Backup-Server (append-only).