Automated zero-downtime deployment for Odoo 18 using GitHub Actions, Docker Compose primary/standby strategy, and Nginx traffic switching via symlink reload.
Every push to the main branch triggers the pipeline:
- Detects which custom Odoo modules changed
- Deploys the new version to the standby container (not yet receiving traffic)
- Runs health checks against the standby container β max 90 seconds
- If healthy: switches Nginx to standby (graceful reload, zero dropped connections)
- Stops the previously active container
- Upgrades only the changed modules via XML-RPC
- Sends an HTML email notification (success or failure)
If the standby container fails health checks, the pipeline aborts β the active container keeps serving traffic with no user impact.
Manual rollback is available via workflow_dispatch β action: rollback.
Developer
β git push β main
βΌ
GitHub Actions
β
ββ detect-changes (git diff β find __manifest__.py, depth-independent)
β
ββ deploy
β ββ SSH: git pull addons on server
β ββ SSH: docker compose up <next-slot>
β ββ SSH: health check (max 94s)
β ββ SSH: switch Nginx symlink + reload
β ββ SSH: docker compose stop <previous-slot>
β ββ XML-RPC: upgrade changed modules (with retry + verify)
β
ββ rollback (workflow_dispatch only β switches Nginx back to previous slot)
β
ββ notify (HTML email β recipients, always runs)
βββββββββββββββββββββββββββββββββββββββ
β Server β
β β
β ββββββββββββββββββββββββββββββββ β
User β HTTPS:443 βββββΆ Nginx :443 β β
β ββββββββββββββββ¬ββββββββββββββββ β
β β symlink β
β βββββββββΌββββββββ β
β β ACTIVE β β
β β primary/stby β β
β βββββββββββββββββ β
β β
β βββββββββββββββββ β
β β STANDBY β β
β β (stopped) β β
β βββββββββββββββββ β
β β
β ββββββββββββββ ββββββββββββββββ β
β β PostgreSQL β β Redis β β
β β (shared) β β (shared) β β
β ββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββ
Each component has its own detailed README:
| Folder | README | Covers |
|---|---|---|
docker/ |
docker/README.md | Docker Compose setup, primary/standby concept, volumes, network, environment variables, commands |
nginx/ |
nginx/README.md | Nginx config features, traffic switching, SSL, setup.sh, switch.sh, shutdown.sh |
github/ |
github/README.md | GitHub Actions workflow, secrets, module detection, force upgrade, rollback, email notification |
Server (Ubuntu 22.04+):
# Update system
sudo apt-get update && sudo apt-get upgrade -y
# Docker Engine
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
# Docker Compose v2 plugin
sudo apt-get install -y docker-compose-plugin
# Nginx
sudo apt-get install -y nginx
# Certbot (Let's Encrypt)
sudo apt-get install -y certbot python3-certbot-nginx
# Tools
sudo apt-get install -y git curl
# Verify versions
docker --version
docker compose version
nginx -vGitHub:
- Repository with this project (or at minimum
github/deploy.ymlin.github/workflows/) - GitHub Actions enabled
- Secrets configured (see Step 7)
Run the commands in Prerequisites above on a fresh Ubuntu 22.04+ server.
sudo mkdir -p /opt/zerodowntime
sudo chown $USER:$USER /opt/zerodowntime
git clone https://github.com/your-org/docker-zero-downtime /opt/zerodowntime
cd /opt/zerodowntimeThese directories are git-ignored (runtime data):
cd /opt/zerodowntime/docker
# Odoo runtime data
mkdir -p etc/addons etc/filestore etc/sessions etc/logs
# PostgreSQL data
mkdir -p odoo-postgres
# Odoo core addons (download or copy separately)
mkdir -p addons/default
# Custom addons β clone your module repo here
# git clone https://github.com/your-org/your-addons addons/customcd /opt/zerodowntime/docker
cp .env.example .env
nano .envMinimum required changes:
POSTGRES_PASSWORD=your_strong_password
POSTGRES_DB=odoo
ODOO_SESSION_REDIS_PASSWORD=your_redis_password
ODOO_SESSION_REDIS_URL=redis://:your_redis_password@redis:6379/0Then edit etc/conf/odoo.conf β update at minimum:
admin_passwd = your_strong_master_password
db_name = odoo
db_namemust matchPOSTGRES_DBin.env.
cd /opt/zerodowntime/docker
# Start infra + primary Odoo
docker compose up -d
# Wait ~30s then check
docker compose psExpected output:
NAME STATUS
odoo18-zerodowntime-postgres Up (healthy)
odoo18-zerodowntime-redis Up (healthy)
odoo18-zerodowntime-primary Up (healthy)
Test locally:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8018/web/health
# Expected: 200Make sure your domain (erp.zerodowntime.com) already points to this server's IP via DNS before running this.
cd /opt/zerodowntime
sudo bash nginx/setup.shsetup.sh does everything in one run:
- Checks that
nginxandcertbotare installed - Obtains a Let's Encrypt certificate via
certbot --standalone - Installs
odoo18-compose-primaryandodoo18-compose-standbyto/etc/nginx/sites-available/ - Activates primary as default via symlink
- Creates
/var/www/certbotfor future webroot renewals - Validates config (
nginx -t) and starts Nginx - Writes
/etc/sudoers.d/odoo-nginxso the deploy user can reload Nginx without a password
Verify after setup:
# Check active symlink
readlink /etc/nginx/sites-enabled/odoo18-zerodowntime
# Expected: /etc/nginx/sites-available/odoo18-compose-primary
# Test HTTPS
curl -s -o /dev/null -w "%{http_code}" https://erp.zerodowntime.com/web/health
# Expected: 200Go to: Repository β Settings β Secrets and variables β Actions β New repository secret
Server Access:
| Secret | Value |
|---|---|
SSH_PRIVATE_KEY |
OpenSSH private key β cat ~/.ssh/id_ed25519 |
SSH_HOST |
Server IP or hostname |
SSH_USER |
SSH username (e.g. ubuntu) |
Paths on Server:
| Secret | Value |
|---|---|
ODOO_ADDONS_PATH |
/opt/zerodowntime/docker/addons/custom |
COMPOSE_PROJECT_PATH |
/opt/zerodowntime |
NGINX_SWITCH_SCRIPT_PATH |
/opt/zerodowntime/nginx/switch.sh |
Odoo Connection:
| Secret | Value |
|---|---|
ODOO_URL |
https://erp.zerodowntime.com |
ODOO_DB |
odoo |
ODOO_ADMIN_USER |
admin |
ODOO_ADMIN_PASSWORD |
Odoo admin password |
Email Notification:
| Secret | Value |
|---|---|
EMAIL_RECIPIENTS |
dev@zerodowntime.com,ops@zerodowntime.com |
SMTP_SERVER |
smtp.gmail.com |
SMTP_PORT |
587 |
SMTP_USER |
noreply@zerodowntime.com |
SMTP_PASSWORD |
Gmail App Password |
EMAIL_FROM |
Odoo CI/CD <noreply@zerodowntime.com> |
Also place the workflow file on GitHub:
# In your repo, copy the workflow to the correct location
mkdir -p .github/workflows
cp github/deploy.yml .github/workflows/deploy.yml
git add .github/workflows/deploy.yml
git commit -m "ci: add odoo zero-downtime deployment workflow"
git pushPush any change to main to trigger the pipeline automatically.
Or use manual trigger:
- GitHub β Actions β Odoo CI/CD - Auto Deploy & Upgrade β Run workflow
- Leave
force_modulesempty (auto-detect) or enter a module name - Leave
actionasdeploy - Click Run workflow
Watch the pipeline at GitHub β Actions.
After a successful first deployment, the standby slot becomes active:
# Check running containers
docker ps --filter name=odoo18-zerodowntime
# Check active Nginx config (should now point to standby after first deploy)
readlink /etc/nginx/sites-enabled/odoo18-zerodowntime
# Test public URL
curl -s -o /dev/null -w "%{http_code}" https://erp.zerodowntime.com/web/health
# Expected: 200
# Check Nginx access log
sudo tail -20 /var/log/nginx/odoo-standby.access.log
# Check Odoo logs
docker logs --tail 50 odoo18-zerodowntime-standby| Service | Host Port | Notes |
|---|---|---|
| Odoo Primary (web) | 8018 |
Nginx β primary |
| Odoo Primary (longpoll) | 8027 |
Nginx β primary longpolling |
| Odoo Standby (web) | 8118 |
Nginx β standby |
| Odoo Standby (longpoll) | 8127 |
Nginx β standby longpolling |
| PostgreSQL | 5016 |
External DB tools access |
| Redis | 5030 |
External Redis tools access |
| HTTPS (public) | 443 |
All user traffic via Nginx |
| HTTP (public) | 80 |
Redirects to HTTPS |
Containers not starting
docker logs odoo18-zerodowntime-primary
docker logs odoo18-zerodowntime-postgres
docker logs odoo18-zerodowntime-redis
# Check env file
cat /opt/zerodowntime/docker/.envStandby container fails β network not found
The standby requires the network created by the primary compose. Always start primary first:
cd /opt/zerodowntime/docker
docker compose up -d
docker compose -f docker-compose.yml -f docker-compose.standby.yml up -d odoo-standbyNginx returns 502 Bad Gateway
The active container may not be running:
# Check active slot
readlink /etc/nginx/sites-enabled/odoo18-zerodowntime
# Check containers
docker ps --filter name=odoo18-zerodowntime
# Switch to the running slot
sudo bash /opt/zerodowntime/nginx/switch.sh primary
# or
sudo bash /opt/zerodowntime/nginx/switch.sh standbyHealth check fails during deploy
The pipeline aborts and keeps the active container running. Check the failed container:
docker logs odoo18-zerodowntime-standby
docker logs odoo18-zerodowntime-primaryManual rollback needed
Use the GitHub Actions manual trigger:
- Actions β Odoo CI/CD β Run workflow
- Set
actiontorollback - Click Run workflow
Or roll back manually on the server:
sudo bash /opt/zerodowntime/nginx/switch.sh primary
# or
sudo bash /opt/zerodowntime/nginx/switch.sh standbyGitHub Actions SSH fails
# Test from local machine
ssh -i your_key ubuntu@your_server_ip "echo OK"
# Key format must be OpenSSH
head -1 your_key
# Expected: -----BEGIN OPENSSH PRIVATE KEY-----Module upgrade fails
Run the script manually from any machine with network access to Odoo:
python3 github/script/upgrade_modules.py \
--url https://erp.zerodowntime.com \
--db odoo \
--user admin \
--password your_password \
--modules your_module_nameEmail not received
- Check spam folder
- For Gmail: use App Password (not account password) β Google Account β Security β App Passwords
- Verify
SMTP_PORT:587for STARTTLS,465for SSL - Check workflow logs under the
notifyjob
Remove Nginx config (decommission)
sudo bash /opt/zerodowntime/nginx/shutdown.sh --yes
# With log cleanup:
sudo bash /opt/zerodowntime/nginx/shutdown.sh --yes --purge-logs