OBM

SSH backups for servers with the database manager off

Odoo 17+ refuses /web/database/backup when list_db = False. On the Agency plan OBM sshs in and streams a pg_dump -Fc instead: key setup, the peer-auth requirement, host-key pinning, what it means for restores, and every error message you might see.

OBM's normal path is agentless: it calls Odoo's own /web/database/backup endpoint over HTTPS and nothing is installed on your servers. Some hardened instances make that impossible. For those, the Agency plan can log in over SSH and stream a pg_dump instead. This page is the whole feature: when to reach for it, how to set it up safely, and what changes about your restores if you do.

Why this exists

Odoo 17 and later check the requested database against the database listing before dumping it. That means an instance configured with list_db = False — the standard hardening advice for a production Odoo — cannot be backed up over HTTP at all. The endpoint doesn't merely hide the database picker; it refuses the backup, and it refuses it with the same generic error page Odoo returns for a wrong master password.

Until now the only honest answer was "turn list_db back on, or back that server up out of band". SSH backups are the third option: OBM connects to the Odoo host as a user you nominate, runs pg_dump there, and streams the result straight back — no list_db, no database manager, no agent to install or patch.

What you get, and what you give up

This is the part to read twice, because it changes what a restore looks like.

  • An SSH backup is always a pg_dump -Fc custom-format dump. Not a zip. The per-database "include filestore" setting is ignored on an SSH instance, because the filestore simply isn't reachable over this channel — OBM asks PostgreSQL for the database and nothing else.
  • No filestore means no attachments. Everything in the database is there: records, settings, and any attachment Odoo was configured to keep in the database. Files that live on disk under filestore/ — most attachments, website assets — are not. If you need those, back them up alongside OBM (rsync, restic, a snapshot of the volume), or use the HTTP path on an instance where you can.
  • Restores still go over HTTP. This surprises people, and it's the good kind of surprise: Odoo's /web/database/restore endpoint works fine with list_db = False — only backup is gated on the listing. So one-click restores keep working on SSH instances, and OBM keeps using the master password for them. Register the instance's real master password even if every backup goes over SSH.
  • Verification differs. A zip is verified by CRC-checking every member and reading its manifest; a dump is verified by its PGDMP header. Both record a SHA-256. See troubleshooting for what a failure means.
  • Discovery won't work. Live database discovery uses the same listing endpoint Odoo is refusing, so it will report the listing as disabled. Type the database names in by hand when you add them — that's the expected flow here, not a bug.

1. Generate a dedicated key pair

Do not reuse your own key, and do not reuse a deploy key. OBM should hold a credential that does exactly one job and can be revoked without disturbing anything else.

# on your own machine — a key pair used by nothing but OBM
ssh-keygen -t ed25519 -C "obm-backup" -f ./obm_backup -N ""

# ./obm_backup      -> the PRIVATE key: paste this into OBM
# ./obm_backup.pub  -> the PUBLIC key: install this on the Odoo host

The private key is stored encrypted at rest and is write-only: once saved, the API will never hand it back — not to you, not to anyone. Rotating means pasting a new one.

2. Install the public key on the Odoo host

# on the Odoo host, as the user OBM will log in as (usually: odoo)
install -d -m 700 ~/.ssh
cat >> ~/.ssh/authorized_keys <<'KEY'
restrict,from="203.0.113.10" ssh-ed25519 AAAAC3Nza...  obm-backup
KEY
chmod 600 ~/.ssh/authorized_keys

The two options in front of the key are the least-privilege part, and both are worth keeping:

  • restrict disables port forwarding, agent forwarding, X11 and pty allocation. OBM needs none of them.
  • from="…" limits the key to the address that backups come from. Ask us for the current egress address of the worker fleet before you set this — and remember to update it if we tell you it changed.

OBM runs exactly two commands over this connection, and never anything else:

true                      # test-connection, and the TOFU probe before a dump
pg_dump -Fc -d 'mydb'     # the backup itself — stdout is streamed to OBM

The database name is validated against a strict pattern and shell-quoted before it reaches the command line, so a database name can never turn into a second command. If you want to go further and force a wrapper script with command="…" in authorized_keys, it must honour $SSH_ORIGINAL_COMMAND — both of the above have to keep working, including the plain true.

3. Make sure the SSH user can actually run pg_dump

This is the requirement people miss, and it is the one that produces the ugliest error. OBM runs pg_dump -Fc -d <database> and nothing more: no host, no user, no password. Everything about authenticating to PostgreSQL has to already be true for that user, on that host. Prove it before you touch OBM:

# log in exactly as OBM will, and prove pg_dump works with no arguments
# beyond the database name — no -h, no -U, no password prompt
ssh -i ./obm_backup [email protected] 'pg_dump -Fc -d mydb | wc -c'

# a number in the tens of megabytes = you are done
# "fe_sendauth: no password supplied" or "role ... does not exist" = not yet

The usual reason this works out of the box is peer authentication: you log in as the odoo unix user, PostgreSQL is on the same machine, and the unix account maps to a PostgreSQL role that owns the database.

# /etc/postgresql/16/main/pg_hba.conf — the line that makes it work
local   all   all   peer

# ... and the unix user must map to a postgres role that can read the db:
sudo -u postgres createuser --superuser odoo   # if it doesn't exist yet

If PostgreSQL lives elsewhere or wants a password, use a .pgpass file:

# alternative when Postgres is on another host / uses password auth
# ~/.pgpass on the Odoo host, owned by the SSH user, mode 0600
db.internal:5432:*:odoo:the-password

# PGHOST/PGUSER must reach a NON-INTERACTIVE ssh command, so set them where
# one is actually read — e.g. ~/.bashrc (bash sources it for sshd-run
# commands) or ~/.ssh/environment with PermitUserEnvironment=yes in sshd_config

Also check that pg_dump is on the PATH of a non-interactive SSH command — ssh host 'command -v pg_dump' answers that in one line — and that its version is at least as new as the server it dumps.

4. Enable SSH on the instance and test the connection

On the instance, switch on SSH backups and fill in the host, the port (22 unless you moved it), the username, and the private key. All four are required together: saving a partial configuration is refused with "SSH backups need a host, a username and a private key", and enabling the feature at all on a non-Agency plan is refused with "SSH backups require the Agency plan".

Then press Test connection. This is not optional politeness — it is how the server's identity gets recorded.

Host-key pinning (trust on first use)

OBM does not consult a known_hosts file. It keeps exactly one trusted fingerprint per instance and enforces it itself.

  • First successful test-connection pins the key. Reopen the instance afterwards and it shows Pinned host key: SHA256:…. Compare that string against the host's real fingerprint the first time — ssh-keyscan -t ed25519 host | ssh-keygen -lf -, run somewhere you trust. That comparison is the entire security value of trust-on-first-use; skipping it means trusting whatever answered.
  • Every later connection must match. A mismatch aborts immediately with "SSH host key changed — re-verify the server and re-run test-connection to re-pin", and the backup fails. It is never retried automatically, because a changed host key is either a rebuild you know about or an attack you need to know about — a machine shouldn't guess which.
  • Changing the host or the username clears the pin and the next successful connection re-pins. Changing the port or rotating the key does not: same server, same identity.
  • To re-pin after a legitimate rebuild: verify the new fingerprint out of band first. If the host or username moved at all, the pin is already gone — just run test-connection again. If the server came back at the same address under the same username, there is no self-service "trust the new key" button; e-mail support and we will clear the pin.

If a backup is the first thing that ever connects — you enabled SSH and a schedule fired before you tested — the worker learns the fingerprint, pins its own dump connection to it so both connections have to agree, and reports it back to be recorded. It still works; you just skipped your chance to check the fingerprint by hand.

Errors you may see, and what each one means

  • "SSH backups require the Agency plan" — the feature is Agency-only. An org that downgrades keeps its SSH configuration, but new SSH backups are refused rather than quietly falling back to HTTP.
  • "SSH backups need a host, a username and a private key" — a partial configuration was submitted. Fill in all three.
  • "the stored SSH private key could not be parsed" — the pasted key isn't a key OpenSSH would accept. Paste the whole file including the -----BEGIN… and -----END… lines, and make sure it is the private key, not the .pub.
  • "SSH authentication failed (key rejected by the server)" — the server answered but refused the key. Check the public key really is in that user's authorized_keys, that ~/.ssh is mode 700 and the file 600, that the account isn't locked, and that a from="…" restriction isn't excluding us. This one is retried automatically, so it will fail a few times before it settles.
  • "cannot reach SSH host (…)" — nothing answered: wrong host or port, a firewall, or the machine is down. The class name in the parentheses (ConnectionRefusedError, TimeoutError, …) tells you which. Retried automatically.
  • "SSH host key changed — re-verify the server and re-run test-connection to re-pin" — the pin didn't match. See above. Never retried.
  • "SSH connected but could not execute commands" — the login works but the shell doesn't run anything. Usually a forced command in authorized_keys, or a shell set to /usr/sbin/nologin.
  • "pg_dump exited with status 1: …" — the real PostgreSQL error follows the colon, truncated. "role does not exist" or "no password supplied" means step 3 isn't done. "database does not exist" means the registered database name doesn't match what is on the server — likely a typo, since discovery can't fill it in for you. "server version mismatch" means the host's pg_dump is older than its PostgreSQL.
  • "ssh dump timed out after 3600s" — the dump exceeded the transfer timeout. See the timeout entry in troubleshooting.
  • "ssh dump failed mid-stream (…)" — the connection died partway. The partial file is discarded, never stored; nothing half-written is ever recorded as a backup.
  • "verification failed: missing PGDMP header — not a pg_dump custom-format archive" — bytes arrived, but they aren't a dump. Almost always a shell profile on the Odoo host printing a banner into stdout, which corrupts the stream. Make the SSH user's startup files silent for non-interactive sessions.

Restoring an SSH backup

One click from the dashboard, into any instance you have registered — the upload goes to Odoo's /web/database/restore, which accepts a custom-format dump as happily as a zip. What you get back is the database only: expect missing attachments until you restore the filestore separately.

The manual path is the standard one, and needs nothing from OBM:

# what OBM stores is a plain custom-format dump, so the manual path is:
createdb -O odoo mydb_restored
pg_restore --no-owner --role=odoo -d mydb_restored mydb-20260728.dump

Both paths are covered in full in Restore a backup, including what to do when a proxy rejects a large upload.

Next steps