← Back to guides

Odoo sandbox

How to create a safe Odoo sandbox

Prepare a separate Odoo environment for testing backups, restores, automations, upgrades, migrations, custom modules, and AI/database integrations without touching production.

The sandbox principle

A safe sandbox is a controlled copy of your Odoo environment. It should use a separate , a separate , and isolated service names, volumes, and ports. The goal is to test realistically without creating production risk.

Read this before creating a sandbox

A sandbox may contain a full copy of production data. Treat it with the same confidentiality as production. Do not expose it publicly without protection, do not use weak passwords, and do not leave real email, payment, webhook, or automation integrations active unless you fully understand the impact.
If the sandbox will be shared with external developers, contractors, testers, client users, AI tools, or third-party services, review whether production data should be anonymized or minimized first. A restored sandbox can contain real customer names, emails, phone numbers, addresses, invoices, attachments, employee records, portal users, API tokens, and private business data.
For sensitive environments, avoid giving broad access to a full production copy. Prefer limited access, temporary credentials, HTTPS protection, IP restrictions or VPN access, disabled integrations, and a clear decision on whether personal or confidential data must be masked before testing.
Before restoring anything, make sure you have a recent backup. The backup guide explains how to create the database dump, filestore archive, deployment config archive, and manifest.

Planning

Design decisions before creating or exposing a sandbox.

Safe check

Read-only checks that help confirm the sandbox is healthy and isolated.

Caution

Steps involving sensitive data, public access, or external integrations.

Change required

Actions that create, restore, modify, start, or expose a sandbox environment.

Sandbox checklist

1. Understand the sandbox goal

Planning

A sandbox is not just a copy of production. It should let you test safely without sending real emails, triggering real automations, exposing real customer data unnecessarily, or damaging production.

Show command and interpretation

Command or manual check

A safe Odoo sandbox can be used to test:

1. Backup restore
2. Odoo upgrades
3. Custom modules
4. Automation changes
5. API integrations
6. Migration scripts
7. AI/database tools
8. User training
9. Risky configuration changes

The sandbox should be separate from production.

How to interpret the result

A good sandbox should have:
- a separate database
- a separate filestore
- a separate Docker Compose project or server
- different ports from production
- no direct connection to production PostgreSQL
- no real email sending unless intentionally configured
- no real payment/webhook/automation side effects
- a clear visual label showing it is not production

Do not test restore, upgrades, or risky automations directly on production.

2. Decide where the sandbox will run

Planning

The safest option is a separate server. A separate Docker project on the same VPS can work for demos and internal testing, but it is less isolated than a different VPS.

Show command and interpretation

Command or manual check

Choose one sandbox option:

Option A — Separate VPS
- safest isolation
- recommended for production clients
- easier to destroy/rebuild without touching production

Option B — Same VPS, separate Docker Compose project
- practical for demos and light testing
- cheaper and faster
- must use separate ports, database volume, filestore volume, and project folder

Option C — Local developer machine
- useful for technical testing
- not ideal for business users unless they can access it

How to interpret the result

Recommended:
- use a separate VPS for serious client production restore tests
- use a separate Docker Compose project for quick internal validation
- avoid sharing the same database container unless you fully understand the risk

For this guide, the example uses:
- production folder: /opt/odoo-secure
- sandbox folder: /opt/odoo-sandbox
- production database: odoo_prod
- sandbox database: odoo_sandbox

3. Confirm production is healthy before copying anything

Safe check

Before creating a sandbox, confirm production is currently running and identify its containers, ports, database, and backup location.

Show command and interpretation

Command or manual check

echo "=== Production project folder ==="
cd /opt/odoo-secure
pwd

echo
echo "=== Production containers ==="
sudo docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"

echo
echo "=== Production database list ==="
sudo docker exec odoo-secure-db psql -U odoo -d postgres -c "
SELECT datname AS database_name
FROM pg_database
WHERE datistemplate = false
ORDER BY datname;
"

echo
echo "=== Latest backup folder ==="
sudo find /opt/odoo-backups -mindepth 1 -maxdepth 1 -type d -printf "%T@ %p\n" 2>/dev/null | sort -nr | head -5

How to interpret the result

Good signs:
- production Odoo container is running
- production PostgreSQL container is running
- the production database name is known
- a recent backup folder exists
- production Odoo ports are still local-only or behind Nginx
This example assumes:
- production PostgreSQL container: odoo-secure-db
- PostgreSQL user: odoo

If your container or database user is different, replace those values after checking:
- sudo docker ps
- your .env file
- your docker-compose.yml
Do not continue if production is already unhealthy unless the goal is incident recovery.

4. Create a separate sandbox folder

Change required

The sandbox needs its own project folder so configuration, volumes, and services are not mixed with production.

Show command and interpretation

Command or manual check

sudo mkdir -p /opt/odoo-sandbox
sudo chown "$USER:$USER" /opt/odoo-sandbox
chmod 750 /opt/odoo-sandbox

echo "=== Sandbox folder ==="
ls -ld /opt/odoo-sandbox

How to interpret the result

Expected:
- /opt/odoo-sandbox exists
- your deployment user can edit it
- it is separate from the production folder

Avoid:
- modifying production docker-compose.yml for sandbox tests
- reusing the same database volume names as production
- reusing the same public domain without clear separation

5. Copy production deployment files into the sandbox folder

Change required

The sandbox should start from a similar deployment structure, but it must be edited so it does not conflict with production or reuse production secrets unnecessarily.

Show command and interpretation

Command or manual check

# Run on: sandbox host.
# Same VPS example:
# Run this on the server where both production and sandbox folders exist.

cd /opt/odoo-sandbox

echo "=== Copy deployment structure from production ==="
cp -a /opt/odoo-secure/docker-compose.yml .
cp -a /opt/odoo-secure/config .
cp -a /opt/odoo-secure/addons .

# Copy .env only if you need it, then rotate/remove sensitive values.
cp -a /opt/odoo-secure/.env .env

echo
echo "=== Sandbox project files ==="
ls -la

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

echo
echo "=== REQUIRED before starting the sandbox ==="
echo "1. Change ODOO_DB_PASSWORD in .env to a NEW sandbox-only value if possible."
echo "2. Change admin_passwd in config/odoo.conf to a NEW sandbox-only value."
echo "3. Remove or rotate any production API keys, SMTP credentials, webhook tokens, or external integration secrets in .env."
echo "4. Do not reuse production secrets in a sandbox that may be exposed later."

echo
echo "=== Edit .env and odoo.conf before starting sandbox ==="
echo "Open and review these files manually:"
echo "- /opt/odoo-sandbox/.env"
echo "- /opt/odoo-sandbox/config/odoo.conf"

How to interpret the result

The sandbox folder should now contain:
- docker-compose.yml
- .env
- config/
- config/odoo.conf
- addons/

How to interpret the result:
- docker-compose.yml exists, but it is still copied from production
- .env exists, but it may contain production secrets
- config/odoo.conf exists, but it may still point to production values
- addons/ exists, even if empty

If the sandbox is on the same VPS:
- copying directly from /opt/odoo-secure is fine
- do not start the sandbox until docker-compose.yml and odoo.conf are edited
- rotate sandbox secrets before exposing the sandbox publicly

If the sandbox is on a separate VPS:
- this direct cp command will not work unless the production files already exist there
- copy the deployment files to the sandbox VPS first using scp, rsync, Git, or a prepared deployment archive
- copy the backup archive to the sandbox VPS before restoring the database and filestore
- never move production secrets to a weaker or less-protected sandbox host without review

Important:
- copied .env and odoo.conf files may contain production secrets
- reusing the production database password or Odoo admin_passwd in the sandbox means production and sandbox share credentials
- that is not acceptable for client environments if the sandbox is exposed or managed by different users
- remove or rotate production SMTP, API, payment, webhook, and integration credentials before using the sandbox
- do not expose the sandbox publicly until secrets and integrations have been reviewed

6. Edit Docker Compose for sandbox isolation

Change required

The sandbox must not reuse production container names, volume names, networks, or local ports. Otherwise it can conflict with production or accidentally reuse production data.

Show command and interpretation

Command or manual check

cd /opt/odoo-sandbox

echo "=== Back up copied docker-compose.yml before editing ==="
cp -a docker-compose.yml docker-compose.yml.before-sandbox-edit

echo
echo "=== Replace docker-compose.yml with isolated sandbox config ==="
cat > docker-compose.yml <<'YAML'
services:
  odoo-sandbox-db:
    image: ${POSTGRES_IMAGE}
    container_name: odoo-sandbox-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: postgres
      POSTGRES_USER: ${ODOO_DB_USER}
      POSTGRES_PASSWORD: ${ODOO_DB_PASSWORD}
    volumes:
      - odoo-sandbox-db-data:/var/lib/postgresql/data
    networks:
      - odoo-sandbox-internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${ODOO_DB_USER} -d postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  odoo-sandbox:
    image: ${ODOO_IMAGE}
    container_name: odoo-sandbox-app
    restart: unless-stopped
    depends_on:
      odoo-sandbox-db:
        condition: service_healthy
    environment:
      HOST: odoo-sandbox-db
      USER: ${ODOO_DB_USER}
      PASSWORD: ${ODOO_DB_PASSWORD}
    ports:
      - "127.0.0.1:18069:8069"
      - "127.0.0.1:18072:8072"
    volumes:
      - odoo-sandbox-web-data:/var/lib/odoo
      - ./config:/etc/odoo
      - ./addons:/mnt/extra-addons
    networks:
      - odoo-sandbox-internal

networks:
  odoo-sandbox-internal:
    driver: bridge

volumes:
  odoo-sandbox-db-data:
  odoo-sandbox-web-data:
YAML

echo
echo "=== Verify sandbox docker-compose.yml ==="
cat docker-compose.yml

How to interpret the result

This command replaces the copied production docker-compose.yml with a sandbox-isolated version.

Good signs:
- container names use sandbox names:
  - odoo-sandbox-app
  - odoo-sandbox-db

- Docker volumes use sandbox names:
  - odoo-sandbox-db-data
  - odoo-sandbox-web-data

- local ports are different from production:
  - 127.0.0.1:18069:8069
  - 127.0.0.1:18072:8072

- the sandbox database host points to the sandbox database service:
  - HOST: odoo-sandbox-db

- the sandbox uses its own Docker network:
  - odoo-sandbox-internal

Avoid:
- reusing production Docker volumes
- reusing production container names
- binding Odoo to 0.0.0.0:8069
- binding PostgreSQL to 0.0.0.0:5432
- starting the sandbox before editing docker-compose.yml and odoo.conf

Important:
This step only updates docker-compose.yml. The next step must update config/odoo.conf so db_host and dbfilter also match the sandbox.

7. Edit odoo.conf for sandbox database and safety

Change required

The sandbox config should point to the sandbox database, use safe proxy settings, and clearly separate itself from production.

Show command and interpretation

Command or manual check

cd /opt/odoo-sandbox

echo "=== Back up copied odoo.conf before sandbox edit ==="
cp -a config/odoo.conf config/odoo.conf.before-sandbox-edit

echo
echo "=== Apply sandbox-safe config changes ==="
sed -i 's/^db_host = .*/db_host = odoo-sandbox-db/' config/odoo.conf
sed -i 's/^dbfilter = .*/dbfilter = ^odoo_sandbox$/' config/odoo.conf
sed -i 's/^list_db = .*/list_db = False/' config/odoo.conf
sed -i 's/^proxy_mode = .*/proxy_mode = True/' config/odoo.conf

echo
echo "=== Review sandbox config without secrets ==="
grep -E "^(admin_passwd|db_host|db_port|db_user|db_password|proxy_mode|list_db|dbfilter|addons_path|data_dir|without_demo)" config/odoo.conf \
  | sed -E 's/(admin_passwd|db_password) = .+/\1 = ***hidden***/'

How to interpret the result

Good result:
- db_host points to the sandbox PostgreSQL service
- dbfilter matches the sandbox database only
- list_db stays False
- proxy_mode stays True if Odoo is behind a reverse proxy
- addons_path still includes the sandbox custom addons mount
- secrets are hidden when printed

Expected sandbox values:
- db_host = odoo-sandbox-db
- dbfilter = ^odoo_sandbox$
- list_db = False
- proxy_mode = True

Why this matters:
- db_host = odoo-sandbox-db makes the sandbox Odoo app connect to the sandbox PostgreSQL container
- dbfilter = ^odoo_sandbox$ prevents Odoo from using the production database name
- list_db = False avoids showing the database selector publicly
- proxy_mode = True keeps Odoo compatible with Nginx/HTTPS if the sandbox is later exposed through a reverse proxy

Important:
This step edits only the sandbox config file in /opt/odoo-sandbox/config/odoo.conf. It should not modify production config in /opt/odoo-secure/config/odoo.conf.

Do not use admin/admin passwords on an exposed sandbox.

8. Start the empty sandbox containers

Change required

Start the sandbox stack so PostgreSQL is available and Docker volumes are created. Then stop the sandbox Odoo app before restore so scheduled actions cannot run from restored production data.

Show command and interpretation

Command or manual check

# Run this on the sandbox server or sandbox VPS.
# This example assumes the sandbox is managed with Docker Compose.

cd /opt/odoo-sandbox

echo "=== Start sandbox containers ==="
sudo docker compose up -d

echo
echo "=== Running Docker containers ==="
sudo docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}"

echo
echo "=== Sandbox compose status ==="
sudo docker compose ps

echo
echo "=== Wait for sandbox PostgreSQL to be healthy ==="
for i in {1..30}; do
  DB_STATUS="$(sudo docker inspect -f '{{.State.Health.Status}}' odoo-sandbox-db 2>/dev/null || echo unknown)"

  if [ "$DB_STATUS" = "healthy" ]; then
    echo "Sandbox PostgreSQL is healthy"
    break
  fi

  echo "Waiting for sandbox PostgreSQL... status=$DB_STATUS"
  sleep 2
done

echo
echo "=== Stop sandbox Odoo before restoring production data ==="
sudo docker compose stop odoo-sandbox

echo
echo "=== Final sandbox status before restore ==="
sudo docker compose ps

How to interpret the result

Good signs:
- sandbox PostgreSQL container is running and healthy
- sandbox Odoo container was created successfully
- sandbox Odoo is stopped before database restore
- sandbox ports are different from production
- sandbox Docker volumes were created
- production containers are still running separately if sandbox is on the same VPS

Expected sandbox examples:
- odoo-sandbox-db is running and healthy
- odoo-sandbox-app exists but is stopped before restore
- 127.0.0.1:18069->8069/tcp
- 127.0.0.1:18072->8072/tcp

Why stop Odoo after starting the containers:
- Docker volumes need to be created before restoring the filestore
- PostgreSQL needs to be running before restoring the database
- but the Odoo app should not run restored production scheduled actions before safety changes are applied

If the sandbox is on a separate VPS:
- run this step on the sandbox VPS
- production containers will not appear in docker ps
- make sure the backup files were copied to the sandbox VPS before restore

If the database does not become healthy:
- check docker compose logs odoo-sandbox-db
- check .env values
- check duplicate volume or container names

Important:
Do not continue to restore data until the sandbox database container is healthy and the sandbox Odoo app is stopped.

9. Create the sandbox database from the backup dump

Change required

This step restores the production backup into a separate sandbox database. It must not target the production database.

Show command and interpretation

Command or manual check

# Run on: sandbox host.
# These commands assume the PostgreSQL role is odoo.
# If your ODOO_DB_USER is different, replace DB_USER accordingly.

BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"
SANDBOX_DB="odoo_sandbox"
DB_USER="odoo"

cd /opt/odoo-sandbox

echo "=== Confirm sandbox database container ==="
sudo docker compose ps odoo-sandbox-db

echo
echo "=== Confirm current databases in sandbox PostgreSQL ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d postgres -c "
SELECT datname AS database_name
FROM pg_database
WHERE datistemplate = false
ORDER BY datname;
"

echo
echo "=== Drop sandbox database if it already exists ==="
sudo docker compose exec -T odoo-sandbox-db dropdb -U "$DB_USER" --if-exists "$SANDBOX_DB"

echo
echo "=== Create sandbox database ==="
sudo docker compose exec -T odoo-sandbox-db createdb -U "$DB_USER" "$SANDBOX_DB"

echo
echo "=== Restore database dump into sandbox database ==="
set -o pipefail
sudo cat "$BACKUP_DIR/database.dump" \
  | sudo docker compose exec -T odoo-sandbox-db pg_restore -U "$DB_USER" -d "$SANDBOX_DB" --no-owner --no-privileges --exit-on-error

RESTORE_EXIT_CODE=${PIPESTATUS[1]}
echo "pg_restore exit code: $RESTORE_EXIT_CODE"

if [ "$RESTORE_EXIT_CODE" -ne 0 ]; then
  echo "pg_restore failed. Stop here and review the error above."
  exit "$RESTORE_EXIT_CODE"
fi

echo
echo "=== Confirm core Odoo tables exist ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
  AND table_name IN ('res_users', 'res_partner', 'ir_attachment', 'ir_module_module')
ORDER BY table_name;
"

echo
echo "=== Confirm sandbox database exists ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d postgres -c "
SELECT datname AS database_name
FROM pg_database
WHERE datistemplate = false
ORDER BY datname;
"

How to interpret the result

Good signs:
- the command targets the sandbox database service: odoo-sandbox-db
- the sandbox database is created
- pg_restore exit code is 0
- core Odoo tables exist

Expected tables:
- ir_attachment
- ir_module_module
- res_partner
- res_users

Important:
- this command must target the sandbox PostgreSQL container
- never run restore commands against the production database container unless you are intentionally restoring production
- --no-owner avoids restoring production object ownership
- --no-privileges avoids restoring production grants/ACLs that may not exist on the sandbox server
- --exit-on-error stops on the first real restore failure
- set -o pipefail helps detect restore failures in the pipeline

For this guide's Docker Compose example:
- sandbox database service: odoo-sandbox-db
- sandbox database name: odoo_sandbox
- production database name: odoo_prod
- PostgreSQL user: odoo

If your ODOO_DB_USER is not odoo, replace DB_USER with the correct role from your .env or docker-compose.yml.

10. Restore the matching filestore

Change required

The restored database references files by checksum. The matching filestore must be restored under the sandbox database name.

Show command and interpretation

Command or manual check

# Run on: sandbox host.
# These commands assume the Odoo service is odoo-sandbox.

BACKUP_DIR="/opt/odoo-backups/YYYYMMDD-HHMMSS"
SANDBOX_DB="odoo_sandbox"

cd /opt/odoo-sandbox

echo "=== Confirm sandbox Odoo container ==="
sudo docker compose ps odoo-sandbox

echo
echo "=== Prepare filestore parent folder inside sandbox container ==="
sudo docker compose exec -T odoo-sandbox sh -c "mkdir -p /var/lib/odoo/filestore"

echo
echo "=== Find sandbox Docker volumes ==="
sudo docker volume ls | grep -Ei "sandbox.*web|odoo-sandbox"

echo
echo "=== Restore filestore archive into temporary folder ==="
TMP_RESTORE="/tmp/odoo-filestore-restore-$SANDBOX_DB"
sudo rm -rf "$TMP_RESTORE"
mkdir -p "$TMP_RESTORE"

sudo tar -xzf "$BACKUP_DIR/filestore.tar.gz" -C "$TMP_RESTORE"

echo
echo "=== Show extracted filestore folder ==="
sudo find "$TMP_RESTORE" -mindepth 1 -maxdepth 1 -type d -print

echo
echo "=== Rename production filestore folder to sandbox database name if needed ==="
if [ -d "$TMP_RESTORE/odoo_prod" ] && [ "odoo_prod" != "$SANDBOX_DB" ]; then
  sudo mv "$TMP_RESTORE/odoo_prod" "$TMP_RESTORE/$SANDBOX_DB"
fi

echo
echo "=== Copy filestore into sandbox Odoo container ==="
sudo docker compose cp "$TMP_RESTORE/$SANDBOX_DB" "odoo-sandbox:/var/lib/odoo/filestore/$SANDBOX_DB"

echo
echo "=== Fix ownership inside sandbox container if needed ==="
sudo docker compose exec -T odoo-sandbox sh -c "chown -R $(id -u):$(id -g) /var/lib/odoo/filestore/$SANDBOX_DB || true"

echo
echo "=== Verify filestore inside sandbox container ==="
sudo docker compose exec -T odoo-sandbox sh -c "ls -la /var/lib/odoo/filestore/$SANDBOX_DB | head"

echo
echo "=== Check for accidental nested filestore folder ==="
sudo docker compose exec -T odoo-sandbox sh -c "test ! -d /var/lib/odoo/filestore/$SANDBOX_DB/$SANDBOX_DB && echo 'OK: no nested filestore folder' || echo 'WARNING: nested filestore folder detected'"

echo
echo "=== Clean temporary restore folder ==="
sudo rm -rf "$TMP_RESTORE"

How to interpret the result

Good signs:
- the command targets the sandbox Odoo service: odoo-sandbox
- the filestore parent folder exists inside the sandbox container
- filestore is restored under the sandbox database name
- the folder contains many subfolders and files
- the sandbox database name and filestore folder name match

Example:
- database: odoo_sandbox
- filestore folder: /var/lib/odoo/filestore/odoo_sandbox

If the copy fails with "Could not find the file /var/lib/odoo/filestore":
- the parent filestore folder did not exist inside the sandbox container
- create it first with mkdir -p /var/lib/odoo/filestore
- then copy the restored filestore again

If images or attachments are missing after login:
- confirm the filestore folder name matches the sandbox database name
- confirm the filestore was copied into the active Odoo data_dir
- confirm file ownership/permissions inside the container

Important:
The backup filestore may originally be named after the production database, such as odoo_prod. For sandbox restore, it should be renamed to odoo_sandbox so Odoo can find the files for the sandbox database.

11. Disable risky sandbox side effects

Caution

A restored database may contain outgoing email settings, incoming mail fetchers, scheduled actions, webhooks, API keys, payment settings, or automation triggers. Disable risky side effects before real testing.

Show command and interpretation

Command or manual check

# Run on: sandbox host.
# Keep sandbox PostgreSQL running, but keep sandbox Odoo stopped while changing restored data.

SANDBOX_DB="odoo_sandbox"
DB_USER="odoo"

cd /opt/odoo-sandbox

echo "=== Stop sandbox Odoo before disabling side effects ==="
sudo docker compose stop odoo-sandbox

echo
echo "=== Neutralize sandbox base URL before any public access option ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
UPDATE ir_config_parameter
SET value = 'http://localhost:18069'
WHERE key = 'web.base.url';

INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
SELECT 'web.base.url', 'http://localhost:18069', 1, NOW(), 1, NOW()
WHERE NOT EXISTS (
  SELECT 1 FROM ir_config_parameter WHERE key = 'web.base.url'
);

SELECT key, value
FROM ir_config_parameter
WHERE key = 'web.base.url';
"

echo
echo "=== Disable outgoing mail servers in sandbox ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
UPDATE ir_mail_server SET active = false;
SELECT
  COUNT(*) AS outgoing_mail_servers_total,
  COUNT(*) FILTER (WHERE active = true) AS outgoing_mail_servers_still_active
FROM ir_mail_server;
"

echo
echo "=== Disable incoming mail servers if table exists ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
DO \$\$
BEGIN
  IF to_regclass('public.fetchmail_server') IS NOT NULL THEN
    UPDATE fetchmail_server SET active = false;
  END IF;
END
\$\$;

SELECT
  CASE
    WHEN to_regclass('public.fetchmail_server') IS NULL THEN 'fetchmail_server table not installed'
    ELSE 'fetchmail_server table checked'
  END AS incoming_mail_check;
"

echo
echo "=== Verify incoming mail servers if table exists ==="
if sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -tAc "SELECT to_regclass('public.fetchmail_server') IS NOT NULL;" | grep -q t; then
  sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
  SELECT
    COUNT(*) AS incoming_mail_servers_total,
    COUNT(*) FILTER (WHERE active = true) AS incoming_mail_servers_still_active
  FROM fetchmail_server;
  "
else
  echo "No incoming mail server table found. This is okay."
fi

echo
echo "=== Disable scheduled actions in sandbox ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
UPDATE ir_cron SET active = false;
SELECT
  COUNT(*) AS scheduled_actions_total,
  COUNT(*) FILTER (WHERE active = true) AS scheduled_actions_still_active,
  COUNT(*) FILTER (WHERE active = false) AS scheduled_actions_disabled
FROM ir_cron;
"

echo
echo "=== Final sandbox side-effect safety summary ==="
sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
SELECT 'Outgoing mail servers still active' AS check_name, COUNT(*)::text AS result
FROM ir_mail_server
WHERE active = true

UNION ALL

SELECT 'Scheduled actions still active' AS check_name, COUNT(*)::text AS result
FROM ir_cron
WHERE active = true

UNION ALL

SELECT 'Sandbox base URL' AS check_name, value AS result
FROM ir_config_parameter
WHERE key = 'web.base.url';
"

How to interpret the result

Good signs:
- the command targets the sandbox database service: odoo-sandbox-db
- sandbox Odoo is stopped before disabling risky actions
- web.base.url is neutralized before any public access option
- outgoing mail servers are disabled or no mail servers exist
- incoming mail fetchers are disabled if the module/table exists
- scheduled actions are disabled before real testing

How to interpret common results:

1. Stop sandbox Odoo before disabling side effects

Expected:
- the sandbox Odoo container stops successfully

Why:
- this prevents scheduled actions from running while you are editing the restored sandbox database

2. Neutralize sandbox base URL

Good:
- web.base.url = http://localhost:18069

Meaning:
- the restored sandbox no longer generates links pointing to the production domain
- if you later expose the sandbox with HTTPS, update web.base.url again to the sandbox HTTPS domain

3. Disable outgoing mail servers in sandbox

Important output:
- outgoing_mail_servers_total
- outgoing_mail_servers_still_active

Good:
- outgoing_mail_servers_still_active = 0

Meaning:
- 0 total means there were no outgoing mail servers configured
- total greater than 0 with still_active = 0 means mail servers existed and are now disabled

4. Disable incoming mail servers if table exists

Good:
- "fetchmail_server table not installed" is okay
- "No incoming mail server table found. This is okay." is okay
- if incoming mail servers exist, incoming_mail_servers_still_active should be 0

Meaning:
- some Odoo databases do not have incoming mail fetching installed
- if the table does not exist, there is nothing to disable for incoming mail
- if PostgreSQL prints DO, it means the conditional safety block executed successfully

5. Disable scheduled actions in sandbox

Important output:
- scheduled_actions_total
- scheduled_actions_still_active
- scheduled_actions_disabled

Good:
- scheduled_actions_still_active = 0
- scheduled_actions_disabled should usually be greater than 0 on a normal restored Odoo database

Meaning:
- scheduled actions are Odoo cron jobs
- disabling them prevents the sandbox from automatically running background tasks from production data

6. Final sandbox side-effect safety summary

Good:
- Outgoing mail servers still active = 0
- Scheduled actions still active = 0
- Sandbox base URL = http://localhost:18069, unless you intentionally changed it later to a protected sandbox HTTPS domain

If any active value is greater than 0:
- stop before continuing
- identify what is still active
- disable it intentionally or document why it must stay active

Recommended sandbox safety actions:
- disable outgoing email servers
- disable incoming mail fetchers if present
- disable or review scheduled actions
- disable payment provider live mode
- disable external webhooks if present
- disable production API keys
- disable integrations that can update external systems
- add a clear sandbox label/banner if possible

Important:
These changes should be made in the sandbox database only, not production. After this step, restart sandbox Odoo in the next step.

12. Start Odoo against the restored sandbox database

Change required

After restoring the database and filestore, restart Odoo so it loads the restored sandbox database cleanly.

Show command and interpretation

Command or manual check

cd /opt/odoo-sandbox

echo "=== Restart sandbox Odoo ==="
sudo docker compose restart odoo-sandbox

echo
echo "=== Check sandbox Odoo logs ==="
sudo docker compose logs --tail=100 odoo-sandbox

echo
echo "=== Test local sandbox Odoo ==="
curl -I --connect-timeout 10 http://127.0.0.1:18069/ || echo "Sandbox Odoo did not respond yet. Check logs above."

How to interpret the result

Good signs:
- the command targets the sandbox Odoo service: odoo-sandbox
- Odoo starts without fatal errors
- local sandbox URL returns a normal Odoo response
- logs do not show missing critical modules
- logs do not show database connection failures

Common issues:
- missing custom addons
- wrong dbfilter
- wrong database name
- filestore restored under the wrong folder name
- duplicate ports with production
- Odoo starts before the restored database is ready

If the local curl test fails:
- check the logs output
- confirm odoo_sandbox exists in the sandbox database container
- confirm db_host = odoo-sandbox-db in config/odoo.conf
- confirm dbfilter = ^odoo_sandbox$ in config/odoo.conf

13. Expose the sandbox safely if needed

Caution

A sandbox does not always need to be public. If it must be accessible from a browser, put it behind HTTPS and protect sensitive routes.

Show command and interpretation

Command or manual check

Choose one access option.

Option A — Keep sandbox local-only with SSH tunnel
Best for technical restore validation and private testing.

# Run this from your local computer, not from the server:
ssh -L 18069:127.0.0.1:18069 your-user@your-server-ip

# Then open this on your local computer:
http://127.0.0.1:18069/

# Verify on the server that sandbox ports are local-only:
sudo ss -tulpn | grep -E ":18069|:18072"


Option B — Use a protected sandbox subdomain
Best for client delivery, user testing, training, or demos.

Example sandbox subdomain:
sandbox-odoo.example.com

The setup is similar to the Odoo HTTPS subdomain guide, but the sandbox Nginx site should proxy to:
- Odoo HTTP: 127.0.0.1:18069
- Odoo websocket/longpolling: 127.0.0.1:18072

Recommended protections:
- HTTPS certificate
- HTTP redirects to HTTPS
- database manager routes blocked
- strong Odoo admin password
- HTTP basic auth or IP allowlist if data is sensitive

Related guide:
Open the “How to host Odoo on a subdomain with HTTPS” guide from the Related guides section below, then adapt the upstream ports to the sandbox ports.


Step B1 — Create DNS record

In your DNS provider, create:

Type: A
Name: sandbox-odoo
Value: YOUR_SERVER_PUBLIC_IP
TTL: Auto or default

Then verify:

dig +short sandbox-odoo.example.com
dig @8.8.8.8 +short sandbox-odoo.example.com
dig @1.1.1.1 +short sandbox-odoo.example.com

If public DNS resolves but the server resolver does not, check authoritative DNS:

dig NS YOUR_DOMAIN.com +short

# Replace YOUR_DOMAIN.com with your real domain.
# Then query the authoritative nameservers directly:
for ns in $(dig NS YOUR_DOMAIN.com +short); do
  echo "--- $ns ---"
  dig @"$ns" +short sandbox-odoo.example.com
done


Step B2 — Create Basic Auth credentials

sudo apt update
sudo apt install -y apache2-utils

sudo htpasswd -c /etc/nginx/.htpasswd-odoo-sandbox sandbox


Step B3 — Create the initial HTTP Nginx site

sudo tee /etc/nginx/sites-available/sandbox-odoo.example.com >/dev/null <<'NGINX'
server {
    listen 80;
    listen [::]:80;

    server_name sandbox-odoo.example.com;

    client_max_body_size 100M;

    location ~* ^/web/database/(manager|selector|create|drop|backup|restore|duplicate) {
        return 403;
    }

    location / {
        proxy_pass http://127.0.0.1:18069;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 720s;
        proxy_connect_timeout 720s;
        proxy_send_timeout 720s;

        proxy_buffering off;
    }

    location /websocket {
        proxy_pass http://127.0.0.1:18072;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 720s;
        proxy_connect_timeout 720s;
        proxy_send_timeout 720s;
    }
}
NGINX

sudo ln -s /etc/nginx/sites-available/sandbox-odoo.example.com /etc/nginx/sites-enabled/sandbox-odoo.example.com 2>/dev/null || echo "Site may already be enabled"

sudo nginx -t
sudo systemctl reload nginx


Step B4 — Test HTTP before HTTPS

curl -I http://sandbox-odoo.example.com/
curl -I http://sandbox-odoo.example.com/web/database/manager

# If the server has a DNS cache issue but public DNS is correct, test with:
curl -I --resolve sandbox-odoo.example.com:80:YOUR_SERVER_PUBLIC_IP http://sandbox-odoo.example.com/
curl -I --resolve sandbox-odoo.example.com:80:YOUR_SERVER_PUBLIC_IP http://sandbox-odoo.example.com/web/database/manager


Step B5 — Issue HTTPS certificate

sudo certbot --nginx -d sandbox-odoo.example.com


Step B6 — Test HTTPS before Basic Auth

curl -I http://sandbox-odoo.example.com/
curl -I https://sandbox-odoo.example.com/
curl -I https://sandbox-odoo.example.com/web/database/manager

# If the server resolver still has DNS cache issues, test with:
curl -I --resolve sandbox-odoo.example.com:80:YOUR_SERVER_PUBLIC_IP http://sandbox-odoo.example.com/
curl -I --resolve sandbox-odoo.example.com:443:YOUR_SERVER_PUBLIC_IP https://sandbox-odoo.example.com/
curl -I --resolve sandbox-odoo.example.com:443:YOUR_SERVER_PUBLIC_IP https://sandbox-odoo.example.com/web/database/manager


Step B7 — Add Basic Auth after HTTPS works

sudo tee /etc/nginx/sites-available/sandbox-odoo.example.com >/dev/null <<'NGINX'
server {
    listen 80;
    listen [::]:80;

    server_name sandbox-odoo.example.com;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name sandbox-odoo.example.com;

    ssl_certificate /etc/letsencrypt/live/sandbox-odoo.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sandbox-odoo.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    client_max_body_size 100M;

    # Block database manager routes before they reach Odoo.
    # auth_basic off avoids showing a password prompt for a route that is always forbidden.
    location ~* ^/web/database/(manager|selector|create|drop|backup|restore|duplicate) {
        auth_basic off;
        return 403;
    }

    location / {
        auth_basic "Odoo Sandbox";
        auth_basic_user_file /etc/nginx/.htpasswd-odoo-sandbox;

        proxy_pass http://127.0.0.1:18069;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 720s;
        proxy_connect_timeout 720s;
        proxy_send_timeout 720s;

        proxy_buffering off;
    }

    location /websocket {
        auth_basic "Odoo Sandbox";
        auth_basic_user_file /etc/nginx/.htpasswd-odoo-sandbox;

        proxy_pass http://127.0.0.1:18072;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_read_timeout 720s;
        proxy_connect_timeout 720s;
        proxy_send_timeout 720s;
    }
}
NGINX

sudo nginx -t
sudo systemctl reload nginx


Step B8 — Final protected HTTPS test

curl -I https://sandbox-odoo.example.com/
curl -I https://sandbox-odoo.example.com/web/database/manager

# If DNS cache is still delayed on the server:
curl -I --resolve sandbox-odoo.example.com:443:YOUR_SERVER_PUBLIC_IP https://sandbox-odoo.example.com/
curl -I --resolve sandbox-odoo.example.com:443:YOUR_SERVER_PUBLIC_IP https://sandbox-odoo.example.com/web/database/manager


Step B9 — Update the sandbox Odoo base URL

After exposing the sandbox through a subdomain, update the restored sandbox database so Odoo generates URLs using the sandbox domain instead of the production domain.

SANDBOX_DB="odoo_sandbox"
DB_USER="odoo"

cd /opt/odoo-sandbox

sudo docker compose exec -T odoo-sandbox-db psql -U "$DB_USER" -d "$SANDBOX_DB" -c "
UPDATE ir_config_parameter
SET value = 'https://sandbox-odoo.example.com'
WHERE key = 'web.base.url';

INSERT INTO ir_config_parameter (key, value, create_uid, create_date, write_uid, write_date)
SELECT 'web.base.url', 'https://sandbox-odoo.example.com', 1, NOW(), 1, NOW()
WHERE NOT EXISTS (
  SELECT 1 FROM ir_config_parameter WHERE key = 'web.base.url'
);

SELECT key, value
FROM ir_config_parameter
WHERE key = 'web.base.url';
"

sudo docker compose restart odoo-sandbox


Option C — Temporary public access only
Best for short controlled tests.

Use only when needed:
- expose access for a short test window
- restrict access with basic auth, IP allowlist, or VPN
- remove the Nginx site or disable access after the test
- do not leave restored production data publicly reachable


Basic verification commands for any option:

echo "=== Sandbox direct ports should not be public ==="
sudo ss -tulpn | grep -E ":18069|:18072"

echo
echo "=== Docker published ports ==="
sudo docker ps --format "table {{.Names}}\t{{.Ports}}"

echo
echo "=== Local sandbox response ==="
curl -I http://127.0.0.1:18069/

echo
echo "=== Protected HTTPS homepage should require Basic Auth if Option B is used ==="
curl -I https://sandbox-odoo.example.com/

echo
echo "=== Database manager route should be blocked if Option B is used ==="
curl -I https://sandbox-odoo.example.com/web/database/manager

How to interpret the result

Choose the access method based on the risk level.

Option A — Local-only SSH tunnel

Good result:
- sandbox ports are bound to 127.0.0.1 on the server
- the sandbox opens from your computer through the SSH tunnel
- no public DNS or Nginx exposure is required

How to interpret:
- 127.0.0.1:18069 means the port is local-only on the server
- users cannot access it directly from the public internet
- this is the safest option for technical validation


Option B — Protected sandbox subdomain

Good result before Basic Auth:
- HTTP returns 301 or 308 redirect to HTTPS
- HTTPS returns 200 OK, 303, or another normal Odoo response
- /web/database routes return 403 Forbidden
- Odoo direct ports remain local-only
- PostgreSQL is not public

Good result after Basic Auth:
- HTTPS homepage returns 401 Unauthorized without Basic Auth credentials
- the response includes WWW-Authenticate: Basic realm="Odoo Sandbox"
- /web/database routes still return 403 Forbidden
- Odoo direct ports remain local-only
- PostgreSQL is not public

How to interpret:
- 200 OK before Basic Auth means Nginx can reach the sandbox Odoo service
- 301 or 308 on HTTP means HTTP redirects to HTTPS
- 401 Unauthorized after Basic Auth means the sandbox is protected before visitors reach Odoo
- 403 Forbidden on /web/database routes means sensitive database manager routes are blocked
- this option is better for client delivery, demos, user testing, and training
- it is more convenient than SSH tunnel access
- it is also riskier because the sandbox becomes reachable from the internet

DNS/cache interpretation:
- if authoritative DNS and public resolvers return the correct IP but curl says "Could not resolve host", the server resolver may have stale DNS cache
- in that case, test with curl --resolve or wait for local DNS cache to refresh
- do not run Certbot until public DNS resolvers can resolve the sandbox subdomain

Nginx warning interpretation:
- nginx -t must return successful before reload
- warnings such as "protocol options redefined" may not block Nginx, but they can be cleaned later
- if nginx -t fails, do not reload until the error is fixed

Sandbox base URL interpretation:
- UPDATE 1 means an existing web.base.url value was updated
- INSERT 0 0 usually means the value already existed, so no new row was inserted
- web.base.url should show the sandbox HTTPS domain
- this prevents the restored sandbox from generating links pointing back to production

For sensitive or real client data:
- Basic Auth over HTTPS is a minimum protection, not a complete security boundary
- prefer IP allowlist, VPN, or private network access when possible
- do not expose restored production data publicly unless the client explicitly accepts the risk

Good result:
- web.base.url = https://sandbox-odoo.example.com


Option C — Temporary public access

Good result:
- access is enabled only during a controlled test window
- access is removed or disabled after testing
- public exposure is documented
- sensitive integrations remain disabled

How to interpret:
- this is useful for short demos or client validation sessions
- it should not become a forgotten second production system


Important safety checks:
- Odoo direct ports should not be bound to 0.0.0.0
- PostgreSQL should not be public
- database manager routes should not be public
- restored production data should not be exposed without access control
- real emails, webhooks, payments, scheduled actions, and production integrations should stay disabled or controlled

If you expose the sandbox publicly:
- clearly label it as sandbox
- avoid weak passwords such as admin/admin
- use basic auth, IP allowlist, or VPN for sensitive data
- remove public access when no longer needed

14. Verify the restored sandbox

Safe check

The goal is not only that Odoo starts. The restored sandbox should prove that the backup is usable.

Show command and interpretation

Command or manual check

Manual verification checklist:

1. Open the sandbox Odoo URL.
2. Log in with a known admin user.
3. Confirm key records exist:
   - users
   - contacts
   - products
   - invoices
   - sales orders
4. Open records with attachments or images.
5. Confirm custom modules are installed or available.
6. Confirm reports load.
7. Confirm no real emails or webhooks are triggered.
8. Document any restore issues.

How to interpret the result

A successful restore test confirms:
- Odoo starts
- users can log in
- key records exist
- attachments and images load
- custom modules are available
- reports and important workflows work
- integrations are disabled or safely sandboxed

After this step, the backup is much more trustworthy because it has been restored and checked in a separate environment.

15. Document cleanup and next use

Planning

A sandbox should not become an unmanaged second production. Decide whether to keep it, reset it, or destroy it after testing.

Show command and interpretation

Command or manual check

After the test, decide:

Option A — Keep the sandbox
- useful for training and future tests
- must be maintained and secured
- should be clearly labeled

Option B — Reset the sandbox
- useful before each test cycle
- restore a fresh backup when needed

Option C — Destroy the sandbox
- safest after one-time restore testing
- frees server resources
- reduces exposure

How to interpret the result

Good operational practice:
- document the restore result
- document problems found
- document the backup date tested
- document whether attachments and custom modules worked
- disable external integrations
- clean temporary files
- remove public exposure if no longer needed

A sandbox is valuable only if it stays clearly separated from production.

Final verification

  • The sandbox uses a separate database from production.
  • The sandbox uses a separate filestore from production.
  • The sandbox uses separate Docker containers, volumes, and ports.
  • The sandbox restore was performed from a real backup.
  • The sandbox starts and users can log in.
  • Attachments, images, PDFs, and important records load correctly.
  • Custom addons are present or documented.
  • Outgoing emails, webhooks, payments, and risky automations are disabled or controlled.
  • The sandbox is clearly labeled and not confused with production.
  • Public access is avoided, restricted, or protected with HTTPS and access controls.
  • Sensitive production data is anonymized, minimized, or access-restricted when the sandbox is shared.

Need help creating a safe Odoo sandbox?

If you need to test an Odoo backup restore, migration, automation, custom module, upgrade, or AI/database workflow without touching production, I can help design or prepare a safer sandbox environment.

I can help review the current production setup, identify what should be isolated, restore the database and filestore into a separate environment, and reduce risky side effects such as real emails, webhooks, payments, or production integrations.

Contact me about an Odoo sandbox →

Related guides

A sandbox is most useful when it is created from a verified backup and later used for safe migration, automation, and upgrade testing.