Deployment and CI/CD
Odoo CI/CD with GitHub, from branch strategy to production deploys
How to build Odoo CI/CD with GitHub Actions, with branch strategy, Odoo tests in CI, linting, deploys with module upgrades, migrations and rollbacks.
CICDoo Engineering Updated 9 min read
The short answer
Odoo CI/CD means one long-lived branch per environment (development, staging, production), a CI job that installs your modules on a fresh PostgreSQL database and runs odoo-bin with --test-enable and --stop-after-init, and a deploy step that pulls the code, upgrades the changed modules with -u and restarts Odoo. Take a database backup before every production upgrade, because code rolls back with git but the database does not.
On this page
- What Odoo CI/CD actually involves
- A branch strategy that maps to environments
- Repository layout
- Running Odoo tests in CI
- A sample GitHub Actions workflow
- Linting with pre-commit and pylint-odoo
- Deploying: pull, upgrade, restart
- Migrations for data and schema changes
- Rollbacks
- Staging with a neutralised production copy
- Pipeline checklist
- Doing this with CICDoo
What Odoo CI/CD actually involves
Continuous integration for Odoo means every push installs your custom modules on an empty database and runs their tests, so a broken manifest, a missing dependency or a failing test is caught before anyone merges. Continuous delivery means a merge into an environment branch updates the server running that branch: pull the new code, upgrade the modules that changed, restart the service.
Odoo is not a stateless web app, and that shapes everything below. A deploy changes two things at once: Python and XML code on disk, and the database schema and data that the module upgrade (-u) rewrites. The code part is easy to reverse. The database part is not. A good Odoo pipeline is built around that asymmetry.
This guide uses GitHub and GitHub Actions, but the same structure works on GitLab CI or any runner that can start a PostgreSQL container. Examples target Odoo 17, 18 and 19.
A branch strategy that maps to environments
The pattern that works for most Odoo teams is one long-lived branch per environment, plus short-lived feature branches:
| Branch | Environment | Database | Who merges into it |
|---|---|---|---|
feature/* |
Development instance or a laptop | Empty or demo data | The developer |
development (or dev) |
Shared development | Demo data, disposable | Developers, after CI passes |
staging |
Staging | Neutralised copy of production | Lead developer, after review |
main (or production) |
Production | Live data | Release owner only |
Two rules keep this sane:
- Code only moves forward. Features go
feature/*todevelopmenttostagingtomain. Hotfixes branch frommainand are merged back down tostaginganddevelopmentright away, otherwise the next release silently reverts them. - Protect
mainandstaging. In GitHub, add branch protection rules that require the CI check to pass and at least one review before merging. Nobody pushes directly to production.
Many teams also name the base branch after the Odoo series (for example 18.0), which makes the version obvious and keeps a clean line when you later start a 19.0 branch for an upgrade.
Repository layout
Keep custom modules at the root of the repository or in one addons/ folder, one module per directory, and pin third-party code (OCA repositories) as git submodules or a documented list of repositories and commits. A requirements.txt for extra Python dependencies of your modules belongs in the repository too, so CI and production install the same thing.
my-odoo-project/
addons/
my_sale_rules/
__init__.py
__manifest__.py
models/
views/
tests/
__init__.py
test_sale_rules.py
requirements.txt
.pre-commit-config.yaml
.github/workflows/ci.yml
Tests only run if the tests package imports them: every test file has to be imported in tests/__init__.py.
Running Odoo tests in CI
Odoo's test runner is built into odoo-bin. The flags that matter:
--test-enableruns the tests of the modules being installed or upgraded.--test-tagsfilters which tests run. The format is[-][tag][/module][:class][.method], so/my_sale_rulesruns everything in that module,:TestSaleRules.test_discountruns one method, and-post_installexcludes a tag. Setting--test-tagsalso implies--test-enable.--stop-after-initmakes Odoo exit once installation and tests finish instead of starting the HTTP server.-i module_a,module_binstalls the modules on a fresh database, which is what you want in CI.--log-level=testkeeps the output readable.
A typical CI command:
python odoo/odoo-bin \
--addons-path=odoo/addons,addons \
--db_host=localhost --db_port=5432 \
--db_user=odoo --db_password=odoo \
-d ci_test \
-i my_sale_rules \
--test-tags=/my_sale_rules \
--stop-after-init \
--log-level=test
Odoo returns a non-zero exit code when tests fail during this run, but it is common to also scan the log for ERROR and CRITICAL lines, because some problems (a failed data file in a dependency, for example) are logged without failing a test.
Two version details: tests tagged at_install run right after their module installs, post_install tests run after all modules are loaded (the default for HttpCase tours). And from Odoo 19, demo data is no longer loaded by default, so if your tests rely on demo records, add --with-demo on 19 (check the official changelog for your exact version).
A sample GitHub Actions workflow
This workflow runs on every push and pull request, starts PostgreSQL as a service container, checks out Odoo Community at the matching series, installs your modules and runs their tests.
name: ci
on:
push:
branches: [development, staging, main]
pull_request:
jobs:
test:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Check out Odoo
uses: actions/checkout@v4
with:
repository: odoo/odoo
ref: '18.0'
path: odoo
fetch-depth: 1
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- name: System packages
run: |
sudo apt-get update
sudo apt-get install -y libldap2-dev libsasl2-dev libpq-dev
- name: Python packages
run: |
pip install -r odoo/requirements.txt
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Install modules and run tests
run: |
python odoo/odoo-bin \
--addons-path=odoo/addons,addons \
--db_host=localhost --db_user=odoo --db_password=odoo \
-d ci_test -i my_sale_rules \
--test-tags=/my_sale_rules \
--stop-after-init --log-level=test 2>&1 | tee odoo.log
test ${PIPESTATUS[0]} -eq 0
! grep -E ' (ERROR|CRITICAL) ' odoo.log
Notes on this workflow:
- Enterprise. If your modules depend on Enterprise, add a third checkout of
odoo/enterprisewith a token from an account that has access, stored as a repository secret and passed to the checkout step'stokeninput, and put it first in--addons-path. - Speed. Checking out
odoo/odoowithfetch-depth: 1keeps it quick. For large projects, a prebuilt Docker image with Odoo and its dependencies already installed cuts minutes off every run. - Test the upgrade path too. Installing on an empty database catches most mistakes, but not a migration that fails on real data. That is what staging is for (see below).
Linting with pre-commit and pylint-odoo
Linting catches the cheap errors before CI spends minutes installing Odoo. The OCA maintains pylint-odoo, a pylint plugin with Odoo-specific checks (manifest keys, SQL injection risks, translation misuse, deprecated attributes). Run it through pre-commit so developers get the same checks locally and in CI:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: vX.Y.Z # pin the latest release
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/OCA/pylint-odoo
rev: vX.Y.Z # pin the latest release
hooks:
- id: pylint_odoo
args: [--rcfile=.pylintrc]
Then add a small job to the workflow:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: pre-commit/[email protected]
If you want a ready-made configuration, the OCA's oca-addons-repo-template ships a maintained pre-commit setup you can copy.
Deploying: pull, upgrade, restart
On a server running Odoo as a systemd service, a deploy is three steps. Run them from a GitHub Actions job over SSH, or from a script on the server triggered by a webhook.
set -euo pipefail
cd /opt/odoo/custom-addons
git fetch origin main
git reset --hard origin/main
sudo systemctl stop odoo
sudo -u odoo /opt/odoo/venv/bin/python /opt/odoo/odoo/odoo-bin \
-c /etc/odoo/odoo.conf -d production \
-u my_sale_rules,my_stock_rules \
--stop-after-init
sudo systemctl start odoo
Things that go wrong here:
- Upgrading only what changed.
-u allworks but is slow on a large database and touches modules you did not change. Either list the changed modules explicitly, or useclick-odoo-updatefrom theclick-odoo-contribproject, which compares module checksums and upgrades only modules whose files changed. - Stopping first. Running
-uwhile the live service is still serving requests risks workers running old code against a new schema. Stop, upgrade, start. The downtime is usually the length of the upgrade. - Multiple databases. If the server hosts several databases, each one needs its own
-urun. - Docker. With containers, the same logic applies: build or pull the new image, run a one-off container with
-u ... --stop-after-initagainst the database, then replace the running container.
Migrations for data and schema changes
Adding a field or a view needs nothing beyond -u. Renaming a field, changing its type or moving data between models does. Odoo runs migration scripts from a module's migrations/<version>/ folder when the version in __manifest__.py increases:
my_sale_rules/
migrations/
18.0.1.1.0/
pre-migrate.py # runs before the module's models are updated
post-migrate.py # runs after
# migrations/18.0.1.1.0/pre-migrate.py
def migrate(cr, version):
if not version:
return
cr.execute("""
ALTER TABLE sale_order
RENAME COLUMN x_discount_rule TO discount_rule
""")
Bump the module version in the same commit as the script, or the script never runs. Odoo's upgrade-util library (github.com/odoo/upgrade-util) has helpers such as rename_field that also update views, filters and translations, which is safer than hand-written SQL.
Rollbacks
A code rollback is git revert and a redeploy. A database rollback means restoring the backup you took before the upgrade, which also throws away everything users entered since. That is why the production deploy should always:
- Take a database and filestore backup immediately before
-u. - Run the upgrade.
- Smoke test (log in, open the changed screens, check the log for tracebacks).
- If it fails in the first minutes, restore the backup and redeploy the previous commit together. Restoring the database with the new code in place, or the old code on the upgraded schema, leaves you in a half state.
Keep migrations reversible where you can (add a new column, migrate data, drop the old one in a later release), so that the previous commit still runs on the upgraded database.
Staging with a neutralised production copy
Staging is only useful if it runs the same data shapes as production. Restore a recent production backup into the staging database, then neutralise it before anyone logs in, so it does not send email to customers, run payment providers or fire scheduled actions against real systems. Since Odoo 16 there is a command for this:
odoo-bin neutralize -c /etc/odoo/odoo.conf -d staging
It disables outgoing mail servers, crons, payment providers and similar integrations, and marks the database as neutralised. On older versions you do the same with SQL. Then deploy the staging branch with -u exactly as you would in production. This is where migration scripts meet real data, and where most upgrade failures should happen, not on production.
Pipeline checklist
- [ ] One branch per environment,
mainandstagingprotected - [ ] CI installs modules on a fresh database and runs
--test-enabletests - [ ] pre-commit with ruff and pylint-odoo, running locally and in CI
- [ ] Deploy stops Odoo, runs
-uon changed modules, starts Odoo - [ ] Module version bumped with every migration script
- [ ] Backup taken automatically before each production upgrade
- [ ] Staging refreshed from production and neutralised
- [ ] A written rollback procedure someone has actually tried
Doing this with CICDoo
CICDoo runs this model on Linux servers you own. You connect a GitHub repository (GitHub OAuth or a personal access token, and GitLab works too), and each branch becomes an instance in one of three stages: development, staging or production. Pushing to a branch starts a deployment job for its instance, and you follow it in the console's Queue tab with the full log. New development or staging instances are created from the console by forking a branch from a base branch (branches and instances).
Promotion is a merge from the console: merge the development branch into staging, check it, then merge staging into production. A Soft Restart restarts Odoo and upgrades modules; a Hard Restart restarts the container. Non-production instances are neutralised when Odoo starts, so a staging copy does not email your customers. Production and staging instances have backups you can take on demand before a release and restore if it goes wrong, and production can run them on a schedule. It works with Odoo Community and Enterprise, 11.0 to the latest release, plus master.
For the wider picture, see the Odoo deployment overview or talk to us.