Last updated: 2026-08-09
A database dump alone is not a complete Odoo backup, as covered in our filestore restore post, attachments live outside the database entirely. This is the backup script we run in production: database plus filestore, compressed, rotated locally, and uploaded to S3-compatible object storage.
Why pg_dump rather than a filesystem-level PostgreSQL backup
PostgreSQL supports several backup approaches, and it's worth being explicit about why this post uses pg_dump rather than pg_basebackup or a continuous WAL-archiving setup. pg_dump produces a logical, version-portable backup, you can restore it into a different PostgreSQL major version, a different server architecture, even a differently-configured cluster, because it's really just a sequence of SQL statements and data in a custom binary wrapper, not a raw copy of PostgreSQL's on-disk files. pg_basebackup and WAL archiving are the right tools for point-in-time recovery on a large, high-write database where losing even a few minutes of transactions matters, but they tie you to matching PostgreSQL versions and are meaningfully more operationally complex to set up and restore correctly. For the large majority of Odoo deployments, a solid daily pg_dump plus filestore backup, tested regularly, is the right tradeoff between simplicity and recovery capability. If your business genuinely cannot tolerate losing even a few hours of transactions, that's a signal to look at WAL archiving specifically, not a reason to distrust the approach in this post for everyone else.
Prerequisites
sudo apt install -y awscli
aws configure
# set your access key, secret key, and default region
# for non-AWS S3-compatible providers, add --endpoint-url in the commands below
If you're using a non-AWS S3-compatible provider (Contabo, Backblaze B2, Wasabi, and similar), confirm the provider's specific endpoint URL and region string before relying on the default aws CLI behaviour, some providers require --endpoint-url on every single command including aws configure's test calls, not just the upload step, and skipping this silently sends the backup to the wrong place or fails with a confusing authentication error that has nothing to do with your actual credentials.
The backup script
#!/bin/bash
set -euo pipefail
DB_NAME="your_production_db"
FILESTORE_DIR="/opt/odoo/.local/share/Odoo/filestore/${DB_NAME}"
BACKUP_ROOT="/opt/odoo/backups"
S3_BUCKET="s3://your-backup-bucket/odoo-backups"
RETENTION_DAYS=14
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
mkdir -p "${BACKUP_ROOT}"
# 1. Database dump, custom format for faster parallel restore later
sudo -u postgres pg_dump -Fc "${DB_NAME}" > "${BACKUP_ROOT}/${DB_NAME}_${TIMESTAMP}.dump"
# 2. Filestore tar, compressed
tar -czf "${BACKUP_ROOT}/${DB_NAME}_filestore_${TIMESTAMP}.tar.gz" \
-C "$(dirname "${FILESTORE_DIR}")" "$(basename "${FILESTORE_DIR}")"
# 3. Upload both to S3
aws s3 cp "${BACKUP_ROOT}/${DB_NAME}_${TIMESTAMP}.dump" "${S3_BUCKET}/"
aws s3 cp "${BACKUP_ROOT}/${DB_NAME}_filestore_${TIMESTAMP}.tar.gz" "${S3_BUCKET}/"
# 4. Local rotation, delete anything older than RETENTION_DAYS
find "${BACKUP_ROOT}" -name "${DB_NAME}_*.dump" -mtime +${RETENTION_DAYS} -delete
find "${BACKUP_ROOT}" -name "${DB_NAME}_filestore_*.tar.gz" -mtime +${RETENTION_DAYS} -delete
echo "Backup complete: ${TIMESTAMP}"
Save this as /opt/odoo/scripts/backup.sh, make it executable, and confirm it runs cleanly by hand before wiring it into cron:
sudo chmod +x /opt/odoo/scripts/backup.sh
sudo /opt/odoo/scripts/backup.sh
Cron schedule
sudo crontab -e -u root
# Daily at 2 AM server time
0 2 * * * /opt/odoo/scripts/backup.sh >> /var/log/odoo-backup.log 2>&1
S3 lifecycle policy for long-term retention
Local rotation (step 4 above) keeps your server's disk from filling up, but you likely want longer retention in S3 itself than on the local disk. Configure a lifecycle rule on the bucket to transition or expire objects, rather than relying on the local script's RETENTION_DAYS to also govern S3 storage:
aws s3api put-bucket-lifecycle-configuration \
--bucket your-backup-bucket \
--lifecycle-configuration '{
"Rules": [{
"ID": "odoo-backup-retention",
"Status": "Enabled",
"Filter": {"Prefix": "odoo-backups/"},
"Expiration": {"Days": 90}
}]
}'
The restore test you actually need
A backup you have never restored is a backup you don't actually have, this is not a cliche, it's the single most common gap we find auditing a new client's infrastructure. Schedule a real restore test, monthly at minimum:
# On a separate test server or a scratch database, never production
sudo -u postgres createdb restore_test
sudo -u postgres pg_restore -d restore_test /path/to/downloaded_backup.dump
# Verify row counts on a few key tables as a sanity check
sudo -u postgres psql restore_test -c "SELECT count(*) FROM res_partner;"
sudo -u postgres psql restore_test -c "SELECT count(*) FROM sale_order;"
Also restore the filestore tar alongside it and confirm a known attachment actually opens, following the verification steps in our filestore restore post. A dump that restores without error but with a broken filestore path is a common false sense of security.
Beyond the row-count sanity check, verify the restored database can actually boot as an Odoo instance, not just that PostgreSQL accepted the data. Point a scratch Odoo config at restore_test and confirm it starts and the login page loads:
sudo -u odoo /opt/odoo/venv/bin/python3 /opt/odoo/odoo-bin -d restore_test --db-filter=^restore_test$ --http-port=8169
curl -I http://localhost:8169/web/login
A restore that passes the row-count check but fails to boot cleanly usually points at a filestore path mismatch or a missing extension, exactly the kind of gap that only shows up when you try to actually use the restored data, not just query it.
Alerting on backup failures, not just running the job
A cron job with output going to a log file nobody reads is functionally the same as no monitoring at all. The script above uses set -euo pipefail, so any failed step exits non-zero, wire that into an actual notification rather than trusting someone will check the log:
# Wrap the cron invocation to alert on non-zero exit
0 2 * * * /opt/odoo/scripts/backup.sh >> /var/log/odoo-backup.log 2>&1 || curl -s -X POST "https://your-alert-webhook" -d "Odoo backup failed on $(hostname)"
If you don't have a webhook-based alert channel already, even a simple daily check that confirms a new file landed in the S3 bucket with today's date is better than silent failure. The specific mechanism matters less than the principle: a backup pipeline with no failure alerting is not really automated, it's just unattended.
Encrypting backups at rest
Database dumps contain everything, customer data, financial records, whatever your Odoo instance stores. If your S3-compatible provider supports server-side encryption, enable it on the bucket rather than relying on transport encryption alone:
aws s3api put-bucket-encryption \
--bucket your-backup-bucket \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
}'
This protects the data at rest in the bucket itself. It does not replace access control, the IAM credentials or API keys used by the backup script and by anyone with bucket access still need to be scoped tightly, a backup bucket with encryption enabled but world-readable permissions provides no real protection.
Common mistakes
- Backing up the database but not the filestore. The single most common gap, covered above.
- Never testing a restore. A cron job that's been silently failing for months is worse than no backup at all, because it creates false confidence.
- No off-server copy. Local backups alone don't survive the server itself failing. The S3 upload step above is not optional for production.
- Plain-text dump format for large databases.
pg_dump -Fc(custom format) supports parallel restore viapg_restore -j, meaningfully faster than a plain SQL dump on a large database during an actual incident. - No failure alerting. A silently broken backup cron is the single most dangerous failure mode here, because nothing visibly breaks until the day you actually need the backup.
- Testing restore on the same server as production. Always use a separate database name and, ideally, a separate server, restoring onto the same instance risks colliding with the live database if a step is fat-fingered.
- Backing up during peak write hours. A
pg_dumpagainst a live, busy database is generally safe (PostgreSQL's MVCC model gives it a consistent snapshot), but it does add read load and can hold row locks briefly on some operations. Schedule it for the lowest-traffic window your business actually has, 2 AM server time is a reasonable default, but confirm it against your actual usage pattern, a global client base may not have a genuinely quiet hour at all, in which case scheduling around the lowest relative traffic window still matters. - One retention tier only. Keeping 14 days locally and 90 days in S3, as in this post, is a reasonable default, but consider whether your business has a compliance or client-contract reason to keep certain backups (year-end, pre-migration snapshots) indefinitely rather than letting the lifecycle policy expire them on the same schedule as routine daily backups.
Sources: PostgreSQL pg_dump documentation.