Managed Odoo is comfortable until you outgrow it. Self-host on a plain VPS — native service, no Docker — and every change means SSHing in, pulling, and restarting by hand. That ritual is exactly what a pipeline should own. Here is the full build, including the four things that broke along the way.
The deploy pipeline · end to end
the script asks: which modules changed in this pull?
Restart only. Templates, JS, assets — a plain systemctl restart reloads them. Fast, zero downtime.
Upgrade just those. New models, fields or views — run -u module_a,module_b so the DB schema catches up.
Untouched modules are never rebuilt. The diff decides the work.
What the server already needs
This assumes a working Odoo 17 install running as a systemd service, with the code repo checked out in the addons path. Two things must be true first:
- The repo is cloned on the VPS and pulls without a password prompt — a read-only SSH deploy key registered with the git host.
- Odoo runs from a virtualenv (it almost always does). Remember where that
pythonlives — it matters later.
git pull) and CI runner → VPS (for the deploy). Different keys, different jobs.Let GitHub log in to the box
Generate a dedicated key pair for CI. The public half goes in the VPS user's authorized_keys; the private half becomes a repo secret. Nothing reusable, easy to revoke.
# dedicated CI key — no passphrase so it runs unattended ssh-keygen -t ed25519 -f ci_deploy_key -N ""
Then add three repository secrets under Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
| SSH_PRIVATE_KEY | full contents of ci_deploy_key |
| VPS_HOST | the server's public IPv4 |
| VPS_USER | the login user |
VPS_HOST, not a hostname. A stray AAAA record sends the runner down a broken IPv6 path and you get random i/o timeout failures that look like everything else.One command, no password
The deploy needs root to restart the service — but sudo normally prompts, and there's no human at the keyboard. The fix isn't blanket sudo; it's a single whitelisted script:
echo "DEPLOY_USER ALL=(ALL) NOPASSWD: /usr/local/bin/odoo-deploy.sh" \ | sudo tee /etc/sudoers.d/odoo-deploy sudo chmod 440 /etc/sudoers.d/odoo-deploy
If the deploy key leaks, the blast radius is one script — not the whole machine. The chmod 440 matters too: sudo silently refuses to load a sudoers file that's group- or world-writable.
A deploy script that reads the diff
This is where "smart" lives. A restart reloads Python and templates, but it does not apply new models, fields, or views to the database — that needs a module upgrade, which is slower and briefly stops the service. Running a full upgrade on every push, across dozens of modules, would be wasteful and risky.
So the script diffs HEAD before and after the pull, maps changed files to their top-level folders, keeps only the ones that are real modules (they have a __manifest__.py), and upgrades exactly that set. No module changes? It just restarts.
#!/bin/bash set -e REPO_DIR="/opt/odoo/custom_addons" BRANCH="17.0" VENV_PYTHON="/opt/odoo/venv/bin/python3" # the venv, not system python ODOO_BIN="/opt/odoo/odoo/odoo-bin" ODOO_CONF="/etc/odoo/odoo.conf" DB_NAME="prod_db" ODOO_USER="odoo" cd "$REPO_DIR" BEFORE=$(git rev-parse HEAD) git pull origin "$BRANCH" AFTER=$(git rev-parse HEAD) # top-level folders touched by this pull CHANGED=$(git diff --name-only "$BEFORE" "$AFTER" | cut -d/ -f1 | sort -u) # keep only folders that are real odoo modules MODULES="" for d in $CHANGED; do if [ -f "$REPO_DIR/$d/__manifest__.py" ]; then MODULES="$MODULES,$d" fi done MODULES=${MODULES#,} # strip leading comma if [ -z "$MODULES" ]; then echo "No module changes. Restart only." systemctl restart odoo else echo "Upgrading modules: $MODULES" systemctl stop odoo sudo -u "$ODOO_USER" "$VENV_PYTHON" "$ODOO_BIN" \ -c "$ODOO_CONF" -d "$DB_NAME" -u "$MODULES" --stop-after-init systemctl start odoo fi systemctl status odoo --no-pager | head -5
Push a change to one module and the log says it plainly:
Updating a1b2c3d..e4f5a6b Fast-forward sale_custom/views/sale_order_views.xml | 1 + Upgrading modules: sale_custom ● odoo.service - Odoo Open Source ERP and CRM Active: active (running) since Mon 2026-01-01 09:00:00 UTC
The workflow is almost nothing
With the brains on the server, the GitHub side collapses to a dozen lines: SSH in, run the one script. That's the whole job.
name: Deploy custom_addons on: push: branches: [17.0] jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy to VPS uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.VPS_HOST }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} timeout: 60s command_timeout: 15m script: sudo /usr/local/bin/odoo-deploy.sh
Commit it, push to 17.0, and watch the Actions tab. Green means the server already has your change.
Four things that broke first
The happy path above is the destination. Getting there tripped four wires — each an error message that looked scarier than its one-line fix.
Running as user 'root' is a security risk. It's a warning, not a failure — the upgrade still completes. Silence it later by giving Odoo its own system user.What every push now does
| The push touches… | The pipeline… | Result |
|---|---|---|
| Docs, workflow, README | detects no modules | restart only |
| One module's code | -u that_module | upgrade 1 |
| Several modules | -u a,b,c together | upgrade set |
| Only templates / JS / SCSS | reloads on restart | restart only |
Pull, decide, act, verify — unattended, in seconds, touching only what changed.
The one upgrade worth doing next
The recurring i/o timeout is provider network flakiness you can't fix from inside the box. A self-hosted runner ends it for good: the runner lives on the VPS and dials out to GitHub over HTTPS, so there's no inbound SSH to drop. The workflow shrinks even further:
jobs: deploy: runs-on: self-hosted steps: - run: sudo /usr/local/bin/odoo-deploy.sh
No secrets, no network round-trip, no timeout. For a single-server setup, it's the natural finish line.