Volver al Blog

Cómo pasar de despliegues locales a CI/CD rápido con GitHub Actions

2026-01-198 min read

He estado creando una aplicación web para entrenamiento de pronunciación para ayudarme a mejorar mi prosodia en inglés. Soy hablante nativo de español y pensé que podría ser útil para otros que están aprendiendo inglés como segunda lengua. El backend está hecho en Python con FastAPI, utilizando Parselmouth (un wrapper de Python para Praat) para el análisis de audio y Google Gemini para generar comentarios de entrenamiento. El frontend es React con Vite y TypeScript.

Para la infraestructura elegí Google Cloud. El backend se ejecuta en Cloud Run, el frontend está alojado en Firebase Hosting, y los datos de usuario residen en Firestore. Las imágenes de Docker se envían a Artifact Registry. El código reside en GitHub. Es una configuración bastante estándar para un proyecto secundario que quería mantener dentro de los límites del nivel gratuito.

El problema

Estaba desplegando tanto el frontend como el backend desde mi máquina local. El frontend era lo suficientemente rápido, pero el backend era una tortura. Cada despliegue significaba construir la imagen de Docker desde cero, lo que tardaba entre 5 y 10 minutos mientras pip descargaba todas las dependencias de Python.

Quería pasar a CI/CD para que los despliegues se realizaran automáticamente cuando hiciera push a main. Pero tampoco quería esperar 10 minutos por cada push. Así que me propuse hacer que GitHub Actions fuera inteligente sobre lo que realmente había cambiado.

La estructura del monorepo

Todo reside en un repositorio con una carpeta frontend/ y una carpeta backend/. El flujo de trabajo se activa al hacer push a main y quería que averiguara qué había cambiado realmente y solo desplegara esa parte. ¿Cambiaron archivos del frontend? Solo desplegar en Firebase. ¿Cambió el código del backend? Solo desplegar en Cloud Run. Y si cambió requirements.txt, reconstruir la imagen base de Docker. Pero solo entonces.

Encontrando una forma de detectar cambios

Busqué y encontré una acción llamada dorny/paths-filter. Comprueba qué archivos cambiaron en un push y genera valores booleanos que otros trabajos pueden usar. La configuré como el primer trabajo en mi flujo de trabajo:

jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
requirements: ${{ steps.filter.outputs.requirements }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
requirements:
- 'backend/requirements.txt'
- 'backend/Dockerfile.base'

Esto me dio tres salidas que podía referenciar en otros trabajos para decidir si debían ejecutarse.

Lidiando con las compilaciones lentas de Docker

Incluso con la detección de cambios funcionando, los despliegues del backend seguían siendo lentos. Cada compilación ejecutaba pip install desde cero. Mi requirements.txt tiene librerías de ML, cosas de procesamiento de audio, de todo. Eso es mucha descarga.

Me di cuenta de que quizás cambiaba las dependencias una vez al mes. El resto del tiempo solo estaba cambiando el código de la aplicación. Así que intenté dividir el Dockerfile en dos.

Dockerfile.base

Puse todo lo lento aquí: paquetes del sistema, dependencias de pip. La idea era compilar esto una vez y enviarlo a Artifact Registry, y luego reutilizarlo.

FROM python:3.11-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txt

Dockerfile.fast

Este comienza desde la imagen base y solo copia el código. Sin apt-get. Sin pip. Solo un comando COPY.

FROM us-central1-docker.pkg.dev/lescoach/prosody-repo/prosody-api-base:latest

WORKDIR /app
COPY app/ ./app/

ENV PYTHONUNBUFFERED=1
ENV PORT=8080
EXPOSE 8080

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Esto redujo el tiempo de compilación de 5-10 minutos a unos 30 segundos, lo cual fue una buena mejora.

Juntando el flujo de trabajo

Configuré el trabajo de la imagen base para que solo se ejecutara cuando las dependencias realmente cambiaran:

build-base-image:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.requirements == 'true'
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- name: Build and push base image
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest

El trabajo de despliegue del backend requirió algunas pruebas y errores. Necesitaba esperar la compilación de la imagen base si se ejecutaba, pero también funcionar cuando se omitía. Terminé con esto:

deploy-backend:
runs-on: ubuntu-latest
needs: [detect-changes, build-base-image]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
(needs.build-base-image.result == 'success' || needs.build-base-image.result == 'skipped')
steps:
# auth steps...

- name: Check if base image exists
id: check-base
run: |
if docker manifest inspect ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi

- name: Build base image if missing
if: steps.check-base.outputs.exists == 'false'
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest

- name: Build and push app image
working-directory: backend
run: |
docker build -f Dockerfile.fast -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }} .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}

- uses: google-github-actions/deploy-cloudrun@v2
with:
service: prosody-api
region: us-central1
image: ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
flags: --allow-unauthenticated

Aprendí que always() es necesario allí. Sin él, el trabajo ni siquiera evaluará su condición si un trabajo anterior fue omitido. También agregué una comprobación con docker manifest inspect para manejar el caso en que la imagen base aún no existe, como en un primer despliegue o si alguien la eliminó accidentalmente.

La parte del frontend

El trabajo del frontend terminó siendo más simple ya que no depende en absoluto del backend:

deploy-frontend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run build
working-directory: frontend
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LESCOACH }}
channelId: live
projectId: lescoach
entryPoint: frontend

Si solo cambió el frontend, este es el único trabajo que se ejecuta.

Configuración de autenticación

Elegí Workload Identity Federation en lugar de claves de cuenta de servicio. Requirió más configuración inicial, pero me gustó no tener que lidiar con la rotación de claves o preocuparme por archivos JSON guardados en secretos. GitHub obtiene tokens de corta duración a través de OIDC, lo que me pareció más limpio.

Dónde terminé

Los cambios solo en el frontend ahora se despliegan en menos de un minuto. Los cambios en el código del backend tardan unos 90 segundos. Los cambios en las dependencias todavía tardan unos 4 minutos, pero esos son raros para mí.

Flujo de trabajo de GitHub Actions que muestra los trabajos detect-changes, build-base-image, deploy-frontend y deploy-backend completándose en 1m 33s
Frontend y backend desplegados juntos en 1m 33s.

La mayoría de los días estoy ajustando cosas de la interfaz de usuario y ahora se despliega rápidamente. Todo el flujo de trabajo terminó siendo de unas 130 líneas de YAML. La acción paths-filter realiza la mayor parte del trabajo pesado para averiguar qué cambió.

Mantente Actualizado

Recibe las últimas publicaciones e insights en tu bandeja de entrada.

Unsubscribe anytime. No spam, ever.