Pipeline CI/CD para Odoo con GitHub Actions y Docker: guía completa

Arquitectura real de un pipeline de integración y despliegue continuo para módulos Odoo en producción

Por qué el CI/CD en Odoo es más difícil de lo que parece

Montar un pipeline de CI/CD para una aplicación web estándar es un problema resuelto. Para Odoo, no tanto. Odoo tiene particularidades que rompen los pipelines genéricos y que la mayoría de guías ignoran:

  • El estado de la base de datos importa: A diferencia de una aplicación stateless, Odoo mantiene el esquema de la base de datos sincronizado con el código fuente. Actualizar módulos sin la migración adecuada rompe producción.
  • Los módulos tienen dependencias cruzadas: Un módulo custom puede depender de un módulo OCA que a su vez depende de una versión específica de Odoo. El orden de instalación y actualización no es trivial.
  • Los tests necesitan una instancia real: Los tests de Odoo requieren una base de datos con el ERP instalado. No puedes hacer un simple pytest sin una instancia funcional con los módulos cargados.
  • El deploy no es un reinicio: Actualizar módulos en producción implica ejecutar -u module_name, que puede tardar minutos en módulos grandes y bloquea la instancia durante ese tiempo.

Esta guía explica cómo resolver cada uno de estos problemas con un pipeline real que uso en producción.

Arquitectura del pipeline: visión general

El pipeline que describo aquí tiene seis fases secuenciales. Cada fase es un job en GitHub Actions. Si cualquier fase falla, el pipeline se detiene y nunca llega a producción:

  1. Lint y estilo de código — flake8, pylint-odoo, isort
  2. Tests unitarios e integración — pytest-odoo con base de datos dedicada
  3. Build de imagen Docker multi-stage — imagen optimizada y firmada
  4. Push a registro privado — GitHub Container Registry o Docker Hub privado
  5. Migración y deploy en staging — actualización de módulos + smoke tests
  6. Deploy a producción — con backup previo automático y health check post-deploy

El entorno de staging es obligatorio. No existe «desplegar directamente a producción desde la rama main». Todo pasa por staging primero.

Fase 1: Lint y análisis estático

El lint es la fase más rápida y la primera barrera. Un commit que rompe el estilo de código o tiene errores estáticos evidentes no debe ni llegar a ejecutar los tests.

Para proyectos Odoo, el linter específico es pylint-odoo, que conoce las convenciones del framework: nombres de campos, herencia de modelos, uso correcto de 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/

El archivo .pylintrc debe configurar explícitamente qué módulos se analizan y deshabilitar checks que no aplican a Odoo (por ejemplo, el check de herencia múltiple, que Odoo usa extensivamente). Un buen punto de partida es el .pylintrc de la OCA.

Fase 2: Tests con pytest-odoo

Esta es la fase más compleja. Ejecutar los tests de Odoo en CI requiere levantar una instancia completa con base de datos PostgreSQL. El approach correcto es usar pytest-odoo como runner, que gestiona la inicialización del entorno Odoo de forma compatible con pytest.

Estructura del servicio PostgreSQL en Actions

GitHub Actions permite levantar servicios auxiliares (containers) que estarán disponibles durante la ejecución del job. Usamos esto para 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

El fichero odoo-test.conf

La configuración de Odoo para el entorno de test debe ser minimalista y desactivar funciones que ralentizan los tests (correo, 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

Buenas prácticas de tests en Odoo

Los tests en Odoo heredan de TransactionCase o SavepointCase. La diferencia es importante: TransactionCase hace rollback de toda la transacción al final de cada test (más rápido), mientras que SavepointCase usa savepoints y permite tests más granulares. Para tests de integración que necesitan datos persistentes entre métodos, usa 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'
        )

Fase 3: Build de imagen Docker multi-stage

La imagen Docker debe ser reproducible, mínima y trazable. El patrón multi-stage permite tener un stage de build (donde instalamos herramientas de compilación) y un stage de runtime (donde solo hay lo necesario para ejecutar).

# 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"]

El step de build en el pipeline etiqueta la imagen con el SHA del commit y el tag de la rama, lo que garantiza trazabilidad total: siempre puedes saber exactamente qué código está corriendo en producción.

  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 }}

Fase 4 y 5: Deploy en staging con migración de módulos

El deploy en staging tiene dos partes: actualizar el contenedor y actualizar los módulos en la base de datos. Son cosas distintas y deben hacerse en orden.

La migración de módulos: el paso que nadie documenta bien

Cuando un módulo Odoo tiene cambios en su modelo de datos (nuevos campos, campos renombrados, nuevas vistas), hay que ejecutar odoo-bin --update=module_name para que Odoo aplique los cambios al esquema de la base de datos. Si omites este paso, el código nuevo intenta acceder a columnas que no existen y la instancia falla.

El script de deploy detecta automáticamente qué módulos han cambiado comparando el commit anterior con el actual:

  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)"

Fase 6: Deploy a producción con backup previo

El deploy a producción solo se ejecuta cuando el deploy en staging ha sido exitoso Y hay una aprobación manual en GitHub Environments. Esta barrera manual es deliberada: en producción, un humano debe confirmar que quiere proceder.

El paso más crítico del deploy a producción es el backup previo. Un backup que no se ha ejecutado correctamente antes del deploy no es un backup: es una ilusión de seguridad.

  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 }}

Buenas prácticas adicionales

Datos anonimizados en staging

Staging debe usar una copia de la base de datos de producción, pero con datos personales anonimizados. Nunca uses datos reales de clientes, emails reales o datos financieros reales en staging. El módulo odoo_data_anonymization de la OCA o un script de anonimización custom antes de restaurar el dump son las dos opciones habituales.

El proceso de refresco de staging debe estar automatizado y ejecutarse al menos semanalmente, para que staging sea un espejo fiel de producción sin datos personales:

#!/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}"

Gestión de secretos

Nunca hardcodees credenciales en el repositorio. Usa siempre GitHub Encrypted Secrets para las variables sensibles del pipeline (DB_PASSWORD, SSH_KEY, TELEGRAM_BOT_TOKEN, etc.). Para las variables de entorno de Odoo en los servidores, usa archivos .env fuera del repositorio, montados en los containers via env_file en docker-compose.

El fichero odoo.conf que va en el repositorio nunca debe contener contraseñas reales. Las variables de entorno inyectadas al container sobreescriben las del fichero de configuración, que es el mecanismo correcto.

Control de versiones del workflow

Fija siempre las versiones de las GitHub Actions que usas (actions/checkout@v4, no actions/checkout@main). Una Action de terceros puede actualizar su main branch con código malicioso. Usar tags específicos o SHAs completos es la práctica de seguridad correcta en supply chain.

Paralelización de tests

Si el tiempo de ejecución de los tests crece (algo esperado en proyectos maduros), puedes paralelizar usando la estrategia matrix de GitHub Actions para distribuir los módulos entre varios jobs, o usando pytest-xdist para paralelizar tests dentro del mismo job. Con 50+ tests, la diferencia entre ejecución secuencial y paralela puede ser de 10 vs 3 minutos.

Rollback rápido

El rollback en Odoo es más complejo que en una aplicación sin estado. Si la nueva versión del código incluye migraciones de base de datos (nuevas columnas, columnas renombradas), no puedes simplemente volver al container anterior: la base de datos ya fue migrada y el código antiguo no la entiende.

Por eso el backup pre-deploy es obligatorio. El procedimiento de rollback completo es:

  1. Detener el container de Odoo.
  2. Restaurar el backup de base de datos tomado antes del deploy.
  3. Volver a lanzar el container con la imagen anterior (que está en el registro con su tag de SHA).

Este proceso debe estar documentado y testeado periódicamente. Un rollback que nunca se ha probado es un rollback que fallará cuando más se necesite.

El pipeline completo: resumen de tiempos

Un pipeline bien configurado para un proyecto Odoo de tamaño medio (10-20 módulos custom, 200-500 tests) tiene estos tiempos aproximados:

Fase Tiempo típico Paralelizable
Lint (flake8 + pylint-odoo) 1-2 min Con test (en paralelo)
Tests (pytest-odoo) 5-15 min Con lint (en paralelo)
Build imagen Docker 3-8 min (con caché: <2 min) No (espera tests)
Deploy staging + migración 3-6 min No (espera build)
Smoke tests staging 1-2 min No (espera deploy staging)
Aprobación manual producción Variable (humano)
Backup + deploy + migración prod 5-10 min No
Health check producción 1-2 min No

Tiempo total desde push hasta producción: 20-45 minutos, con la mayor parte dedicada a tests y a la aprobación humana. Sin CI/CD, el mismo proceso manual de un desarrollador experimentado tarda entre 45 minutos y 2 horas, y con mucho más margen de error.

Conclusión

Un pipeline CI/CD para Odoo bien construido elimina la principal fuente de caídas en producción: el error humano en el despliegue manual. Cada vez que el pipeline detecta un fallo antes de llegar a producción —sea un test roto, un lint fallido, o una migración que no arranca— estás evitando una incidencia real. En proyectos donde una hora de caída cuesta miles de euros en operativa perdida, la inversión en automatización se amortiza en el primer incidente que no ocurre.

El workflow que he descrito aquí no es teórico: es el mismo esquema que uso en mis proyectos en producción, adaptado y simplificado para esta guía. Los detalles concretos (nombres de servicios, rutas, variables de entorno) varían por proyecto, pero la estructura de seis fases, la validación de backup, y el health check post-deploy son invariables.

¿Quieres implementar CI/CD en tu proyecto Odoo?

Solicitar auditoría técnica gratuita

Odoo Alta Disponibilidad con Patroni y HAProxy: arquitectura HA
Cómo diseñar una arquitectura HA real para Odoo con Patroni, replicación PostgreSQL streaming, HAProxy y filestore compartido: RTO, RPO y pruebas de failover incluidos.