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
Read this before creating a sandbox
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
PlanningA 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
PlanningThe 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 itHow 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_sandbox3. Confirm production is healthy before copying anything
Safe checkBefore 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 -5How 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 requiredThe 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-sandboxHow 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 separation5. Copy production deployment files into the sandbox folder
Change requiredThe 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 reviewed6. Edit Docker Compose for sandbox isolation
Change requiredThe 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.ymlHow 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 requiredThe 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 requiredStart 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 psHow 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 requiredThis 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 requiredThe 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
CautionA 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 requiredAfter 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.conf13. Expose the sandbox safely if needed
CautionA 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/managerHow 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 needed14. Verify the restored sandbox
Safe checkThe 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
PlanningA 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 exposureHow 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.