Why CI/CD in Odoo is harder than it looks
Setting up a CI/CD pipeline for a standard web application is a solved problem. For Odoo, not so much. Odoo has particularities that break generic pipelines and that most guides ignore:
- Database state matters: Unlike a stateless application, Odoo keeps the database schema synchronised with the source code. Updating modules without the proper migration breaks production.
- Modules have cross-dependencies: A custom module may depend on an OCA module which in turn depends on a specific version of Odoo. The installation and update order is non-trivial.
- Tests need a real instance: Odoo tests require a database with the ERP installed. You cannot run a simple
pytestwithout a working instance with the modules loaded. - Deploy is not a restart: Updating modules in production means running
-u module_name, which can take minutes on large modules and locks the instance during that time.
This guide explains how to solve each of these problems with a real pipeline I use in production.
Pipeline architecture: overview
The pipeline I describe here has six sequential phases. Each phase is a job in GitHub Actions. If any phase fails, the pipeline stops and never reaches production:
- Lint and code style — flake8, pylint-odoo, isort
- Unit and integration tests — pytest-odoo with a dedicated database
- Multi-stage Docker image build — optimised and signed image
- Push to private registry — GitHub Container Registry or private Docker Hub
- Migration and staging deploy — module update + smoke tests
- Production deploy — with automatic pre-deploy backup and post-deploy health check
The staging environment is mandatory. There is no "deploy directly to production from the main branch". Everything goes through staging first.
Phase 1: Lint and static analysis
Lint is the fastest phase and the first barrier. A commit that breaks code style or has obvious static errors should not even get to run the tests.
For Odoo projects, the specific linter is pylint-odoo, which knows the framework conventions: field names, model inheritance, correct use of api.depends, api.constrains, etc.
# .github/workflows/ci.yml (fragmento fase lint)
jobs:
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install lint dependencies
run: |
pip install flake8 pylint-odoo isort
- name: Run flake8
run: |
flake8 --max-line-length=120 \
--exclude=__pycache__,.git,migrations \
custom_addons/
- name: Run pylint-odoo
run: |
pylint --load-plugins=pylint_odoo \
--rcfile=.pylintrc \
custom_addons/
- name: Check import order (isort)
run: |
isort --check-only --diff custom_addons/
The .pylintrc file must explicitly configure which modules are analysed and disable checks that do not apply to Odoo (for example, the multiple inheritance check, which Odoo uses extensively). A good starting point is the OCA's .pylintrc.
Phase 2: Tests with pytest-odoo
This is the most complex phase. Running Odoo tests in CI requires spinning up a full instance with a PostgreSQL database. The correct approach is to use pytest-odoo as the runner, which manages Odoo environment initialisation in a pytest-compatible way.
PostgreSQL service structure in Actions
GitHub Actions allows auxiliary services (containers) to be started that will be available during job execution. We use this for PostgreSQL:
test:
runs-on: ubuntu-22.04
needs: lint
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: odoo_test
POSTGRES_PASSWORD: odoo_test
POSTGRES_DB: odoo_test
options: >
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Cache Odoo dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-odoo-deps-${{ hashFiles('requirements.txt') }}
- name: Install Odoo and dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
libpq-dev libldap2-dev libsasl2-dev \
node-less npm wkhtmltopdf
pip install -r requirements.txt
pip install pytest pytest-odoo coverage
- name: Run tests with coverage
env:
DB_HOST: localhost
DB_PORT: 5432
DB_USER: odoo_test
DB_PASSWORD: odoo_test
DB_NAME: odoo_test
run: |
python -m pytest \
--odoo-database=$DB_NAME \
--odoo-addons-path="odoo/addons,custom_addons" \
--odoo-config=ci/odoo-test.conf \
--tb=short \
--cov=custom_addons \
--cov-report=xml \
--cov-fail-under=70 \
custom_addons/*/tests/
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
The odoo-test.conf file
The Odoo configuration for the test environment must be minimal and disable features that slow down tests (mail, workers, etc.):
[options]
addons_path = odoo/addons,custom_addons
db_host = localhost
db_port = 5432
db_user = odoo_test
db_password = odoo_test
test_enable = True
without_demo = True
log_level = warn
workers = 0
email_from = False
smtp_server = localhost
smtp_port = 1025
Odoo test best practices
Odoo tests inherit from TransactionCase or SavepointCase. The difference matters: TransactionCase rolls back the entire transaction at the end of each test (faster), while SavepointCase uses savepoints and allows more granular tests. For integration tests that need persistent data between methods, use SavepointCase.
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestSaleOrderCustom(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'Test Partner CI',
'email': 'ci@test.com',
})
def test_sale_order_confirm_creates_picking(self):
"""Verifica que confirmar un pedido crea el albarán."""
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.env.ref('product.product_product_1').id,
'product_uom_qty': 1,
'price_unit': 100.0,
})],
})
order.action_confirm()
self.assertEqual(
order.picking_ids[0].state,
'confirmed',
'El albarán debe estar confirmado al confirmar el pedido'
)
Phase 3: Multi-stage Docker image build
The Docker image must be reproducible, minimal and traceable. The multi-stage pattern allows a build stage (where compilation tools are installed) and a runtime stage (where only what is needed to run is present).
# Dockerfile
# =====================
# Stage 1: builder
# =====================
FROM python:3.10-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libldap2-dev \
libsasl2-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# =====================
# Stage 2: runtime
# =====================
FROM python:3.10-slim-bookworm AS runtime
LABEL org.opencontainers.image.source="https://github.com/REPO"
LABEL org.opencontainers.image.revision="${GIT_SHA}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libldap-2.5-0 \
libsasl2-2 \
node-less \
wkhtmltopdf \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copiar dependencias Python del stage builder
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Usuario no-root para seguridad
RUN groupadd -r odoo && useradd -r -g odoo odoo
WORKDIR /opt/odoo
# Copiar código Odoo y addons custom
COPY --chown=odoo:odoo odoo/ ./odoo/
COPY --chown=odoo:odoo custom_addons/ ./custom_addons/
COPY --chown=odoo:odoo config/ ./config/
USER odoo
EXPOSE 8069 8072
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8069/web/health || exit 1
ENTRYPOINT ["python", "odoo/odoo-bin"]
CMD ["--config=/opt/odoo/config/odoo.conf"]
The build step in the pipeline tags the image with the commit SHA and the branch tag, guaranteeing full traceability: you can always know exactly what code is running in production.
build:
runs-on: ubuntu-22.04
needs: test
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/odoo
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_SHA=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
Phases 4 and 5: Staging deploy with module migration
The staging deploy has two parts: updating the container and updating the modules in the database. These are different things and must be done in order.
Module migration: the step nobody documents well
When an Odoo module has changes to its data model (new fields, renamed fields, new views), you must run odoo-bin --update=module_name so that Odoo applies the changes to the database schema. If you skip this step, the new code tries to access columns that do not exist and the instance fails.
The deploy script automatically detects which modules have changed by comparing the previous commit with the current one:
deploy-staging:
runs-on: ubuntu-22.04
needs: build
environment: staging
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # necesario para git diff
- name: Detect changed modules
id: changed-modules
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD -- custom_addons/ \
| cut -d'/' -f2 \
| sort -u \
| tr '\n' ',')
CHANGED=${CHANGED%,} # quitar coma final
echo "modules=${CHANGED}" >> $GITHUB_OUTPUT
echo "Changed modules: ${CHANGED}"
- name: Deploy to staging via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
set -e
IMAGE=ghcr.io/${{ github.repository }}/odoo:sha-${{ github.sha }}
MODULES="${{ steps.changed-modules.outputs.modules }}"
echo "[1/4] Pulling new image: ${IMAGE}"
docker pull ${IMAGE}
echo "[2/4] Stopping current Odoo workers (keep DB up)"
docker compose -f /opt/odoo/docker-compose.staging.yml stop odoo
echo "[3/4] Running module migrations: ${MODULES}"
if [ -n "${MODULES}" ]; then
docker run --rm \
--network odoo_network \
--env-file /opt/odoo/staging.env \
${IMAGE} \
--config=/opt/odoo/config/odoo.conf \
--database=odoo_staging \
--update=${MODULES} \
--stop-after-init
fi
echo "[4/4] Starting Odoo with new image"
IMAGE=${IMAGE} docker compose \
-f /opt/odoo/docker-compose.staging.yml \
up -d --no-deps odoo
- name: Smoke tests on staging
run: |
sleep 30 # esperar arranque
# Health check endpoint nativo de Odoo 16+
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
https://staging.skanndar.top/web/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed: HTTP $STATUS"
exit 1
fi
echo "Staging health check OK (HTTP $STATUS)"
Phase 6: Production deploy with prior backup
The production deploy only runs when the staging deploy has been successful AND there is a manual approval in GitHub Environments. This manual gate is deliberate: in production, a human must confirm they want to proceed.
The most critical step of the production deploy is the prior backup. A backup that has not been executed correctly before the deploy is not a backup: it is an illusion of security.
deploy-production:
runs-on: ubuntu-22.04
needs: deploy-staging
environment: production # requiere aprobación manual en GitHub
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Detect changed modules
id: changed-modules
run: |
CHANGED=$(git diff --name-only HEAD~1 HEAD -- custom_addons/ \
| cut -d'/' -f2 \
| sort -u \
| tr '\n' ',')
echo "modules=${CHANGED%,}" >> $GITHUB_OUTPUT
- name: Deploy to production via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
set -e
IMAGE=ghcr.io/${{ github.repository }}/odoo:sha-${{ github.sha }}
MODULES="${{ steps.changed-modules.outputs.modules }}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_PATH=/opt/backups/pre-deploy-${TIMESTAMP}.dump
echo "[1/5] Pre-deploy backup"
docker exec postgres16 pg_dump \
-U odoo \
-Fc \
odoo_production \
-f ${BACKUP_PATH}
# Verificar que el backup es válido (size > 1MB)
BACKUP_SIZE=$(stat -c%s ${BACKUP_PATH})
if [ ${BACKUP_SIZE} -lt 1048576 ]; then
echo "ERROR: Backup demasiado pequeño (${BACKUP_SIZE} bytes). Abortando deploy."
exit 1
fi
echo "Backup OK: ${BACKUP_PATH} (${BACKUP_SIZE} bytes)"
echo "[2/5] Pulling new image"
docker pull ${IMAGE}
echo "[3/5] Stopping Odoo workers"
docker compose -f /opt/odoo/docker-compose.prod.yml stop odoo
echo "[4/5] Running module migrations on production"
if [ -n "${MODULES}" ]; then
docker run --rm \
--network odoo_network \
--env-file /opt/odoo/production.env \
${IMAGE} \
--config=/opt/odoo/config/odoo.prod.conf \
--database=odoo_production \
--update=${MODULES} \
--stop-after-init
fi
echo "[5/5] Starting new Odoo"
IMAGE=${IMAGE} docker compose \
-f /opt/odoo/docker-compose.prod.yml \
up -d --no-deps odoo
- name: Production health check
run: |
sleep 45
for i in 1 2 3 4 5; do
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
https://skanndar.top/web/health 2>/dev/null || echo "000")
if [ "$STATUS" = "200" ]; then
echo "Production health check OK (intento $i)"
exit 0
fi
echo "Intento $i: HTTP $STATUS. Esperando..."
sleep 15
done
echo "ERROR: Production health check failed tras 5 intentos"
exit 1
- name: Notify deploy result
if: always()
uses: appleboy/telegram-action@v0.1.1
with:
to: ${{ secrets.TELEGRAM_CHAT_ID }}
token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
message: |
${{ job.status == 'success' && '✅' || '❌' }} Deploy producción Odoo
Commit: ${{ github.sha }}
Módulos: ${{ steps.changed-modules.outputs.modules }}
Estado: ${{ job.status }}
Additional best practices
Anonymised data in staging
Staging should use a copy of the production database, but with personal data anonymised. Never use real customer data, real emails or real financial data in staging. The OCA's odoo_data_anonymization module or a custom anonymisation script before restoring the dump are the two standard options.
The staging refresh process must be automated and run at least weekly, so that staging is a faithful mirror of production without personal data:
#!/bin/bash
# scripts/refresh-staging.sh
set -e
TIMESTAMP=$(date +%Y%m%d)
DUMP=/tmp/prod-anonymized-${TIMESTAMP}.dump
echo "Dumping production database..."
docker exec postgres16 pg_dump -U odoo -Fc odoo_production -f ${DUMP}
echo "Anonymizing data..."
docker run --rm \
-v ${DUMP}:/dump.dump \
-e PGPASSWORD=odoo \
odoo-anonymizer:latest \
--input=/dump.dump \
--output=/dump-anon.dump \
--rules=/etc/anonymizer-rules.yml
echo "Restoring to staging..."
docker exec postgres16-staging dropdb --if-exists odoo_staging
docker exec postgres16-staging createdb odoo_staging
docker exec postgres16-staging pg_restore \
-U odoo \
-d odoo_staging \
/dump-anon.dump
echo "Staging refreshed at ${TIMESTAMP}"
Secrets management
Never hardcode credentials in the repository. Always use GitHub Encrypted Secrets for sensitive pipeline variables (DB_PASSWORD, SSH_KEY, TELEGRAM_BOT_TOKEN, etc.). For Odoo environment variables on servers, use .env files outside the repository, mounted into containers via env_file in docker-compose.
The odoo.conf file committed to the repository must never contain real passwords. Environment variables injected into the container override those in the configuration file, which is the correct mechanism.
Workflow version pinning
Always pin the versions of the GitHub Actions you use (actions/checkout@v4, not actions/checkout@main). A third-party Action can update its main branch with malicious code. Using specific tags or full SHAs is the correct supply chain security practice.
Test parallelisation
If test execution time grows (expected in mature projects), you can parallelise using the matrix strategy in GitHub Actions to distribute modules across multiple jobs, or use pytest-xdist to parallelise tests within the same job. With 50+ tests, the difference between sequential and parallel execution can be 10 vs 3 minutes.
Fast rollback
Rollback in Odoo is more complex than in a stateless application. If the new version of the code includes database migrations (new columns, renamed columns), you cannot simply revert to the previous container: the database has already been migrated and the old code does not understand it.
That is why the pre-deploy backup is mandatory. The complete rollback procedure is:
- Stop the Odoo container.
- Restore the database backup taken before the deploy.
- Restart the container with the previous image (which is in the registry with its SHA tag).
This process must be documented and tested periodically. A rollback that has never been tested is a rollback that will fail when it is needed most.
The complete pipeline: time summary
A well-configured pipeline for a medium-sized Odoo project (10-20 custom modules, 200-500 tests) has these approximate times:
| Phase | Typical time | Parallelisable |
|---|---|---|
| Lint (flake8 + pylint-odoo) | 1-2 min | With test (in parallel) |
| Tests (pytest-odoo) | 5-15 min | With lint (in parallel) |
| Docker image build | 3-8 min (with cache: <2 min) | No (waits for tests) |
| Staging deploy + migration | 3-6 min | No (waits for build) |
| Staging smoke tests | 1-2 min | No (waits for staging deploy) |
| Manual production approval | Variable (human) | — |
| Backup + deploy + prod migration | 5-10 min | No |
| Production health check | 1-2 min | No |
Total time from push to production: 20-45 minutes, with the majority spent on tests and human approval. Without CI/CD, the same manual process by an experienced developer takes between 45 minutes and 2 hours, with far greater margin for error.
Conclusió
A well-built CI/CD pipeline for Odoo eliminates the main source of production outages: human error in manual deployment. Every time the pipeline catches a failure before reaching production — whether a broken test, a failed lint or a migration that does not start — you are preventing a real incident. In projects where one hour of downtime costs thousands of euros in lost operations, the investment in automation pays off on the first incident that does not happen.
The workflow I have described here is not theoretical: it is the same schema I use in my production projects, adapted and simplified for this guide. The specific details (service names, paths, environment variables) vary by project, but the six-phase structure, backup validation, and post-deploy health check are invariable.