Ir al contenido
  1. Posts/

GitHub Actions para tu homelab: CI/CD casero que funciona de verdad

Hubo una época en que desplegar una actualización en mi homelab implicaba: SSH al servidor, docker pull, docker-compose down, docker-compose up -d, revisar logs, rezar. Cada vez. Para cada servicio. Si tenía 8 servicios que actualizar, multiplicaba ese proceso por 8.

No es que fuera insoportable. Pero con el tiempo acumulas suficiente cansancio de hacer lo mismo a mano que buscas automatizarlo. Y cuando lo automatizas bien, no quieres volver al método manual.

GitHub Actions con self-hosted runners fue la solución que más me convence por su relación entre esfuerzo de configuración y resultado.

Qué es un self-hosted runner
#

GitHub Actions tiene runners gestionados por GitHub (ubuntu-latest, windows-latest…) que ejecutan tus pipelines en servidores de Microsoft. Para código público o repositorios de GitHub, están bien.

Para el homelab tienen un problema: esos runners no tienen acceso a tu red local. No pueden hacer SSH a tu servidor, no pueden escribir a tu NAS, no pueden pushear a tu Gitea privado.

Un self-hosted runner es un agente que instalas en uno de tus propios servidores. Cuando GitHub Actions lanza un job con runs-on: self-hosted, el trabajo se ejecuta en tu máquina, dentro de tu red, con acceso a todos tus servicios locales.

Puedo configurar el runner en cualquier máquina Linux de mi red. Yo lo tengo en mi Mac mini, que está siempre encendido y tiene acceso a todos los servidores del homelab via Tailscale.

Instalación del runner
#

En el repo de GitHub que quieras configurar: Settings > Actions > Runners > New self-hosted runner.

Seleccionas tu arquitectura (Linux/macOS/ARM64, según tu máquina) y te da los comandos exactos. El proceso es:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
mkdir actions-runner && cd actions-runner

# Descarga el runner (versión actualizada en la web de GitHub)
curl -o actions-runner-linux-arm64-2.314.1.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.314.1/actions-runner-linux-arm64-2.314.1.tar.gz

tar xzf actions-runner-linux-arm64-2.314.1.tar.gz

# Configura con el token que te da GitHub
./config.sh --url https://github.com/tu-usuario/tu-repo \
  --token XXXXXXXXXXXXX

# Instala como servicio del sistema
sudo ./svc.sh install
sudo ./svc.sh start

En 5 minutos el runner aparece en verde en la interfaz de GitHub. A partir de ahí, cualquier pipeline que use runs-on: self-hosted corre en tu máquina.

Uso runners con labels para distinguirlos. Tengo uno en el Mac mini (linux, arm64, homelab) y otro en un nodo del cluster K3s (linux, amd64, k3s). En el workflow especifico cuál necesito:

1
runs-on: [self-hosted, homelab, arm64]

Un pipeline real: deploy de container al cambiar código
#

Tengo varios proyectos donde el flujo es: edito el código, hago commit, push, y quiero que la nueva versión esté corriendo en mi servidor sin hacer nada más.

El workflow básico para un proyecto con Docker:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: Deploy homelab service

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: [self-hosted, homelab]
    
    steps:
      - name: Checkout código
        uses: actions/checkout@v4
      
      - name: Build imagen Docker
        run: |
          docker build -t mi-servicio:latest .
          docker tag mi-servicio:latest mi-servicio:${{ github.sha }}
      
      - name: Deploy
        run: |
          docker stop mi-servicio || true
          docker rm mi-servicio || true
          docker run -d \
            --name mi-servicio \
            --restart unless-stopped \
            -p 8080:8080 \
            -e ENV_VAR=${{ secrets.MI_ENV_VAR }} \
            mi-servicio:latest
      
      - name: Verificar que arrancó
        run: |
          sleep 5
          docker ps | grep mi-servicio
          curl -f http://localhost:8080/health || exit 1

Este workflow se ejecuta en mi Mac mini. Hace el build local (más rápido que en los runners de GitHub para imágenes ARM), para el container anterior, lanza el nuevo, y verifica que el endpoint de health responde. Si algo falla, el job falla y GitHub me notifica.

Las variables sensibles (tokens, contraseñas, URLs internas) van en Secrets del repositorio en GitHub. Dentro del workflow se referencian como ${{ secrets.NOMBRE }} y nunca aparecen en logs.

Workflow para stack con docker-compose
#

Para proyectos con varios containers en docker-compose, el workflow es parecido pero más limpio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
name: Deploy stack

on:
  push:
    branches: [ main ]
    paths:
      - 'docker-compose.yml'
      - 'docker-compose.prod.yml'
      - 'config/**'

jobs:
  deploy:
    runs-on: [self-hosted, homelab]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Pull imágenes actualizadas
        run: docker-compose -f docker-compose.prod.yml pull
      
      - name: Restart stack con zero-downtime
        run: |
          docker-compose -f docker-compose.prod.yml up -d \
            --remove-orphans \
            --force-recreate
      
      - name: Limpiar imágenes antiguas
        run: docker image prune -f

Noto el paths en el trigger: el deploy solo se ejecuta si cambian archivos relevantes. Si edito el README o la documentación, no quiero que el sistema redeploy todo el stack.

Tests antes de desplegar
#

Una de las cosas que más valor da a los pipelines es meter tests o validaciones antes del deploy. Para configuraciones de Kubernetes, valido con kubeval o conftest que los manifests son correctos antes de aplicarlos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
name: Validate and deploy K3s manifests

on:
  push:
    branches: [ main ]
    paths: [ 'k8s/**' ]

jobs:
  validate:
    runs-on: [self-hosted, homelab]
    steps:
      - uses: actions/checkout@v4
      
      - name: Validar sintaxis YAML
        run: |
          for f in k8s/*.yaml; do
            python3 -c "import yaml; yaml.safe_load(open('$f'))" || exit 1
            echo "OK: $f"
          done
      
      - name: Dry-run en el cluster
        run: |
          kubectl apply --dry-run=server -f k8s/
  
  deploy:
    needs: validate  # Solo corre si validate pasó
    runs-on: [self-hosted, k3s]
    steps:
      - uses: actions/checkout@v4
      
      - name: Apply manifests
        run: kubectl apply -f k8s/
      
      - name: Esperar rollout
        run: |
          kubectl rollout status deployment/mi-servicio \
            -n mi-namespace \
            --timeout=120s

El job deploy tiene needs: validate. Si los tests fallan, el deploy no se ejecuta. Esto me ha salvado varias veces de aplicar un YAML malformado al cluster.

Workflows matriciales: testing en múltiples configuraciones
#

GitHub Actions tiene una función llamada matrix strategy que ejecuta el mismo job con distintos parámetros en paralelo. Lo uso para probar que mis scripts Python funcionan en distintas versiones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
jobs:
  test:
    runs-on: [self-hosted, homelab]
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Instalar Python ${{ matrix.python-version }}
        uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
      
      - name: Instalar dependencias
        run: pip install -r requirements.txt
      
      - name: Ejecutar tests
        run: pytest tests/ -v

Tres jobs corren en paralelo, uno por cada versión de Python. Si falla en 3.11 pero pasa en 3.10 y 3.12, sabes exactamente dónde buscar.

Gitea Actions: la alternativa completamente self-hosted
#

Si prefieres no depender de GitHub y ya tienes Gitea en tu homelab, Gitea Actions es compatible con la sintaxis de GitHub Actions. Los workflows son prácticamente iguales, el runner se instala igual, y todo corre dentro de tu red.

La diferencia principal es que el trigger viene de tu Gitea local en vez de GitHub. Para proyectos internos que no tienen sentido en GitHub (scripts de configuración de mi homelab, notas personales, proyectos privados) uso Gitea Actions.

El runner para Gitea:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Descarga act_runner (el runner oficial de Gitea)
wget https://dl.gitea.com/act_runner/latest/act_runner-linux-arm64

chmod +x act_runner-linux-arm64

# Registra el runner en tu instancia de Gitea
./act_runner-linux-arm64 register \
  --no-interactive \
  --instance https://tu-gitea.local \
  --token TU_TOKEN_DE_GITEA \
  --name mi-runner \
  --labels "homelab,arm64"

# Ejecuta el runner
./act_runner-linux-arm64 daemon

La sintaxis del workflow es idéntica a GitHub Actions con pequeñas diferencias en algunas actions de terceros que no están disponibles en Gitea. Para flujos simples de build-test-deploy, el 95% de la sintaxis funciona sin cambios.

Notificaciones cuando algo falla
#

Un pipeline que falla en silencio es peor que no tener pipeline. Tengo configurado que si un job falla, me llega una notificación a Telegram:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
      - name: Notificar fallo
        if: failure()
        run: |
          curl -s -X POST \
            "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
            -d chat_id="${TELEGRAM_CHAT_ID}" \
            -d text="Deploy fallido en ${{ github.repository }} - rama ${{ github.ref_name }}. Commit: ${{ github.sha }}"
        env:
          TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}

La condición if: failure() hace que este step solo corra si el workflow falló. Cuando algo se rompe me llega el mensaje al móvil con el repo, la rama y el hash del commit. Puedo entrar a GitHub a ver los logs directamente desde la notificación.

Cron jobs dentro de GitHub Actions
#

GitHub Actions también soporta triggers por cron. Esto me permite ejecutar tareas periódicas que necesitan correr en mi red local sin levantar servicios adicionales:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
on:
  schedule:
    - cron: '0 3 * * *'  # Cada día a las 3:00 AM

jobs:
  backup-check:
    runs-on: [self-hosted, homelab]
    steps:
      - name: Verificar backups recientes
        run: |
          # Comprueba que el backup de ayer existe y pesa lo esperado
          BACKUP_DATE=$(date -d yesterday +%Y-%m-%d)
          ls -la /mnt/backups/daily-${BACKUP_DATE}* || exit 1
          
      - name: Notificar éxito
        run: |
          echo "Backup del $BACKUP_DATE verificado correctamente"

Para tareas que no necesitan el contexto conversacional de mi agente principal, esto es más limpio que tener cron jobs en el sistema operativo: los logs están en GitHub, puedo ver historial, y el pipeline falla visiblemente si algo va mal.

Lo que no funciona bien
#

Para ser completo: GitHub Actions tiene limitaciones reales para el homelab.

Si GitHub tiene una interrupción, tus deploys se paran. Ha pasado. Para servicios críticos donde necesitas deploys incluso sin conexión a Internet, Gitea Actions es mejor opción.

Los self-hosted runners requieren mantenimiento. El runner necesita actualizarse periódicamente, el proceso puede colgarse, y si el servidor donde corre se apaga inesperadamente el runner queda offline sin avisar. Tengo un check básico que verifica que el runner está activo y me avisa si no lo está.

El acceso a secretos en repos públicos requiere cuidado. Nunca uses self-hosted runners en repos públicos de GitHub a menos que sepas exactamente lo que haces. Alguien podría hacer un fork, abrir un PR con código malicioso en el workflow, y ejecutaría en tu máquina con acceso a todos tus secretos. En repos privados esto no aplica.

Mi stack actual de pipelines
#

  • Mac mini como runner principal (siempre encendido, ARM64)
  • Nodo K3s worker como runner secundario para deploys al cluster
  • Gitea para proyectos completamente internos
  • GitHub para proyectos que tienen o pueden tener componentes públicos
  • Notificaciones a Telegram en todos los fallos
  • Workflows para: web scraper, scripts Python del homelab, deploys de containers, validación de manifests K3s

El tiempo que pasé configurando todo esto (un fin de semana largo) lo recuperé en el primer mes. La diferencia entre “push y olvídate” y “push, SSH, actualiza, verifica” se acumula.


Si tienes proyectos en tu homelab que actualizas más de una vez a la semana y lo haces a mano, self-hosted runners probablemente valen el esfuerzo. Si solo actualizas algo cada mes o dos, igual el setup no compensa tanto.