INTRODUCTION

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.

01

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:

StageTriggerWhat It DoesGate
Lint & StaticEvery push / PRFlake8, pylint-odoo, ESLint for OWL componentsMust pass for PR merge
TestEvery push / PRInstall modules on fresh DB, run --test-enable with real PostgreSQLMust pass for PR merge
DeployMerge to mainSSH into server, pull code, run -u on changed modules, restart workersHealth 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:

Directory layout
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
Why Not a Monorepo with Odoo Source?

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.

02

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.

YAML — .github/workflows/ci.yml
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 ERROR and exits 0. The grep step 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.
Critical: Odoo Tests Don't Fail the Process

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.

03

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.

YAML — .github/workflows/deploy.yml
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 1

Design choices that matter:

  • concurrency with cancel-in-progress: false — if two merges happen in quick succession, the second deploy waits instead of cancelling the first mid-update. Cancelling a -u mid-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 --hard on the server — the server copy is always an exact mirror of main. 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:

BranchTargetAuto-DeployApproval Required
developStaging serverYes, on every pushNo
mainProduction serverYes, after mergeYes (via GitHub Environment)
hotfix/*Production (manual)No — workflow_dispatchYes
Staging Database Strategy

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.

04

3 CI/CD Mistakes That Silently Ship Broken Odoo Modules to Production

1

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.

Our Fix

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.

2

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.

Our Fix

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.

3

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.

Our Fix

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.

BUSINESS ROI

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:

85%Fewer Post-Deploy Incidents

Automated tests catch regressions before they reach users. The bugs that do get through are edge cases, not obvious failures.

10 minDeploy Time (was 2 hrs)

From merge to live in minutes. No SSH, no manual commands, no "let me check which modules changed."

100%Deploy Audit Trail

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.

SEO NOTES

Optimization Metadata

Meta Desc

Build a full CI/CD pipeline for Odoo 19 with GitHub Actions. Automated testing against real PostgreSQL, zero-downtime deploys, and rollback strategy.

H2 Keywords

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"

Stop Deploying Odoo Over SSH

Every ssh prod-server && cd /opt/odoo && git pull && ./restart.sh is a deployment with no safety net. No tests, no rollback, no audit trail. It works until it doesn't — and when it doesn't, the cost is measured in hours of downtime and lost transactions.

If you're running Odoo 19 with custom modules and deploying manually, we can help. We set up CI/CD pipelines, configure testing environments, and implement zero-downtime deployment strategies tailored to your infrastructure — whether that's Docker, bare metal, or Odoo.sh. The pipeline pays for itself the first time it catches a regression that would have reached production.

Book a Free DevOps Assessment