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
What an Odoo backup actually contains
Odoo keeps its data in two places, and a usable backup needs both:
- The PostgreSQL database: every record, configuration, user, journal entry and stock move.
- 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 setdata_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.confand any environment files, includingadmin_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
PutObjectbut notDeleteObjector 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:
- Pick a backup at random, not the latest one.
- Restore it onto a separate machine or a throwaway container, never over production.
- Neutralise it (next section).
- Log in, open a few records with attachments, print an invoice, and compare record counts on key tables with production.
- 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.urlto 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.