Backups, upgrades and operations

Odoo backups to S3, and how to restore them

Odoo backup done right: database plus filestore, pg_dump formats, a cron script to S3, retention, encryption, restore drills and staging neutralisation.

CICDoo Engineering Updated 8 min read

The short answer

An Odoo backup is two things taken together, the PostgreSQL database and the filestore directory under data_dir/filestore/<db>. Dump the database with pg_dump in custom format, archive the matching filestore, encrypt both, copy them to S3-compatible storage on a schedule with lifecycle-based retention, and prove it works with regular restore drills. Neutralise any restored copy before using it as staging.

On this page
  1. What an Odoo backup actually contains
  2. Two ways to back up: the zip or pg_dump
  3. A backup script to S3-compatible storage
  4. What else to keep with the backup
  5. Retention
  6. Encryption
  7. Restore steps
  8. Restore drills
  9. Neutralising a restored copy for staging
  10. Doing this with CICDoo

What an Odoo backup actually contains

Odoo keeps its data in two places, and a usable backup needs both:

  1. The PostgreSQL database: every record, configuration, user, journal entry and stock move.
  2. The filestore: attachments, product images, uploaded documents, generated PDFs and asset bundles, stored as files under data_dir/filestore/<database_name>. On a package install that is usually /var/lib/odoo/.local/share/Odoo/filestore/<db> or /var/lib/odoo/filestore/<db> if you set data_dir = /var/lib/odoo. In the official Docker image it is /var/lib/odoo/filestore/<db>.

The database references filestore entries by checksum. A database restored without its filestore opens, but images, attachments and sometimes the web client assets are missing. Assets regenerate on their own; customer documents do not. Take both, close together in time, and keep them as a pair.

Two ways to back up: the zip or pg_dump

The database manager zip

Odoo's database manager (/web/database/manager, or a POST to /web/database/backup) produces a .zip containing dump.sql (a plain SQL dump), the filestore/ directory and a manifest.json with the Odoo version and installed modules. It is convenient for small databases and for moving a database between Odoo servers, and it can be scripted:

curl -fsS -X POST \
  -F "master_pwd=${ODOO_MASTER}" -F "name=erp" -F "backup_format=zip" \
  -o erp.zip https://erp.example.com/web/database/backup

Its limits matter in production. The whole backup is built by an Odoo worker, so it is bound by limit_time_real and limit_memory_hard and fails on large databases. It needs the database manager reachable, which conflicts with list_db = False and good hardening. And the plain SQL dump inside cannot be restored in parallel.

pg_dump plus a filestore archive

For anything beyond a few gigabytes, dump the database with PostgreSQL's own tools and archive the filestore separately. This does not depend on Odoo being up, and it restores faster.

pg_dump format Flag Compressed Parallel dump Parallel restore Use it for
Plain SQL -Fp No (pipe to gzip) No No Reading, grepping, tiny databases
Custom -Fc Yes No Yes (pg_restore -j) The default choice
Directory -Fd Yes Yes (-j) Yes Large databases, fastest
Tar -Ft No No No Rarely useful

Use -Fc unless the database is large enough that dump time matters, then use -Fd -j 4. pg_dump takes a consistent snapshot, so it is safe to run while Odoo is serving users. The filestore archive is not transactional, but because Odoo never modifies a stored file in place (new content gets a new checksum path), an archive taken right after the dump is consistent enough in practice. At worst it contains a few files newer than the dump, which is harmless.

A backup script to S3-compatible storage

This script works with AWS S3 and any S3-compatible service (Backblaze B2, Wasabi, Cloudflare R2, MinIO, Scaleway, Hetzner Object Storage) through --endpoint-url. It dumps, archives, encrypts with age, uploads, and cleans up.

#!/usr/bin/env bash
# /usr/local/bin/odoo-backup.sh
set -euo pipefail

DB="erp"
FILESTORE="/var/lib/odoo/filestore/${DB}"
WORK="/var/backups/odoo"
BUCKET="s3://acme-odoo-backups"
ENDPOINT="https://s3.eu-central-1.amazonaws.com"
AGE_RECIPIENT="age1examplepublickeyreplacewithyourown"
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
TIER="daily"
[ "$(date -u +%u)" = "7" ] && TIER="weekly"
[ "$(date -u +%d)" = "01" ] && TIER="monthly"

mkdir -p "$WORK"
cd "$WORK"

# 1. database, custom format
sudo -u postgres pg_dump -Fc -Z 6 -d "$DB" -f "${DB}-${STAMP}.dump"

# 2. filestore
tar -C "$(dirname "$FILESTORE")" -czf "${DB}-${STAMP}-filestore.tar.gz" "$(basename "$FILESTORE")"

# 3. encrypt with a public key (the private key is NOT on this server)
for f in "${DB}-${STAMP}.dump" "${DB}-${STAMP}-filestore.tar.gz"; do
  age -r "$AGE_RECIPIENT" -o "${f}.age" "$f"
  rm -f "$f"
done
sha256sum ./*"${STAMP}"*.age > "${DB}-${STAMP}.sha256"

# 4. upload
aws s3 cp "$WORK" "${BUCKET}/${TIER}/${STAMP}/" --recursive \
  --exclude "*" --include "*${STAMP}*" --endpoint-url "$ENDPOINT"

# 5. clean local copies
rm -f ./*"${STAMP}"*
echo "backup ${STAMP} ok (${TIER})"

Schedule it with cron or a systemd timer, and send the result somewhere a human will see a failure:

# /etc/cron.d/odoo-backup
15 2 * * * root /usr/local/bin/odoo-backup.sh >> /var/log/odoo-backup.log 2>&1 || curl -fsS https://hc.example.com/fail

If you prefer rclone, replace step 4 with:

rclone copy "$WORK" "remote:acme-odoo-backups/${TIER}/${STAMP}/" --include "*${STAMP}*"

rclone also offers a crypt remote that encrypts transparently, which can replace the age step.

For Docker installs, run the dump through the database container (docker compose exec -T db pg_dump -U odoo -Fc erp) and read the filestore from the Odoo volume, as shown in the Odoo Docker Compose guide.

What else to keep with the backup

The database and filestore are enough to recover the data, but not to rebuild a working server quickly. Keep these under version control or in the same secure store:

  • odoo.conf and any environment files, including admin_passwd.
  • The exact code: the Odoo version or image digest, and the commit of every custom and third-party addons repository. A database restored against different module code may fail to load or behave differently.
  • Reverse proxy and system config: nginx site files, systemd units or docker-compose.yml, cron entries.
  • Secrets that are not in the database: API keys passed as environment variables, the decryption key for the backups themselves.

Retention

Let the storage expire old backups rather than the script. Deleting from a script means a compromised server can also delete your history. With the daily/, weekly/ and monthly/ prefixes above, an S3 lifecycle configuration does the job:

{
  "Rules": [
    { "ID": "daily",   "Filter": { "Prefix": "daily/" },   "Status": "Enabled", "Expiration": { "Days": 14 } },
    { "ID": "weekly",  "Filter": { "Prefix": "weekly/" },  "Status": "Enabled", "Expiration": { "Days": 60 } },
    { "ID": "monthly", "Filter": { "Prefix": "monthly/" }, "Status": "Enabled", "Expiration": { "Days": 400 } }
  ]
}

Apply it with aws s3api put-bucket-lifecycle-configuration --bucket acme-odoo-backups --lifecycle-configuration file://lifecycle.json. Adjust the numbers to your own legal requirements: accounting data often has statutory retention periods of several years.

Two more protections are worth the effort:

  • Write-only credentials. Give the backup server an access key that can PutObject but not DeleteObject or change lifecycle rules.
  • Object Lock (immutability). Where the provider supports it, enable Object Lock in compliance or governance mode so backups cannot be deleted before their retention date, even with admin credentials. This is the real defence against ransomware.

Encryption

Your backups contain customer data, invoices, and password hashes. Encrypt them before they leave the server, with a key that is not stored on the server:

  • age or GPG with a public key: the server can encrypt but not decrypt. Keep the private key in a password manager or offline, and test that the people who need it can find it.
  • Server-side encryption (SSE-S3 or SSE-KMS): protects against stolen disks at the provider, not against someone with your bucket credentials. Use it in addition, not instead.

Also keep admin_passwd and a copy of odoo.conf in the same secure place, since you need them to rebuild the server.

Restore steps

Restoring onto a new or rebuilt server with the same Odoo version and the same custom addons:

# 1. fetch and decrypt
aws s3 cp s3://acme-odoo-backups/daily/20260930T021500Z/ . --recursive --endpoint-url "$ENDPOINT"
sha256sum -c erp-20260930T021500Z.sha256
age -d -i ~/backup-key.txt -o erp.dump erp-20260930T021500Z.dump.age
age -d -i ~/backup-key.txt -o filestore.tar.gz erp-20260930T021500Z-filestore.tar.gz.age

# 2. stop Odoo so nothing connects during the restore
sudo systemctl stop odoo

# 3. database
sudo -u postgres createdb -O odoo erp
sudo -u postgres pg_restore -j 4 --no-owner --role=odoo -d erp erp.dump

# 4. filestore (directory name must match the database name)
sudo mkdir -p /var/lib/odoo/filestore
sudo tar -C /var/lib/odoo/filestore -xzf filestore.tar.gz
sudo chown -R odoo:odoo /var/lib/odoo/filestore/erp

# 5. start and check
sudo systemctl start odoo

If you restore under a different database name, rename the filestore directory to match. If the restored database runs on a newer Odoo minor build, run odoo -d erp -u all --stop-after-init once before starting. Restoring onto a different major version does not work directly; that needs a migration (see upgrading Odoo 17 to 18).

To restore a zip from the database manager, either upload it through /web/database/manager, or unzip it and restore by hand: psql -d erp -f dump.sql for the database and move filestore/ to data_dir/filestore/erp.

Restore drills

A backup nobody has restored is a hope, not a backup. Schedule a drill, at least quarterly and after every major change:

  1. Pick a backup at random, not the latest one.
  2. Restore it onto a separate machine or a throwaway container, never over production.
  3. Neutralise it (next section).
  4. Log in, open a few records with attachments, print an invoice, and compare record counts on key tables with production.
  5. Write down how long the whole restore took. That number is your real recovery time, and it is the one to give management.

Automate the easy part: a weekly job that restores the latest dump into a scratch database with pg_restore, runs a count query, and drops it catches corrupted or empty backups early.

Neutralising a restored copy for staging

A restored production database still believes it is production. It will send emails to customers, fetch the shared mailbox, charge payment tokens, push to shipping carriers and run every scheduled action. Before anyone uses a copy, neutralise it.

Odoo 16 and later ship a neutralise command that deactivates outgoing and incoming mail servers, most scheduled actions, payment providers, webhooks and other integrations, and adds a banner to the interface:

odoo neutralize -d erp_staging -c /etc/odoo/odoo.conf

Run it before the first start of the copy. Then also:

  • Give the copy a new database UUID so it is not seen as the same instance: UPDATE ir_config_parameter SET value = gen_random_uuid() WHERE key = 'database.uuid';. For Enterprise, the copy must not be registered as your production database.
  • Set web.base.url to the staging hostname.
  • Point outgoing mail at a catch-all (Mailpit, MailHog) if staging needs to send any mail at all.
  • Review custom modules for their own crons or API calls, which the neutralise command only handles if the module implements it.

Doing this with CICDoo

If you want the schedule, storage and restore handled for you, CICDoo takes scheduled and on-demand backups of Odoo instances on your own servers, either database only or full with filestore, in plain SQL, custom or directory format. Backups go to S3-compatible storage, can be downloaded, restored from the console, and are pruned by retention. Staging instances in CICDoo sit next to production in the stage model. More on self-hosted Odoo with CICDoo, or talk to us.

Part of Self-hosted Odoo, done right

FAQ

Questions, answered

How do I back up an Odoo database?

Back up two things together: the PostgreSQL database with pg_dump (custom format, -Fc, is a good default) and the filestore directory under data_dir/filestore/<database_name>. The database manager zip contains both, but it is built by an Odoo worker and fails on large databases.

Does an Odoo backup include attachments?

Only if it includes the filestore. The database manager zip does. A pg_dump alone does not, because Odoo stores attachments as files on disk and only keeps references in the database.

How do I back up Odoo to S3 automatically?

Run a nightly script from cron or a systemd timer that dumps the database, archives the filestore, encrypts both, and uploads them with the AWS CLI or rclone to an S3-compatible bucket. Use lifecycle rules for retention and write-only credentials on the server.

How do I restore an Odoo backup?

Stop Odoo, create an empty database, restore the dump with pg_restore (or psql for plain SQL), extract the filestore into data_dir/filestore/<database_name> with the directory named after the database, fix ownership for the odoo user, and start Odoo on the same major version.

How long should I keep Odoo backups?

A common pattern is 14 daily, 8 weekly and 12 monthly backups, but accounting and tax records often have legal retention periods of several years in your country. Set retention with storage lifecycle rules and Object Lock rather than deletion scripts.

What does odoo neutralize do?

Available since Odoo 16, it prepares a copied database for testing: it disables mail servers, most scheduled actions, payment providers and other integrations, and shows a banner that the database is neutralised. Run it on every restored copy before using it as staging.

Run Odoo like this, without doing it by hand

CICDoo turns every step in this guide into a push to Git, on servers you own. Talk to an engineer about your setup.

No per-seat fees. Your servers stay yours.