Your Custom Module Passed Tests Locally. Then It Broke Three Workflows in Production.
We've seen this pattern at every Odoo shop without CI/CD: a developer merges a custom module change, someone runs -u all on the server over SSH, and within 48 hours a ticket appears — the invoice PDF layout is wrong, a computed field returns null, or a scheduled action silently stopped running. Nobody knows which commit caused it because there's no automated gate between "code pushed" and "code running in production."
Odoo 19 makes this worse. The new privilege system, the OWL 3 frontend rewrite, and stricter ORM validation mean a module that worked fine on Odoo 18 can fail in new, non-obvious ways — ways that only surface under real data volumes or specific user role combinations. Manual testing can't catch these regressions consistently.
This guide shows you how to build a complete CI/CD pipeline for Odoo 19 using GitHub Actions — from running your test suite against a real PostgreSQL database on every push, to deploying to staging and production with zero-downtime rolling restarts. Every workflow file is production-tested across our client portfolio.
How to Structure a GitHub Actions CI/CD Pipeline for Odoo 19 Custom Modules
Before jumping into YAML, let's establish the pipeline architecture. A mature Odoo CI/CD pipeline has three stages, each with a clear gate:
| Stage | Trigger | What It Does | Gate |
|---|---|---|---|
| Lint & Static | Every push / PR | Flake8, pylint-odoo, ESLint for OWL components | Must pass for PR merge |
| Test | Every push / PR | Install modules on fresh DB, run --test-enable with real PostgreSQL | Must pass for PR merge |
| Deploy | Merge to main | SSH into server, pull code, run -u on changed modules, restart workers | Health check after restart |
The key principle: nothing reaches production without passing the test stage against a real Odoo instance with a real PostgreSQL database. SQLite mocks and unit tests without the ORM are insufficient — Odoo's business logic is deeply coupled to the database, and the only reliable test is one that exercises the full stack.
Repository Structure
Your custom addons repo should follow this layout. The CI pipeline expects it:
odoo-custom-addons/
├── .github/
│ └── workflows/
│ ├── ci.yml # Lint + Test (runs on push/PR)
│ └── deploy.yml # Deploy (runs on merge to main)
├── requirements.txt # Extra Python deps for custom modules
├── my_module_a/
│ ├── __manifest__.py
│ ├── models/
│ ├── views/
│ └── tests/
│ ├── __init__.py
│ └── test_my_module_a.py
├── my_module_b/
│ └── ...
└── scripts/
├── run_tests.sh # Test runner wrapper
└── deploy.sh # Deployment script Some teams commit Odoo's full source code alongside their custom modules. This bloats your repo to 2GB+, makes git clone painfully slow in CI, and creates merge conflicts on every Odoo patch. Instead, install Odoo from pip or clone it as a shallow dependency in CI. Your repo should only contain code you wrote.
GitHub Actions Workflow for Automated Odoo 19 Module Testing
This is the core CI workflow. It spins up PostgreSQL as a service container, installs Odoo 19 from source, installs your custom modules, and runs the test suite. Every PR gets this treatment.
name: Odoo 19 CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
ODOO_VERSION: "19.0"
PYTHON_VERSION: "3.12"
jobs:
lint:
name: Lint & Static Analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install linters
run: pip install flake8 pylint-odoo
- name: Flake8
run: |
flake8 . --max-line-length=120 \
--exclude=__manifest__.py \
--ignore=E501,W503,W504
- name: Pylint-Odoo
run: |
pylint --load-plugins=pylint_odoo \
--disable=all \
--enable=odoolint \
--valid-odoo-versions=${{ env.ODOO_VERSION }} \
$(find . -name '*.py' -not -path './.git/*' \
-not -path '*/migrations/*')
test:
name: Test with Odoo ${{ matrix.odoo-version }}
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
odoo-version: ["19.0"]
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: odoo_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: System dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends \
libxml2-dev libxslt1-dev libldap2-dev \
libsasl2-dev libpq-dev libjpeg-dev
- name: Cache Odoo source
uses: actions/cache@v4
id: cache-odoo
with:
path: /tmp/odoo
key: odoo-${{ matrix.odoo-version }}-${{ hashFiles('.github/workflows/ci.yml') }}
- name: Clone Odoo
if: steps.cache-odoo.outputs.cache-hit != 'true'
run: |
git clone --depth 1 \
--branch ${{ matrix.odoo-version }} \
https://github.com/odoo/odoo.git /tmp/odoo
- name: Install Odoo + custom deps
run: |
pip install -r /tmp/odoo/requirements.txt
if [ -f requirements.txt ]; then
pip install -r requirements.txt
fi
- name: Detect changed modules
id: modules
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="HEAD~1"
fi
MODULES=$(git diff --name-only "$BASE" HEAD \
| grep -oP '^[^/]+(?=/)' \
| sort -u \
| while read dir; do
[ -f "$dir/__manifest__.py" ] && echo "$dir"
done \
| paste -sd, -)
echo "modules=${MODULES:-all}" >> "$GITHUB_OUTPUT"
echo "Testing modules: ${MODULES:-all}"
- name: Initialize database
run: |
python /tmp/odoo/odoo-bin \
--db_host=localhost --db_port=5432 \
--db_user=odoo --db_password=odoo \
-d odoo_test \
--addons-path=/tmp/odoo/addons,${{ github.workspace }} \
-i ${{ steps.modules.outputs.modules }} \
--stop-after-init \
--log-level=warn
env:
PGPASSWORD: odoo
- name: Run tests
run: |
python /tmp/odoo/odoo-bin \
--db_host=localhost --db_port=5432 \
--db_user=odoo --db_password=odoo \
-d odoo_test \
--addons-path=/tmp/odoo/addons,${{ github.workspace }} \
-u ${{ steps.modules.outputs.modules }} \
--test-enable \
--stop-after-init \
--log-level=test \
2>&1 | tee /tmp/test_output.log
# Fail if any test errors found
if grep -q "ERROR.*odoo.modules" /tmp/test_output.log; then
echo "::error::Odoo tests failed!"
grep "ERROR" /tmp/test_output.log
exit 1
fi
if grep -q "FAIL:" /tmp/test_output.log; then
echo "::error::Test failures detected!"
grep "FAIL:" /tmp/test_output.log
exit 1
fi
echo "All tests passed."Key design decisions in this workflow:
- Real PostgreSQL service container — not SQLite, not mocked. Odoo's ORM generates PostgreSQL-specific SQL; testing against anything else is lying to yourself.
- Odoo source cached between runs — the first run clones Odoo (~2 min), subsequent runs restore from cache (~5 sec). This cuts CI time by 60%.
- Changed-module detection — on PRs, only the modules touched by the diff are installed and tested. A 15-module repo doesn't need to re-test all 15 on every push.
- Log parsing for failure detection — Odoo's test runner doesn't exit with code 1 on test failure. It logs
ERRORand exits 0. Thegrepstep catches this and fails the workflow correctly. - Lint as a separate job — runs in parallel with setup, fails fast on style issues without waiting for the full test suite.
This is the #1 surprise for teams setting up Odoo CI for the first time. odoo-bin --test-enable always exits with code 0, even when tests fail. It writes failures to the log and moves on. Without the log-parsing step, your CI will show green on every run regardless of test results. The grep for ERROR.*odoo.modules and FAIL: patterns is how you make Odoo CI actually work as a gate.
Zero-Downtime Odoo 19 Deployment via GitHub Actions and SSH
Once tests pass on main, the deployment workflow connects to your server via SSH, pulls the latest code, updates changed modules, and restarts Odoo workers — all without dropping active user sessions.
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch: # Manual trigger for hotfixes
concurrency:
group: production-deploy
cancel-in-progress: false # Never cancel an in-progress deploy
env:
SERVER_USER: odoo
DEPLOY_PATH: /opt/odoo/custom-addons
jobs:
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
# Only deploy if CI passed
needs: [] # Add 'test' job if in same file
environment: production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Need parent commit for diff
- name: Detect changed modules
id: modules
run: |
MODULES=$(git diff --name-only HEAD~1 HEAD \
| grep -oP '^[^/]+(?=/)' \
| sort -u \
| while read dir; do
[ -f "$dir/__manifest__.py" ] && echo "$dir"
done \
| paste -sd, -)
echo "modules=${MODULES}" >> "$GITHUB_OUTPUT"
echo "Deploying modules: ${MODULES:-none}"
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script_stop: true
script: |
set -euo pipefail
echo "=== Pulling latest code ==="
cd ${{ env.DEPLOY_PATH }}
git fetch origin main
git reset --hard origin/main
MODULES="${{ steps.modules.outputs.modules }}"
if [ -n "$MODULES" ]; then
echo "=== Installing new Python deps ==="
if [ -f requirements.txt ]; then
/opt/odoo/venv/bin/pip install -q \
-r requirements.txt
fi
echo "=== Updating modules: $MODULES ==="
sudo systemctl stop odoo
/opt/odoo/venv/bin/python \
/opt/odoo/odoo/odoo-bin \
-c /etc/odoo/odoo.conf \
-u "$MODULES" \
--stop-after-init \
--log-level=warn
echo "=== Restarting Odoo ==="
sudo systemctl start odoo
else
echo "No module changes, skipping update."
sudo systemctl reload odoo
fi
# Health check — wait for Odoo to respond
echo "=== Health check ==="
for i in $(seq 1 30); do
if curl -sf -o /dev/null \
http://127.0.0.1:8069/web/login; then
echo "Odoo is healthy after $i seconds."
exit 0
fi
sleep 1
done
echo "::error::Health check failed after 30s!"
exit 1Design choices that matter:
concurrencywithcancel-in-progress: false— if two merges happen in quick succession, the second deploy waits instead of cancelling the first mid-update. Cancelling a-umid-run can leave module states inconsistent.environment: production— enables GitHub's environment protection rules. You can require manual approval, restrict to specific branches, or add a deployment delay.- Health check loop — Odoo workers take 5–15 seconds to fully initialize. The loop retries for 30 seconds before failing. If it fails, the GitHub Actions run shows red and you get a notification.
workflow_dispatch— allows manual trigger for emergency hotfixes that bypass the normal PR flow.git reset --hardon the server — the server copy is always an exact mirror ofmain. No local edits, no drift. If someone SSH'd in and made a "quick fix" directly on the server, this reset catches it.
Multi-Environment Strategy
For teams with staging and production, extend the pipeline with branch-based triggers:
| Branch | Target | Auto-Deploy | Approval Required |
|---|---|---|---|
develop | Staging server | Yes, on every push | No |
main | Production server | Yes, after merge | Yes (via GitHub Environment) |
hotfix/* | Production (manual) | No — workflow_dispatch | Yes |
Staging should run against a recent anonymized copy of production data, not demo data. Demo data exercises 1% of your business logic. A staging database with 50,000 real (anonymized) sale orders catches the performance regressions and edge cases that demo data never will. We automate this with a weekly pg_dump → anonymize → restore cron job.
3 CI/CD Mistakes That Silently Ship Broken Odoo Modules to Production
Testing on an Empty Database Instead of a Populated One
Most Odoo CI guides install modules on a fresh database and run tests. This works for unit tests but misses a critical class of bugs: upgrade migration failures. When you run -u my_module on a database with 6 months of data, the pre_init_hook, post_init_hook, and ORM migration logic encounter column type changes, renamed fields, and data constraints that don't exist on an empty install. Your CI says green; production says psycopg2.errors.NotNullViolation.
We maintain a second CI job that runs -u against a nightly snapshot of the staging database. This catches migration bugs before they reach production. The snapshot is stored as a compressed artifact in a private S3 bucket and restored at CI time.
Trusting Odoo's Exit Code (It Always Returns 0)
We covered this above, but it bears repeating because it's the single most common CI bug we see. Teams set up a beautiful GitHub Actions pipeline, Odoo tests fail, the log shows FAIL: test_invoice_creation, and the pipeline shows a green checkmark. The team merges with confidence. Production breaks. The post-mortem reveals the CI was never actually gating on test results.
The tee + grep pattern in our CI workflow is non-negotiable. We also add a summary annotation that posts the failure count directly to the PR check: echo "::error::$FAIL_COUNT test(s) failed". Developers see the failure count without opening the full log.
No Rollback Plan When Deployment Fails
The deploy workflow runs -u my_module, which modifies database schema. If the update succeeds but the health check fails (OOM, misconfigured cron, etc.), you can't just git revert and re-deploy — the database schema has already been migrated forward. A code rollback with a forward-migrated database is a recipe for KeyError and ColumnDoesNotExist exceptions everywhere.
We add a pre-deploy database snapshot step: pg_dump the production database before running -u. If the health check fails, the rollback procedure restores the snapshot and reverts the code. The snapshot adds 2–5 minutes to deploy time but saves hours of emergency recovery. For Docker deployments, we tag the current image before pulling the new one — rollback is just docker compose up -d with the previous tag.
What CI/CD Saves Your Odoo Operation
CI/CD isn't a developer luxury — it's an operational insurance policy. Here's what changes when you stop deploying over SSH:
Automated tests catch regressions before they reach users. The bugs that do get through are edge cases, not obvious failures.
From merge to live in minutes. No SSH, no manual commands, no "let me check which modules changed."
Every deployment is a GitHub Actions run: who triggered it, what changed, which tests passed, and whether the health check succeeded. SOC 2 auditors love this.
The hidden ROI is developer confidence. When your team knows that every push is tested against a real Odoo instance with a real database, they ship faster. The fear of "will this break production?" is replaced by "CI will tell me if it's broken before it reaches anyone." That psychological safety translates directly to velocity.
Optimization Metadata
Build a full CI/CD pipeline for Odoo 19 with GitHub Actions. Automated testing against real PostgreSQL, zero-downtime deploys, and rollback strategy.
1. "How to Structure a GitHub Actions CI/CD Pipeline for Odoo 19 Custom Modules"
2. "GitHub Actions Workflow for Automated Odoo 19 Module Testing"
3. "3 CI/CD Mistakes That Silently Ship Broken Odoo Modules to Production"