← Back to guides

Odoo backups

Odoo backups: database, filestore, and custom modules

Applies to Odoo 15 to 20, self-hosted (Community or Enterprise).

This guide explains what a real Odoo backup should include before you trust a deployment, migration, automation, upgrade, or AI/database integration.

The backup principle

A useful Odoo backup must be restorable. For most self-hosted environments, that means backing up the , the , , and the deployment configuration needed to run the system again.

Read this before running backup commands

This guide is practical, but backup commands still need care. A backup may contain customer data, employee data, invoices, API keys, passwords, and other sensitive business information. Store backups securely, restrict permissions, and avoid copying production backups to unsafe locations.
Before changing production backup automation, first create a manual backup and test a restore in a separate sandbox. A backup process that has never been restored is not fully proven.

Planning

Design decisions before changing backup, storage, automation, or restore processes.

Safe check

Read-only commands or manual checks that should not change the server state.

Caution

Checks or plans that may involve sensitive data, secrets, or careful interpretation.

Change required

Actions that create, copy, restore, move, or automate backups. Plan storage, permissions, and impact before applying.

Backup checklist

1. Understand what a complete Odoo backup includes

Planning

An Odoo backup is not only a PostgreSQL dump. Odoo stores binary files, attachments, custom modules, configuration, and deployment secrets outside the database.

Show command and interpretation

Command or manual check

A complete Odoo backup usually includes:

1. PostgreSQL database
2. Odoo filestore
3. Custom addons
4. Odoo configuration
5. Docker Compose or deployment files
6. Environment variables / secrets
7. Nginx configuration
8. Restore instructions
9. Off-server copy

How to interpret the result

If you only backup PostgreSQL, you may lose:
- uploaded documents
- product images
- invoice PDFs
- email attachments
- website media
- custom modules
- deployment configuration
- passwords, tokens, and integration settings stored outside the database

A backup is only trustworthy if you know how to restore it.

2. Identify the Odoo deployment type

Safe check

Backup commands depend on how Odoo is installed. Before backing up anything, identify whether Odoo is managed by Docker Compose, a Linux systemd service, managed hosting, or an on-premise setup.

Show command and interpretation

Command or manual check

echo "=== Docker containers ==="
sudo docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}" 2>/dev/null || echo "Docker is not installed or you do not have permission to run Docker"

echo
echo "=== Docker Compose project files in current folder ==="
pwd
ls -la docker-compose.yml compose.yml 2>/dev/null || echo "No Docker Compose file found in the current folder"

echo
echo "=== Odoo systemd services ==="
systemctl list-units --type=service --all | grep -Ei "odoo" || echo "No Odoo systemd service found"

echo
echo "=== Common Odoo paths ==="
sudo find /etc /opt /var/lib /var/log -maxdepth 3 -iname "*odoo*" 2>/dev/null | head -100

How to interpret the result

Use the output to identify the deployment type.

Docker Compose signs:
- docker ps shows Odoo and PostgreSQL containers
- a docker-compose.yml or compose.yml file exists
- common project folders look like /opt/odoo-secure, /opt/odoo, or another deployment folder
- Docker volumes may appear under /var/lib/docker/volumes

Linux package/systemd signs:
- systemctl shows an Odoo service
- common config paths may include /etc/odoo/odoo.conf
- Odoo data may be under /var/lib/odoo

Managed hosting signs:
- you may not have SSH, Docker, or systemd access
- backups may be controlled by the hosting provider

On-premise/office server signs:
- Odoo may run on a local company server
- DNS/firewall/router rules may forward public traffic to the server
- backups must consider local storage, network shares, and physical hardware risk

How to interpret common results:
- "No Odoo systemd service found" is normal for Docker Compose deployments.
- Docker volumes such as /var/lib/docker/volumes/... usually mean Docker is storing database or Odoo data.
- Nginx and Let's Encrypt paths show the public HTTPS reverse proxy setup, not the Odoo database itself.
- Do not copy backup commands blindly until you know where the database, filestore, addons, and config are stored.

3. Locate the active database name

Safe check

You need the exact database name before creating a PostgreSQL dump or testing a restore.

Show command and interpretation

Command or manual check

# Docker example:
# Replace odoo-secure-db with your PostgreSQL container name from step 2.
# Replace -U odoo with the PostgreSQL user from your odoo.conf db_user value.

sudo docker exec odoo-secure-db psql -U odoo -l

# Or inside PostgreSQL:
sudo docker exec odoo-secure-db psql -U odoo -d postgres -c "\l"

How to interpret the result

Look for the active Odoo database name.

Example:
- odoo_prod

PostgreSQL often includes a default database named postgres. Do not confuse it with your Odoo database. In this example, the real Odoo business database is odoo_prod.

Backing up only postgres would not back up the Odoo customers, products, orders, invoices, users, or settings.

4. Locate the Odoo filestore

Safe check

The filestore contains uploaded files and attachments. Without it, a restored database may show missing files, images, PDFs, and documents.

Show command and interpretation

Command or manual check

echo "=== Check Odoo data_dir from config ==="
cd /opt/odoo-secure
grep -E "^data_dir" config/odoo.conf || echo "No data_dir found in config"

echo
echo "=== Check common filestore paths inside the Odoo container ==="
sudo docker exec odoo-secure-app sh -c '
echo "--- data_dir path: /var/lib/odoo ---"
ls -la /var/lib/odoo 2>/dev/null || true

echo
echo "--- common path: /var/lib/odoo/filestore ---"
ls -la /var/lib/odoo/filestore 2>/dev/null || true

echo
echo "--- database filestore: /var/lib/odoo/filestore/odoo_prod ---"
ls -la /var/lib/odoo/filestore/odoo_prod 2>/dev/null || true

echo
echo "--- alternate path: /var/lib/odoo/.local/share/Odoo/filestore ---"
ls -la /var/lib/odoo/.local/share/Odoo/filestore 2>/dev/null || true

echo
echo "--- alternate database filestore: /var/lib/odoo/.local/share/Odoo/filestore/odoo_prod ---"
ls -la /var/lib/odoo/.local/share/Odoo/filestore/odoo_prod 2>/dev/null || true
'

echo
echo "=== Find filestore paths on the host or Docker volumes ==="
sudo find /var/lib/docker /opt /var/lib -path "*filestore*" -type d 2>/dev/null | head -50

How to interpret the result

The filestore path can differ depending on the Odoo version, Docker image, configuration, or volume mapping.

Good signs:
- you find a filestore folder matching the Odoo database name
- example: filestore/odoo_prod
- the folder contains many small subfolders with short names such as 0a, 1f, b5, cd, etc.
- in Docker setups, the host path may be inside /var/lib/docker/volumes/...

Common container paths:
- /var/lib/odoo/filestore/<database_name>
- /var/lib/odoo/.local/share/Odoo/filestore/<database_name>

Common host/Docker volume paths:
- /var/lib/docker/volumes/<project>_odoo-web-data/_data/filestore/<database_name>
- /var/lib/docker/volumes/<project>_odoo-data/_data/filestore/<database_name>
- a custom host folder mounted into the Odoo container

How to interpret the result:
- If /var/lib/odoo/filestore/odoo_prod exists inside the container, that is likely the active filestore.
- If the container path does not show the filestore but the host search finds /var/lib/docker/volumes/.../_data/filestore/odoo_prod, the filestore is stored in a Docker volume.
- If no filestore is found, confirm the database name, check data_dir in odoo.conf, and inspect Docker volume mounts.
- The filestore folder should match the Odoo database name. For example, database odoo_prod should usually have a filestore folder named odoo_prod.

Important:
The database dump and filestore backup should be taken together as a consistent pair. Backing up only PostgreSQL may restore the records, but uploaded files, images, PDFs, and attachments may be missing if the filestore is not included.

For a perfectly consistent pair on a busy instance, take the backup during low activity or briefly stop Odoo, not PostgreSQL, so no new attachments are written between the database dump and the filestore archive. On a quiet instance, taking them back-to-back is normally sufficient.

5. Locate custom addons

Safe check

Custom modules are often outside the database. If you restore the database without the custom module code, Odoo may fail to start or important features may behave incorrectly.

Show command and interpretation

Command or manual check

# Go to the folder where your Odoo deployment is managed.
# Example for a Docker Compose install:
cd /opt/odoo-secure

echo "=== Current folder ==="
pwd

echo
echo "=== Review addons path from odoo.conf ==="
grep -E "^addons_path" config/odoo.conf || echo "No addons_path found"

echo
echo "=== Project addons folder ==="
ls -la addons 2>/dev/null || echo "No local addons folder found in this project"

echo
echo "=== Extra addons folder inside the Odoo container ==="
sudo docker exec odoo-secure-app sh -c 'ls -la /mnt/extra-addons 2>/dev/null || echo "No /mnt/extra-addons folder found"'

echo
echo "=== Search common custom addons folders ==="
sudo find /opt /mnt /var/lib -maxdepth 4 -type d \( -name "addons" -o -name "custom-addons" -o -name "extra-addons" \) 2>/dev/null

How to interpret the result

How to interpret the result:

1. Check addons_path in odoo.conf.
   This tells you where Odoo expects to load modules from.

   Example:
   - addons_path = /mnt/extra-addons,/usr/lib/python3/dist-packages/odoo/addons

2. Separate built-in addons from custom addons.
   Built-in Odoo addons are usually part of the Odoo installation or Docker image.

   Common built-in path:
   - /usr/lib/python3/dist-packages/odoo/addons

   You normally do not need to back up built-in Odoo core addons if you can recreate the same Odoo version from the image/package.

3. Identify custom addon folders.
   These are the folders you must back up.

   Common custom addon paths:
   - /opt/odoo-secure/addons
   - /mnt/extra-addons inside the container
   - /opt/odoo/custom/addons
   - /opt/odoo/extra-addons
   - a custom host folder mounted into the Odoo container

4. Empty custom addon folder is okay.
   If /opt/odoo-secure/addons exists but is empty, it means no custom addons are currently installed there. Still include the folder in backup scripts so future custom modules are covered.

5. Ignore unrelated addon folders.
   Some paths may contain the word addons but are not Odoo custom modules.

   Example:
   - /var/lib/vim/addons is related to Vim, not Odoo.

If you use third-party or custom modules, also keep a list of:
- module name
- version
- source repository or vendor
- license
- Odoo version compatibility
- whether the module is required for the database to start correctly

6. Locate configuration and secrets

Caution

Odoo needs configuration to reconnect to the database, use the correct addons path, run behind a proxy, and protect the database manager. Some files contain secrets.

Show command and interpretation

Command or manual check

# Example Docker Compose deployment:
cd /opt/odoo-secure

echo "=== Deployment files ==="
ls -la

echo
echo "=== Config files ==="
ls -la config

echo
echo "=== Show important odoo.conf keys without secrets ==="
grep -E "^(admin_passwd|db_host|db_port|db_user|db_password|proxy_mode|list_db|dbfilter|addons_path|data_dir)" config/odoo.conf \
  | sed -E 's/^(admin_passwd|db_password)[[:space:]]*=.*/\1 = ***hidden***/'

echo
echo "=== Environment file exists, do not print secrets ==="
ls -la .env 2>/dev/null || echo "No .env file found"

echo
echo "=== Nginx site configs that may need backup ==="
sudo ls -la /etc/nginx/sites-available 2>/dev/null | grep -Ei "odoo|erp" || echo "No obvious Odoo Nginx config found"

How to interpret the result

Important files may include:
- docker-compose.yml
- .env
- config/odoo.conf
- Nginx site config
- custom scripts
- backup scripts
- deployment notes

How to interpret the result:

1. Deployment files
   The project folder should contain the files needed to recreate the deployment.

   Common files and folders:
   - docker-compose.yml
   - .env
   - config/
   - addons/

2. Odoo configuration
   config/odoo.conf should contain important runtime settings such as:
   - db_host
   - db_port
   - db_user
   - addons_path
   - data_dir
   - proxy_mode
   - list_db
   - dbfilter

   Sensitive values such as admin_passwd and db_password should be hidden when displayed.

3. Environment file
   .env may contain database passwords, Odoo master passwords, tokens, or other secrets.
   It should exist if your deployment depends on it, but its content should not be printed publicly.

4. Nginx configuration
   If Odoo is exposed through Nginx/HTTPS, the Nginx site config should be included in the backup plan.

   Common path:
   - /etc/nginx/sites-available/your-odoo-domain

5. File permissions
   Secret files should have restricted permissions.

   Good examples:
   - .env with 600 permissions
   - docker-compose.yml with restricted permissions if it contains secrets

Do not paste secrets publicly.

Backups containing .env, odoo.conf, database dumps, or configuration files should be protected with strict permissions and stored securely.

7. Create a manual backup folder

Change required

Create a timestamped folder so database, filestore, addons, and config backups are grouped together.

Show command and interpretation

Command or manual check

BACKUP_ROOT="/opt/odoo-backups"
BACKUP_DATE="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="$BACKUP_ROOT/$BACKUP_DATE"

sudo mkdir -p "$BACKUP_DIR"
sudo chmod 700 "$BACKUP_ROOT" "$BACKUP_DIR"

echo "$BACKUP_DIR"

echo
echo "=== Verify backup folder permissions ==="
sudo ls -ld "$BACKUP_ROOT" "$BACKUP_DIR"

How to interpret the result

Expected:
- a new timestamped backup folder is created
- permissions restrict casual access
- all backup parts for this run are stored together

Important:
Copy the printed BACKUP_DIR path and reuse the same folder for the following backup steps. Do not run this folder-creation step again until the current backup run is finished, or later steps may target a new empty folder instead of the intended backup folder.

Good permission example:
- drwx------ root root /opt/odoo-backups
- drwx------ root root /opt/odoo-backups/YYYYMMDD-HHMMSS

The timestamped folder will be used for:
- PostgreSQL database dump
- Odoo filestore archive
- custom addons
- deployment configuration
- backup manifest

For production, use an off-server backup destination too. A backup stored only on the same VPS may be lost if the VPS is deleted, corrupted, encrypted by malware, or compromised.

8. Back up the PostgreSQL database

Change required

The PostgreSQL database contains Odoo business records: users, partners, products, orders, invoices, settings, module state, and more.

Show command and interpretation

Command or manual check

(
BACKUP_ROOT="/opt/odoo-backups"
DB_NAME="odoo_prod"

# Prefer pinning the exact backup folder printed by step 7.
# Replace this value with the real folder created for this backup run.
BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"

echo "=== Using backup folder ==="
echo "$BACKUP_DIR"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found. Run step 7 first, then paste the printed path into BACKUP_DIR."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Create PostgreSQL dump ==="
# pipefail makes the pipeline fail when pg_dump fails, not only when tee fails.
set -o pipefail
if ! sudo docker exec odoo-secure-db pg_dump -U odoo -Fc "$DB_NAME" | sudo tee "$BACKUP_DIR/database.dump" >/dev/null; then
  echo "pg_dump FAILED: removing the partial dump file."
  sudo rm -f "$BACKUP_DIR/database.dump"
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Back up PostgreSQL roles (globals) ==="
# Roles and their settings are not part of pg_dump. Needs a superuser role
# (the POSTGRES_USER of the official image is one). Without superuser rights,
# add --no-role-passwords.
if ! sudo docker exec odoo-secure-db pg_dumpall -U odoo --globals-only | sudo tee "$BACKUP_DIR/globals.sql" >/dev/null; then
  echo "pg_dumpall --globals-only FAILED: removing the partial file."
  sudo rm -f "$BACKUP_DIR/globals.sql"
fi

echo
echo "=== Verify database dump ==="
sudo ls -lh "$BACKUP_DIR/database.dump"

echo
echo "=== Test dump metadata ==="
sudo cat "$BACKUP_DIR/database.dump" | sudo docker exec -i odoo-secure-db pg_restore -l | head -20
)

How to interpret the result

The database dump should exist inside the timestamped backup folder.

Replace BACKUP_DIR with the exact folder path printed in step 7. This avoids accidentally splitting one backup across multiple timestamped folders.

Good result:
- no "FAILED" message is printed
- database.dump exists
- file size is not 0
- pg_restore -l can read the dump metadata
- metadata shows the expected Odoo database name
- globals.sql exists (roles)

Example:
- dbname: odoo_prod
- Format: CUSTOM

Why set -o pipefail:
- without it, the pipeline's result is the result of tee, which succeeds even if pg_dump stops halfway
- with it, a failed pg_dump is detected and the partial file is removed instead of looking like a valid backup

About globals.sql:
- it contains PostgreSQL roles and their settings, including password hashes: protect it like the database dump
- on restore, the guide uses --no-owner, so roles are optional for a test restore, but they help rebuild a server identically

If you see "Permission denied" when finding the backup folder:
- the backup root is probably protected with 700 permissions
- use sudo when listing or finding backup folders
- do not weaken permissions just to make the command easier

If the dump is accidentally created at /database.dump:
- move it into the timestamped backup folder
- verify it with pg_restore -l
- avoid leaving backup files in /

Important:
If the server will be reset, deleted, reinstalled, or restored from scratch, copy the backup off the server before doing anything destructive. A backup stored only on the same VPS can be lost with the VPS.

For large production databases:
- schedule during lower activity
- monitor disk space
- consider compression and off-server transfer
- test restore regularly

9. Back up the Odoo filestore

Change required

The filestore contains attachments and binary content. It should be backed up with the database.

Show command and interpretation

Command or manual check

(
BACKUP_ROOT="/opt/odoo-backups"
DB_NAME="odoo_prod"

# Use the exact backup folder printed by step 7.
# Replace this value with the real folder created for this backup run.
BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"

echo "=== Using backup folder ==="
echo "$BACKUP_DIR"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found. Run step 7 first, then paste the printed path into BACKUP_DIR."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Resolve the filestore from the running Odoo container ==="

# Docker example: read the host path mounted on the container's data_dir.
# /var/lib/odoo is the data_dir of the official Odoo image. If odoo.conf sets
# another data_dir, use that path instead.
# Non-Docker install: set FILESTORE_PATH="<data_dir>/filestore/$DB_NAME" by hand.
ODOO_CONTAINER="odoo-secure-app"
DATA_DIR_ON_HOST="$(sudo docker inspect "$ODOO_CONTAINER" --format '{{range .Mounts}}{{if eq .Destination "/var/lib/odoo"}}{{.Source}}{{end}}{{end}}')"
FILESTORE_PATH="$DATA_DIR_ON_HOST/filestore/$DB_NAME"

echo "Detected filestore path:"
echo "$FILESTORE_PATH"

if [ -z "$DATA_DIR_ON_HOST" ] || ! sudo test -d "$FILESTORE_PATH"; then
  echo "No filestore folder found for database: $DB_NAME"
  echo "Go back to the filestore location step and confirm the container name, data_dir, and path."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Verify filestore folder ==="
sudo ls -ld "$FILESTORE_PATH"

echo
echo "=== Create filestore archive ==="
sudo tar -C "$(dirname "$FILESTORE_PATH")" -czf "$BACKUP_DIR/filestore.tar.gz" "$DB_NAME"

echo
echo "=== Verify filestore archive ==="
sudo ls -lh "$BACKUP_DIR/filestore.tar.gz"

echo
echo "=== Test archive content ==="
sudo tar -tzf "$BACKUP_DIR/filestore.tar.gz" | head -20
)

How to interpret the result

The filestore archive should exist inside the timestamped backup folder.

Replace BACKUP_DIR with the exact folder path printed in step 7. This avoids accidentally splitting one backup across multiple timestamped folders.

Good result:
- filestore.tar.gz exists
- file size is not 0
- tar -tzf can list the archive content
- archive content includes the database filestore folder

Example:
- odoo_prod/...
- odoo_prod/b5/...
- odoo_prod/22/...

If the filestore path does not exist:
- confirm the database name
- confirm data_dir in odoo.conf
- inspect Docker volumes
- check whether filestore is mounted somewhere else
- go back to the filestore location step before continuing

Why the path is read from the container instead of searched on disk:
- a broad find can match an old volume, a sandbox copy, or Docker's internal storage layers
- picking the first match could silently back up the wrong filestore

If you see "Permission denied":
- the backup folder is probably protected with 700 permissions
- use sudo to read, write, list, or archive backup files
- do not weaken backup folder permissions just to make commands easier

Important:
Do not assume the filestore is inside PostgreSQL. It is usually separate. A restored database without the matching filestore may have missing images, PDFs, uploads, and attachments.

For a perfectly consistent database and filestore pair on a busy instance, take the backup during low activity or briefly stop Odoo, not PostgreSQL, so no new attachments are written between the database dump and the filestore archive. On a quiet instance, taking them back-to-back is normally sufficient.

10. Back up custom addons and deployment config

Change required

Database and filestore are not enough if the Odoo instance depends on custom code, Docker Compose, Nginx, or configuration files.

Show command and interpretation

Command or manual check

(
BACKUP_ROOT="/opt/odoo-backups"

# Use the exact backup folder printed by step 7.
# Replace this value with the real folder created for this backup run.
BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"

echo "=== Using backup folder ==="
echo "$BACKUP_DIR"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found. Run step 7 first, then paste the printed path into BACKUP_DIR."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Back up Odoo project config and addons ==="
cd /opt/odoo-secure

sudo tar -czf "$BACKUP_DIR/odoo-project-config-and-addons.tar.gz" \
  docker-compose.yml \
  .env \
  config \
  addons

echo
echo "=== Back up Nginx Odoo site config if it exists ==="
sudo cp -a /etc/nginx/sites-available/odoo.example.com "$BACKUP_DIR/nginx-site.conf" 2>/dev/null || echo "No matching Nginx site config found"

echo
echo "=== Verify backup folder content ==="
sudo ls -lh "$BACKUP_DIR"

echo
echo "=== Test project config archive content ==="
sudo tar -tzf "$BACKUP_DIR/odoo-project-config-and-addons.tar.gz" | head -30
)

How to interpret the result

The backup folder should include:
- database.dump
- filestore.tar.gz
- odoo-project-config-and-addons.tar.gz
- nginx-site.conf if applicable
- globals.sql (PostgreSQL roles, from step 8)

Replace BACKUP_DIR with the exact folder path printed in step 7. This avoids accidentally splitting one backup across multiple timestamped folders.

The project config archive should include:
- docker-compose.yml
- .env
- config/
- config/odoo.conf
- addons/

How to interpret the result:
- database.dump is the PostgreSQL database backup
- filestore.tar.gz is the Odoo attachments/files backup
- odoo-project-config-and-addons.tar.gz contains deployment files, secrets, Odoo config, and custom addons
- nginx-site.conf contains the public reverse proxy / HTTPS configuration if Odoo is exposed through Nginx

Be careful:
- .env and odoo.conf may contain secrets
- database.dump may contain business and personal data
- backup folders should stay permission-protected
- do not publish or share these backup files publicly

If you see "Permission denied":
- the backup folder is probably protected with 700 permissions
- use sudo to list, read, or write backup files
- do not weaken backup folder permissions just to make commands easier

11. Create a backup manifest

Safe check

A manifest helps you understand what is inside the backup later, especially during a stressful restore.

Show command and interpretation

Command or manual check

(
BACKUP_ROOT="/opt/odoo-backups"

# Use the exact backup folder printed by step 7.
# Replace this value with the real folder created for this backup run.
BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"

echo "=== Using backup folder ==="
echo "$BACKUP_DIR"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found. Run step 7 first, then paste the printed path into BACKUP_DIR."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

{
  echo "Backup date: $(date -Is)"
  echo "Hostname: $(hostname)"
  echo "Odoo URL: https://odoo.example.com"
  echo "Database: odoo_prod"
  echo
  echo "Backup folder:"
  echo "$BACKUP_DIR"
  echo
  echo "Files:"
  sudo ls -lh "$BACKUP_DIR"
  echo
  echo "Docker containers:"
  sudo docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}" 2>/dev/null || true
  echo
  echo "Odoo config summary:"
  cd /opt/odoo-secure
  grep -E "^(db_host|db_port|db_user|proxy_mode|list_db|dbfilter|addons_path|data_dir)" config/odoo.conf 2>/dev/null || true
} | sudo tee "$BACKUP_DIR/MANIFEST.txt" >/dev/null

echo
echo "=== Manifest content ==="
sudo cat "$BACKUP_DIR/MANIFEST.txt"
)

How to interpret the result

The manifest should describe:
- when the backup was made
- which server it came from
- which Odoo URL it belongs to
- which database was backed up
- which backup folder was used
- which files were included
- which containers/images were running
- basic Odoo configuration context without secrets

Replace BACKUP_DIR with the exact folder path printed in step 7. This avoids accidentally creating a manifest for the wrong backup folder.

Good signs:
- MANIFEST.txt exists inside the backup folder
- it lists database.dump
- it lists filestore.tar.gz
- it lists odoo-project-config-and-addons.tar.gz
- it lists nginx-site.conf if applicable
- it references the correct database name

How to interpret the result:
- the manifest is not the backup itself
- it is a readable index of what the backup contains
- it helps during restore because you do not need to guess which database, URL, server, or files the backup belongs to

Important:
Do not print secrets in the manifest. Avoid including admin_passwd, db_password, API keys, tokens, or private environment values.

12. Copy the backup off the server

Change required

A backup stored only on the same server does not protect against server deletion, disk failure, ransomware, or serious compromise.

Show command and interpretation

Command or manual check

# Step A — Run this on the server first.
# It verifies the protected backup path and creates a temporary export archive
# that your SSH user can download.

(
BACKUP_ROOT="/opt/odoo-backups"

# Use the exact backup folder printed by step 7.
# Replace this value with the real folder created for this backup run.
BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"

echo "=== Protected backup folder on the server ==="
echo "$BACKUP_DIR"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found. Run step 7 first, then paste the printed path into BACKUP_DIR."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Verify backup content before export ==="
sudo ls -lh "$BACKUP_DIR"

BACKUP_NAME="$(basename "$BACKUP_DIR")"
EXPORT_DIR="$HOME/backup-export"
EXPORT_FILE="$EXPORT_DIR/odoo-backup-$BACKUP_NAME.tar.gz"

echo
echo "=== Create temporary export archive ==="
mkdir -p "$EXPORT_DIR"
chmod 700 "$EXPORT_DIR"

sudo tar -C "$(dirname "$BACKUP_DIR")" -czf "$EXPORT_FILE" "$BACKUP_NAME"

sudo chown "$USER:$USER" "$EXPORT_FILE"
chmod 600 "$EXPORT_FILE"

echo
echo "=== Export file ready for download ==="
ls -lh "$EXPORT_FILE"

echo
echo "Download this file from your local computer:"
echo "$EXPORT_FILE"
)


# Step B — Run this from your local computer, not from the server.
# Example from macOS/Linux terminal:
scp your-user@your-server-ip:/home/your-user/backup-export/odoo-backup-YYYYMMDD-HHMMSS.tar.gz ./


# Example from Windows PowerShell:
scp your-user@your-server-ip:/home/your-user/backup-export/odoo-backup-YYYYMMDD-HHMMSS.tar.gz C:\Users\YourName\Backups


# Step C — After confirming the file exists on your local computer,
# run this on the server to remove the temporary export archive.
rm ~/backup-export/odoo-backup-YYYYMMDD-HHMMSS.tar.gz

How to interpret the result

1. Verify the protected backup folder on the server.
   Replace BACKUP_DIR with the exact folder path printed in step 7.

   The server should show the selected backup folder and list files such as:
   - database.dump
   - filestore.tar.gz
   - odoo-project-config-and-addons.tar.gz
   - nginx-site.conf
   - MANIFEST.txt

2. Create a temporary export archive.
   The export archive is created under the SSH user's home folder so it can be downloaded with scp.

   Example:
   - /home/your-user/backup-export/odoo-backup-YYYYMMDD-HHMMSS.tar.gz

3. Run scp from your local computer.
   Do not run the scp download command from the server. Run it from your laptop or workstation terminal.

   Windows PowerShell example:
   - scp your-user@your-server-ip:/home/your-user/backup-export/odoo-backup-YYYYMMDD-HHMMSS.tar.gz C:\Users\YourName\Backups

4. Confirm the file exists on your local computer.
   After download, check your local backup folder and confirm the archive is present.

5. Delete the temporary export from the server after download.
   Keep the original protected backup in /opt/odoo-backups, but remove the temporary readable export archive from the user's home folder.

Why this extra export step is useful:
- /opt/odoo-backups may be protected with root-only permissions
- direct scp from /opt/odoo-backups can fail
- the temporary archive lets you download the backup without weakening backup folder permissions

Important:
Keep at least one protected copy on the server for quick rollback and at least one copy off the server. For sensitive production systems, use encrypted storage. Do not store unencrypted production backups in public buckets, shared folders, or insecure drives. Backups can contain customer data, employee data, invoices, passwords, API keys, and private business information.

13. Verify backup integrity

Safe check

A backup file that exists is not automatically usable. Basic checks help catch empty files, missing parts, or corrupt archives.

Show command and interpretation

Command or manual check

(
BACKUP_ROOT="/opt/odoo-backups"

# Use the exact backup folder printed by step 7.
# Replace this value with the real folder created for this backup run.
BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"

echo "=== Using backup folder ==="
echo "$BACKUP_DIR"

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found. Run step 7 first, then paste the printed path into BACKUP_DIR."
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo
echo "=== Backup files ==="
sudo ls -lh "$BACKUP_DIR"

echo
echo "=== Check required backup files exist ==="
for file in database.dump filestore.tar.gz odoo-project-config-and-addons.tar.gz MANIFEST.txt; do
  if sudo test -s "$BACKUP_DIR/$file"; then
    echo "OK: $file exists and is not empty"
  else
    echo "MISSING OR EMPTY: $file"
  fi
done

echo
echo "=== Test filestore archive ==="
sudo tar -tzf "$BACKUP_DIR/filestore.tar.gz" | head -20

echo
echo "=== Test config/addons archive ==="
sudo tar -tzf "$BACKUP_DIR/odoo-project-config-and-addons.tar.gz" | head -30

echo
echo "=== Test PostgreSQL dump metadata ==="
sudo cat "$BACKUP_DIR/database.dump" | sudo docker exec -i odoo-secure-db pg_restore -l | head -20

echo
echo "=== Show manifest ==="
sudo cat "$BACKUP_DIR/MANIFEST.txt"
)

How to interpret the result

Good signs:
- required files exist and are not empty
- filestore.tar.gz can be listed with tar -tzf
- odoo-project-config-and-addons.tar.gz can be listed with tar -tzf
- pg_restore -l can read the database dump metadata
- metadata shows the expected Odoo database name
- MANIFEST.txt exists and describes the backup

Replace BACKUP_DIR with the exact folder path printed in step 7. This avoids verifying the wrong backup folder.

Expected required files:
- database.dump
- filestore.tar.gz
- odoo-project-config-and-addons.tar.gz
- MANIFEST.txt

Optional but recommended:
- nginx-site.conf if Odoo is exposed through Nginx/HTTPS
- globals.sql with the PostgreSQL roles

How to interpret common results:

1. "Permission denied"
   The backup folder is probably protected with root-only permissions.
   Use sudo to list, read, and test backup files.
   Do not weaken backup folder permissions just to make commands easier.

2. tar archive cannot be listed
   The archive may be missing, incomplete, or corrupted.
   Recreate that part of the backup before trusting it.

3. pg_restore cannot read database.dump
   The database dump may be invalid or incomplete.
   Recreate the PostgreSQL dump and test again.

4. File exists but size is 0
   The backup command likely failed.
   Do not trust an empty backup file.

Important:
These checks prove that the backup files can be read at a basic level. This still does not replace a real restore test in a separate sandbox.

14. Test restore on a separate sandbox

Change required

The only reliable proof of a backup is a successful restore test. Never test restore first on production.

Show command and interpretation

Command or manual check

# Run on the sandbox host, never on production.
# This example uses the sandbox Docker Compose project from the sandbox guide:
#   folder /opt/odoo-sandbox
#   services odoo-sandbox-db (PostgreSQL) and odoo-sandbox (official Odoo image)
# Stop the sandbox Odoo service first:  sudo docker compose stop odoo-sandbox

# --- Part 1: restore the database and the filestore ---
(
set -o pipefail
cd /opt/odoo-sandbox || exit 1

BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"
SOURCE_DB="odoo_prod"          # database name inside the backup
RESTORE_DB="odoo_restore_test"   # new name for the restored copy
DB_USER="odoo"                   # PostgreSQL role used by the sandbox Odoo

if [ ! -d "$BACKUP_DIR" ]; then
  echo "Backup folder not found: $BACKUP_DIR"
  exit 1  # leaves only this ( ... ) block, not your SSH session
fi

echo "=== 1. Create an empty database (fails if the name already exists) ==="
sudo docker compose exec -T odoo-sandbox-db createdb -U "$DB_USER" "$RESTORE_DB" || exit 1

echo "=== 2. Restore the PostgreSQL dump ==="
sudo cat "$BACKUP_DIR/database.dump"   | sudo docker compose exec -T odoo-sandbox-db pg_restore -U "$DB_USER" -d "$RESTORE_DB" --no-owner --no-privileges --exit-on-error   || { echo "pg_restore failed. Review the error above."; exit 1; }

echo "=== 3. Restore the filestore under the new database name ==="
TMP_RESTORE="$(sudo mktemp -d)"
sudo tar -xzf "$BACKUP_DIR/filestore.tar.gz" -C "$TMP_RESTORE"
sudo docker compose run --rm --no-deps -u root -v "$TMP_RESTORE:/restore:ro" odoo-sandbox   sh -c "test ! -e /var/lib/odoo/filestore/$RESTORE_DB     && mkdir -p /var/lib/odoo/filestore     && cp -a /restore/$SOURCE_DB /var/lib/odoo/filestore/$RESTORE_DB     && chown -R odoo:odoo /var/lib/odoo/filestore/$RESTORE_DB"   || echo "Filestore copy failed: check that the target folder does not already exist."
sudo rm -rf "$TMP_RESTORE"
)

# --- Part 2: switch off side effects BEFORE Odoo serves the copy ---
cd /opt/odoo-sandbox
RESTORE_DB="odoo_restore_test"
DB_USER="odoo"

# Odoo 16 and later: built-in neutralization of the restored database.
sudo docker compose run --rm --no-deps odoo-sandbox odoo neutralize -d "$RESTORE_DB"

# Odoo 15 and earlier (no neutralize command): disable mail servers and scheduled actions.
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$RESTORE_DB" -c "
UPDATE ir_mail_server SET active = false;
UPDATE ir_cron SET active = false;
"

# --- Part 3: start the restored copy with crons off and outgoing mail blocked ---
# Runs in the foreground on the sandbox port (127.0.0.1:18069). Stop it with Ctrl+C.
sudo docker compose run --rm --service-ports odoo-sandbox   odoo -d "$RESTORE_DB" --db-filter="^$RESTORE_DB$"   --max-cron-threads=0 --smtp=127.0.0.1 --smtp-port=9

How to interpret the result

Run only the Part 2 command that matches your Odoo version.

How to read each part:

1. Database restore
   - createdb fails if the name already exists: choose a new RESTORE_DB instead of overwriting
   - --no-owner and --no-privileges restore objects under the sandbox role instead of the production role and grants
   - --exit-on-error stops at the first real error; a silent partial restore is worse than a failure

2. Filestore restore
   - the folder is renamed from odoo_prod to the new database name, because Odoo looks for filestore/<database_name>
   - files are owned by the odoo user inside the container, otherwise attachments fail to load or upload
   - docker compose run works even while the sandbox Odoo service is stopped

3. Side effects off before first start
   - Odoo 16 and later: odoo neutralize disables scheduled actions, mail servers, and other integrations, depending on the installed modules
   - Odoo 15 and earlier: there is no neutralize command, so outgoing mail servers and scheduled actions are disabled with SQL
   - --max-cron-threads=0 starts Odoo without running scheduled actions at all
   - --smtp=127.0.0.1 --smtp-port=9 points the fallback SMTP setting at a closed port inside the container, so no email can leave even if a mail server record was missed

4. Restore check, with the copy running
   Open http://127.0.0.1:18069 through an SSH tunnel (see the sandbox guide) and confirm:
   - Odoo starts without missing-module errors in the console output
   - users can log in
   - key records exist: contacts, products, invoices, sales orders
   - attachments, product images, and PDF reports open
   - custom modules are installed and work
   - no email was sent and no scheduled action ran

5. Document the result
   - backup date tested, database name, restore duration
   - restore options needed and issues found
   - who checked it and when

When the test is finished:
- stop Odoo with Ctrl+C
- keep the restored copy as a sandbox, or drop it with dropdb and remove its filestore folder

If you do not have a sandbox yet:
- stop here
- keep the backup safely stored
- create a sandbox first
- then come back and perform the restore test

A backup that has never been restored is only an assumption. Repeat this test regularly, and after any change to the backup process.

15. Automate backups carefully

Change required

Automated backups are useful, but they can create a false sense of safety if they are not monitored, rotated, and tested.

Show command and interpretation

Command or manual check

# Example: nightly backup with cron + restic to an encrypted, off-server repository,
# with retention and a success/failure alert. Adapt names, paths, and the repository.
# Borg (borg create / borg prune) can be used the same way instead of restic.

# --- 1. One-time setup ---
# root needs SSH key access to the backup host for the sftp repository.
sudo apt install -y restic
sudo install -d -m 700 /root/.config/odoo-backup
sudo sh -c 'umask 077; openssl rand -base64 32 > /root/.config/odoo-backup/restic-password'
sudo env RESTIC_PASSWORD_FILE=/root/.config/odoo-backup/restic-password \
  restic -r sftp:backup@backup-host.example.com:/srv/restic/odoo init

# --- 2. Backup script ---
sudo tee /usr/local/sbin/odoo-backup.sh >/dev/null <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

export RESTIC_REPOSITORY="sftp:backup@backup-host.example.com:/srv/restic/odoo"
export RESTIC_PASSWORD_FILE="/root/.config/odoo-backup/restic-password"
ALERT_URL="https://hc-ping.com/your-check-id"   # dead man's switch, e.g. Healthchecks.io

DB_CONTAINER="odoo-secure-db"
ODOO_CONTAINER="odoo-secure-app"
DB_NAME="odoo_prod"
PROJECT="/opt/odoo-secure"
STAGING="/opt/odoo-backups/nightly"

# Any failing command sends a failure alert, then stops the script.
trap 'curl -fsS -m 10 --retry 3 "$ALERT_URL/fail" >/dev/null || true' ERR

mkdir -p "$STAGING"
chmod 700 "$STAGING"

# Database and roles. The dump is renamed only after pg_dump succeeds.
docker exec "$DB_CONTAINER" pg_dump -U odoo -Fc "$DB_NAME" > "$STAGING/database.dump.tmp"
mv "$STAGING/database.dump.tmp" "$STAGING/database.dump"
docker exec "$DB_CONTAINER" pg_dumpall -U odoo --globals-only > "$STAGING/globals.sql"

# Filestore path, resolved from the container mount as in step 9.
DATA_DIR="$(docker inspect "$ODOO_CONTAINER" --format '{{range .Mounts}}{{if eq .Destination "/var/lib/odoo"}}{{.Source}}{{end}}{{end}}')"
test -d "$DATA_DIR/filestore/$DB_NAME"

restic backup --tag odoo \
  "$STAGING" \
  "$DATA_DIR/filestore/$DB_NAME" \
  "$PROJECT/docker-compose.yml" "$PROJECT/.env" "$PROJECT/config" "$PROJECT/addons" \
  /etc/nginx/sites-available

# Retention: 7 daily, 4 weekly, 6 monthly snapshots.
restic forget --tag odoo --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

# Success ping. If it never arrives, the monitoring service alerts you.
curl -fsS -m 10 --retry 3 "$ALERT_URL" >/dev/null
SCRIPT
sudo chmod 700 /usr/local/sbin/odoo-backup.sh

# --- 3. Run it once by hand and list the snapshots ---
sudo /usr/local/sbin/odoo-backup.sh
sudo env RESTIC_PASSWORD_FILE=/root/.config/odoo-backup/restic-password \
  restic -r sftp:backup@backup-host.example.com:/srv/restic/odoo snapshots

# --- 4. Schedule it every night at 02:30 ---
echo '30 2 * * * root /usr/local/sbin/odoo-backup.sh >> /var/log/odoo-backup.log 2>&1' \
  | sudo tee /etc/cron.d/odoo-backup >/dev/null

How to interpret the result

What this example gives you:
- one encrypted snapshot per night, stored off the server
- database dump, PostgreSQL roles, filestore, deployment files, Odoo config, custom addons, and Nginx sites in the same snapshot
- automatic retention: 7 daily, 4 weekly, and 6 monthly snapshots
- a failure alert when any command fails, and an alert from the monitoring service when no success ping arrives (cron not running, server down)

Good result after the manual run in part 3:
- the script ends without error
- restic snapshots lists one snapshot tagged odoo
- the monitoring service shows a successful ping

Before relying on it:
- store a copy of the restic password outside the server, for example in a password manager; without it the repository cannot be restored
- remove paths from the restic backup line that do not exist on your server, such as .env or addons, or the script stops with an error
- run restic check regularly, for example weekly, to verify the repository
- restore a snapshot into the sandbox (step 14) on a schedule, not only once
- watch /var/log/odoo-backup.log and the disk usage of /opt/odoo-backups

Avoid:
- silent backup failures
- backups filling the server disk
- backups stored only on the same VPS
- backups containing secrets without protection
- no restore testing
- no notification when backup fails

For production, automation should include alerting and a documented restore process.

Final verification

  • ✓The PostgreSQL database dump exists and can be read by pg_restore.
  • ✓The Odoo filestore is included and matches the database name.
  • ✓Custom addons are included or documented with their source and version.
  • ✓docker-compose.yml, odoo.conf, .env, and Nginx config are backed up securely.
  • ✓The backup has a manifest explaining what it contains.
  • ✓At least one copy exists off the server.
  • ✓Backup permissions protect secrets and customer data.
  • ✓A restore test has been performed on a separate sandbox.
  • ✓Backup automation includes monitoring, retention, and failure alerts.

Need a tested Odoo backup setup?

If your current Odoo backup is only a database dump, or if you are not sure whether your filestore, custom addons, configuration files, and secrets are included, I can help review or improve the backup process.

A reliable setup should include the PostgreSQL database, filestore, custom addons, deployment configuration, off-server copies, a manifest, and a restore test plan.

Contact me for Odoo backup setup →

Related guides

Backups are part of a wider safe Odoo deployment process. Harden the server first, then expose Odoo through HTTPS carefully.