EXP Server Runbook¶
Host: exp.scottrecycling.com
Role: Testing Odoo + production Django/Flask/FastAPI app host
User: dan (sudo enabled)
Last verified: 2026-04-14
This runbook is the single-page reference for operating the EXP server. It documents every service that matters, how to restart each one, where its config lives, common failure modes, and recovery steps. Keep this file current. When a new service is added, add its row to the tables below.
Critical reminder: EXP is a test server for Odoo. The sr_live_jan19_backup database and all other Odoo databases on EXP are neutralized clones — crons disabled, mail servers disabled, outgoing queues cancelled, companies marked [TEST CLONE]. Do not treat EXP Odoo as production. Real customer-facing production Odoo is on a separate host.
However, EXP does host real production Django/Flask/FastAPI apps (/assistant, /financial, /health, /life, /plantbiz, /deals, /tutor). These must stay up.
Service Inventory¶
Odoo instances (all TEST CLONES — neutralized)¶
| Service | Port | DB | Config | Path served | Service user |
|---|---|---|---|---|---|
odoo.service |
1819 | sr_live_jan19_backup |
/etc/odoo/odoo.conf |
/ and /stg/ |
odoo |
odoo-superstore.service |
1820 | scott_superstore |
/etc/odoo/odoo-superstore.conf |
/superstore/ |
odoo |
odoo-training.service |
1821 | sr_training |
/etc/odoo/odoo-training.conf |
training.scottrecycling.com |
odoo |
Production Django / Flask / FastAPI apps¶
| Service | Port | DB | Directory | Path served | Framework |
|---|---|---|---|---|---|
personal-assistant.service |
8085 | personal_assistant (pg) |
/opt/personal-assistant |
/assistant/ |
Django + Gunicorn |
scott-financial.service |
5000 | scott_financial (pg) |
/opt/scott-financial |
/financial + financial.scottrecycling.com |
Flask + Gunicorn |
health-erp.service |
8080 | health_erp (pg) |
/opt/health_erp |
/health/ |
Django + Gunicorn |
life-erp.service |
8081 | life_erp (pg) |
/opt/life_erp |
/life/ |
Django + Gunicorn |
plantbiz.service |
5010 | plantbiz (pg) |
/opt/plantbiz |
/plantbiz/ |
Flask + Gunicorn |
deal-hunter.service |
8090 | config file | /opt/deal-hunter |
/deals/ |
Flask + Gunicorn |
deploy_api_1 (Docker) |
8010 | containerized pg (deploy_db_1) |
/opt/personal-tutor |
/tutor |
FastAPI in Docker Compose |
Supporting infrastructure¶
| Service | Purpose | Config |
|---|---|---|
nginx |
Reverse proxy for all HTTP/HTTPS traffic | /etc/nginx/sites-enabled/exp |
postgresql (or postgresql@14-main) |
Database engine for all Odoo + production apps | /etc/postgresql/14/main/ |
cron (root + dan) |
Nightly backup, monitoring, offsite staging | crontab -l (per user) |
Static documentation sites (no service — nginx aliases)¶
| Path | Source directory |
|---|---|
/docs |
/var/www/docs/ (rsynced from ~/Desktop/Sr-docs/site/ via ./deploy.sh) |
/sss-docs |
/var/www/sss-docs/ |
/scott-docs |
/var/www/scott-docs/ |
/plastics-docs |
/var/www/plastics-docs/ |
Restart Procedures¶
Odoo (test clones)¶
Restarting Odoo is SAFE because the clones are neutralized — no crons, no mail, no external side effects. But it briefly interrupts any active user session on the test UI.
# Port 1819 — main test clone
sudo systemctl restart odoo.service
sleep 5
curl -s -o /dev/null -w "1819: %{http_code}\n" http://localhost:1819/web/login
# Port 1820 — Superstore
sudo systemctl restart odoo-superstore.service
sleep 5
curl -s -o /dev/null -w "1820: %{http_code}\n" http://localhost:1820/web/login
# Port 1821 — Training
sudo systemctl restart odoo-training.service
sleep 5
curl -s -o /dev/null -w "1821: %{http_code}\n" http://localhost:1821/web/login
Expected: all three return HTTP 200 after restart.
Production Django/Flask apps¶
Restart ONE at a time unless you have a specific reason to restart all. Every restart briefly interrupts users of that specific app.
# Personal Assistant
sudo systemctl restart personal-assistant.service
# Scott Financial
sudo systemctl restart scott-financial.service
# Health ERP
sudo systemctl restart health-erp.service
# Life ERP
sudo systemctl restart life-erp.service
# PlantBiz
sudo systemctl restart plantbiz.service
# Deal Hunter
sudo systemctl restart deal-hunter.service
Verify after each restart:
sudo systemctl status <service-name> --no-pager
curl -s -o /dev/null -w "%{http_code}\n" https://exp.scottrecycling.com/<path>/
Personal Tutor (Docker)¶
cd /opt/personal-tutor
sudo docker compose restart
sudo docker compose ps
curl -s -o /dev/null -w "%{http_code}\n" https://exp.scottrecycling.com/tutor
Nginx¶
Nginx restart affects everything going through the reverse proxy. Only restart when nginx config changes.
# Check config first — never restart with a bad config
sudo nginx -t
# If config is good:
sudo systemctl reload nginx # graceful, no dropped connections
# OR
sudo systemctl restart nginx # brief interruption
PostgreSQL¶
DO NOT restart PostgreSQL lightly. It interrupts every database-backed service on the box at once. Only restart for:
- Major PG version upgrades
- Config changes requiring restart (pg_hba.conf typically only needs reload, not restart)
- Emergency recovery
# Graceful reload (for pg_hba.conf changes, connection limits, etc.)
sudo systemctl reload postgresql
# Full restart (last resort)
sudo systemctl restart postgresql
If you restart PG, all 7+ services that depend on it will lose connections temporarily and may need a restart themselves.
Health Checking¶
Quick status check¶
This runs the same checks as the hourly cron and dumps the most recent alerts.
Detailed health check¶
Grep for alerts¶
grep ">>> ALERT:" /opt/monitoring/health.log | tail -20
grep ">>> CRITICAL:" /opt/monitoring/health.log | tail -20
Manual per-service checks¶
# All systemd services in one command
for svc in odoo odoo-superstore odoo-training personal-assistant scott-financial health-erp life-erp plantbiz deal-hunter nginx postgresql; do
printf "%-25s %s\n" "$svc" "$(systemctl is-active $svc 2>/dev/null || echo 'not-found')"
done
# All HTTP endpoints
for url in \
https://exp.scottrecycling.com/ \
https://exp.scottrecycling.com/superstore/ \
https://exp.scottrecycling.com/assistant/ \
https://exp.scottrecycling.com/financial \
https://financial.scottrecycling.com/ \
https://exp.scottrecycling.com/health/ \
https://exp.scottrecycling.com/life/ \
https://exp.scottrecycling.com/plantbiz/ \
https://exp.scottrecycling.com/deals/ \
https://exp.scottrecycling.com/tutor \
https://exp.scottrecycling.com/docs/ \
https://training.scottrecycling.com/; do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url")
printf "%-50s %s\n" "$url" "$code"
done
Deployment Procedures¶
Deploy sr-docs¶
From Dan's local machine:
The deploy.sh script builds with mkdocs build --strict, rsyncs site/ to /var/www/docs/ on EXP, and verifies HTTP 200. Takes ~10 seconds.
Verifies automatically — but manual sanity check:
curl -s -o /dev/null -w "%{http_code}\n" https://exp.scottrecycling.com/docs/
curl -s -o /dev/null -w "%{http_code}\n" https://exp.scottrecycling.com/docs/strategy/
Deploy SR-Odoo (test clone on EXP)¶
# On EXP:
cd /opt/odoo/custom_addons/Dan-Odoo
git pull origin jan
# For a MODULE upgrade (most common):
sudo systemctl stop odoo.service
sudo -u odoo bash -c 'cd /tmp && /opt/odoo/odoo17-venv/bin/python3 /opt/odoo/odoo17/odoo-bin \
-c /etc/odoo/odoo.conf -d sr_live_jan19_backup -u <module_name> \
--stop-after-init --logfile=/tmp/odoo_upgrade.log'
grep -iE "ERROR|CRITICAL|Traceback" /tmp/odoo_upgrade.log | tail -20
sudo systemctl start odoo.service
# For a FRESH INSTALL:
# Same as above but use -i <module_name> instead of -u
# Verify:
sleep 5
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:1819/web/login
IMPORTANT: EXP is a test clone. After any fresh clone refresh from production, re-run scripts/neutralize_clone.sql on the affected database to re-disable crons and cancel any stale outgoing mail. See the 2026-04-14 incident — 60,769 stale production mails were queued on an already-cloned DB.
Deploy a Django/Flask app¶
Each app has its own deployment procedure. General pattern:
# Example for scott-financial
cd /opt/scott-financial
sudo -u dan git pull
sudo -u dan /opt/scott-financial/venv/bin/pip install -r requirements.txt # if deps changed
sudo systemctl restart scott-financial.service
sleep 3
curl -s -o /dev/null -w "%{http_code}\n" https://financial.scottrecycling.com/
Same pattern applies to the other Django/Flask apps. Replace the directory and service name.
Backup & Restore¶
Nightly backups (automated)¶
- Who:
/opt/backups/odoo/backup.shruns at 02:00 via cron - What:
pg_dump sr_live_jan19_backup | gzip - Retention: 14 days rolling
- Location:
/opt/backups/odoo/sr_live_jan19_backup_YYYYMMDD_HHMM.sql.gz - Log:
/opt/backups/odoo/backup.log
Offsite staging (automated)¶
- Who:
/opt/backups/odoo-offsite-stage/stage.shruns Sundays at 04:00 via cron - What: rotation to weekly / monthly / yearly tiers with automatic pruning
- Retention: 8 weekly + 12 monthly + 5 yearly = 25 files × ~114 MB ≈ 2.9 GB total
- Status: local staging only — not sending offsite yet (pending Dan's cloud credentials)
- Docs:
/opt/backups/odoo-offsite-stage/README.md
Restore (manual — for disaster or testing)¶
# 1. Identify the backup to restore
ls -lht /opt/backups/odoo/*.sql.gz | head -5
# 2. Create a target database (never overwrite live without explicit intent)
sudo -u postgres createdb <target_db> -O odoo
# 3. Restore
time gunzip -c /opt/backups/odoo/<backup_file>.sql.gz | sudo -u postgres psql <target_db>
# 4. Verify
sudo -u postgres psql <target_db> -c "SELECT count(*) FROM customer_pickups;"
sudo -u postgres psql <target_db> -c "SELECT id, name FROM res_company;" # should say [TEST CLONE]
# 5. If restoring for a real recovery, re-run neutralize (in case the source is also a clone)
sudo -u postgres psql <target_db> < /opt/odoo/custom_addons/Dan-Odoo/scripts/neutralize_clone.sql
See /opt/backups/odoo/RESTORE_TEST.md for the quarterly restore-test procedure.
Common Failure Modes¶
"Odoo returns HTTP 500 or won't start"¶
- Check the service status:
sudo systemctl status odoo.service - Check the logs:
sudo journalctl -u odoo.service --since "10 minutes ago" --no-pager | tail -60 - Look for
ERRORorTracebacklines - Common causes:
- Module upgrade failed — rollback with
git reset --hard <prior-commit>in/opt/odoo/custom_addons/Dan-Odooand restart - Database connection refused —
sudo systemctl status postgresqlfirst - Port in use —
sudo ss -tlnp | grep :1819to see if something else is holding the port - Config file error —
sudo -u odoo /opt/odoo/odoo17-venv/bin/python3 /opt/odoo/odoo17/odoo-bin -c /etc/odoo/odoo.conf --stop-after-init --test-enableto validate
"Django app returns 502"¶
502 means nginx can't reach the upstream (the gunicorn process).
- Check the service:
sudo systemctl status <app>.service - If it's inactive: restart it and check logs
- If it's active but gunicorn is crashing:
sudo journalctl -u <app>.service --since "10 minutes ago" --no-pager | tail -40 - If the app won't start: check that its virtualenv is intact, dependencies are installed, database is reachable
"Nginx won't start"¶
sudo nginx -tto validate the config- If config error: fix the offending line in
/etc/nginx/sites-enabled/exp sudo systemctl restart nginx- If port 80 or 443 is in use:
sudo ss -tlnp | grep -E ':80|:443'
"Everything is down at once"¶
Probably PostgreSQL. Check:
sudo systemctl status postgresql
sudo systemctl status postgresql@14-main
sudo journalctl -u postgresql@14-main --since "10 minutes ago" --no-pager | tail -40
If PG is running but connections fail, check:
- /etc/postgresql/14/main/pg_hba.conf — authentication config
- Connection limit: sudo -u postgres psql -c "SHOW max_connections;"
If PG is down and won't start, check disk space: df -h /var/lib/postgresql/. A full disk is the most common cause.
"Disk is full"¶
Likely culprits (in order):
1. /var/log/journal — systemd journal (use sudo journalctl --vacuum-time=7d)
2. /var/log/nginx — nginx access logs (rotate or truncate)
3. /var/lib/postgresql/14/main/pg_log — PG query logs (rotate)
4. /opt/backups/ — backup retention too long
5. /tmp — installer logs, temp files
"SSL certificate expired"¶
LetsEncrypt auto-renews via cron. If it failed:
sudo certbot renew --dry-run # diagnose
sudo certbot renew # force renewal
sudo systemctl reload nginx # pick up the new cert
Check renewal logs: sudo tail -40 /var/log/letsencrypt/letsencrypt.log
"Monitoring alerts aren't firing"¶
- Check the hourly cron is running:
crontab -l | grep health-check - Check the log:
tail -100 /opt/monitoring/health.log - Check for stale timestamps:
ls -la /opt/monitoring/health.log - Check the email sink config:
cat /opt/monitoring/alert.cfg(if SMTP configured)
Emergency Contacts¶
(Fill in with real data — see ~/Desktop/sr-drafts/succession-plan.md for the full list)
| Role | Who | Phone | When to call |
|---|---|---|---|
| Owner / primary | Dan | [[cell]] |
Any emergency |
| Backup coordinator | [[name]] |
[[cell]] |
Dan unreachable |
| IT / systems | Dan | [[cell]] |
EXP server issues |
| Hosting provider | [[if any]] |
[[phone]] |
Physical hardware, network, datacenter issues |
| DNS registrar | [[provider]] |
[[phone]] |
Domain / DNS issues |
| Cloudflare or CDN | [[if used]] |
[[phone]] |
CDN or DDoS issues |
Related Documents¶
- Developer Architecture — module dependencies
- Developer Changelog — recent code changes
- Developer Deployment — module-specific deploy procedures
- Developer Debugging — troubleshooting Odoo
- Compliance / Safety — OSHA and workplace safety (EXP physical security is separate)
~/Desktop/sr-drafts/succession-plan.md— key person backup plan (private)~/Desktop/sr-drafts/facility-security-template.md— physical security (not server)
Update History¶
| Date | Change | By |
|---|---|---|
| 2026-04-14 | Initial runbook created. Documents 3 Odoo + 7 Django/Flask + 4 static doc sites + supporting infrastructure. | Dan (via master plan infrastructure hardening) |