Complete CI/CD Pipeline for Odoo with GitHub Actions and Docker

Real architecture of a continuous integration and deployment pipeline for Odoo modules in production

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 pytest without 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:

  1. Lint and code style — flake8, pylint-odoo, isort
  2. Unit and integration tests — pytest-odoo with a dedicated database
  3. Multi-stage Docker image build — optimised and signed image
  4. Push to private registry — GitHub Container Registry or private Docker Hub
  5. Migration and staging deploy — module update + smoke tests
  6. 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:

  1. Stop the Odoo container.
  2. Restore the database backup taken before the deploy.
  3. 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.

Do you want to implement CI/CD in your Odoo project?

Request a free technical audit

Odoo High Availability with Patroni and HAProxy: HA architecture
How to design a real HA architecture for Odoo with Patroni, PostgreSQL streaming replication, HAProxy and shared filestore: RTO, RPO and failover testing included.