Blog
DevOps

Push to Deploy: CI/CD for Native Odoo on a VPS

How a single git push pulls, rebuilds and restarts a self-hosted Odoo server — upgrading only the modules that actually changed.

Pushpendra · 08/30/2026 · 7 min read

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

Commitgit push 17.0
Actionsworkflow fires
SSH inrunner → vps
Deploy scriptdetect diff
Odoo liveactive (running)

the script asks: which modules changed in this pull?

no modules

Restart only. Templates, JS, assets — a plain systemctl restart reloads them. Fast, zero downtime.

module changed

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.

1 Prerequisites

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 python lives — it matters later.
Two SSH links, don't confuse them: VPS → git host (for git pull) and CI runner → VPS (for the deploy). Different keys, different jobs.
2 The runner reaches in

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.

local machinebash
# 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:

SecretValue
SSH_PRIVATE_KEYfull contents of ci_deploy_key
VPS_HOSTthe server's public IPv4
VPS_USERthe login user
Prefer a raw IPv4 in 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.
3 Least privilege

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:

on the vpsbash
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.

4 The brain

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.

/usr/local/bin/odoo-deploy.shbash
#!/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:

github actions · deploy logoutput
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
5 The trigger

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.

.github/workflows/deploy.ymlyaml
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.


Field notes

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.

01

The dreaded i/o timeout

dial tcp ***:22: i/o timeout

The connection never even opened — a network drop, not an auth failure. The firewall was open and fail2ban wasn't even installed; the culprit was the VPS provider's own network layer silently dropping packets from some of GitHub's rotating runner IPs.

Fix: it's intermittent — re-run the job. For a permanent cure, move to a self-hosted runner (below) so there's no inbound SSH to drop.

02

Wrong path, right lesson

cd: /path/to/... : No such file or directory

A placeholder left in the script — harmless, but useful: it proved the SSH login worked. The command ran, only the path was wrong. When a step fails, read where it failed before assuming the connection is broken.

Fix: point at the real checkout path on the server.

03

No module named 'psycopg2'

ModuleNotFoundError: No module named 'psycopg2'

The upgrade called odoo-bin directly, so its shebang picked system Python — which has none of Odoo's dependencies. The systemd service had been quietly using the virtualenv all along.

Fix: invoke the venv's python explicitly and pass odoo-bin as its argument — whatever ExecStart uses is the python that works.

04

The same error, twice

"$ODOO_BIN" "$ODOO_BIN" — a copy-paste typo

After adding the venv variable, the fix didn't take — because the command still read $ODOO_BIN $ODOO_BIN, never $VENV_PYTHON. When an identical error survives a fix, don't re-theorize: cat the file and check what's actually running.

Fix: "$VENV_PYTHON" "$ODOO_BIN", in that order. Done.

One benign leftover: Odoo logs 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.

The payoff

What every push now does

The push touches…The pipeline…Result
Docs, workflow, READMEdetects no modulesrestart only
One module's code-u that_moduleupgrade 1
Several modules-u a,b,c togetherupgrade set
Only templates / JS / SCSSreloads on restartrestart 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:

deploy.yml · self-hosted variantyaml
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.