DevOps

Configura GitHub Actions para desplegar en tu servidor Linux

Por qué deployar con GitHub Actions a tu propio servidor

Durante años deployé mis proyectos con un rsync artesanal que vivía en una cron del servidor de staging. Funcionaba, claro, hasta el día que sube un cambio con un typo en una variable de entorno y el sitio quedó caído durante seis horas porque nadie se enteró hasta la mañana siguiente. Ese fue el momento en que empecé a usar GitHub Actions para push directo a mi servidor Linux.

La idea es simple: cuando haces git push a la rama principal, GitHub levanta una máquina virtual efímera, corre tus tests, y si todo pasa, abre una conexión SSH con tu servidor y descarga la nueva versión. Tú no tocas el servidor, no hay scripts a medio terminar, no hay contraseñas en archivos .env versionados. El flujo se vuelve reproducible y queda auditado en la pestaña Actions del repo, con cada deploy identificado por commit, autor y timestamp.

En este post te muestro el patrón completo: configurar SSH entre GitHub y tu servidor, escribir el workflow, manejar secretos sin filtrarlos, y resolver los tres problemas que casi siempre aparecen la primera vez (permisos del usuario, conexión al puerto 22 y claves SSH revocadas). El setup toma una hora la primera vez y cinco minutos en cualquier proyecto siguiente.

TL;DR — Lo que vas a tener al final

  • Tiempo de setup: 45-60 min la primera vez, 5 min por proyecto siguiente.
  • Requisitos: Un servidor Linux con SSH accesible desde Internet (o vía proxy), un repo en GitHub y un usuario no-root dedicado para el deploy.
  • Resultado: Cada git push a main ejecuta tests, construye la app si aplica, y la despliega atómicamente en tu servidor con rollback manual de un comando.
  • Troubleshooting: Errores frecuentes documentados al final: permisos SSH, secretos no detectados, rsync fallando por paths, y la trampa clásica de hacer git pull con el agente conectado al mismo directorio.

Antes de empezar: lo que necesitas

Tres cosas, ninguna opcional. La primera es un servidor Linux con un usuario dedicado al deploy, idealmente uno sin privilegios de sudo. Si usas root vas a publicar la llave privada del runner en cualquier rama y todo el mundo va a poder borrar tu base de datos. Crea un usuario específico, por ejemplo deploy, con home en /home/deploy y shell /bin/bash.

La segunda es acceso SSH desde los runners de GitHub a tu servidor. Los runners de GitHub salen desde un pool de IPs que rota, así que no puedes hacer firewall por IP. Lo que sí puedes: exigir clave pública (no contraseña) y limitar el usuario deploy a comandos específicos con authorized_keys con command="...", o un puerto SSH expuesto solo a través de Cloudflare Tunnel, WireGuard o Tailscale. Si tu servidor está detrás de un NAT, esta parte requiere un poquito de plomería adicional.

La tercera es un repo en GitHub con código que quieras deployar. El ejemplo de este post asume una app Node.js, pero el patrón sirve para cualquier cosa que viva en un directorio: sitios estáticos, binarios Go, contenedores Docker, configuraciones de infraestructura. La única diferencia es qué archivos sincronizas y qué comandos corres después del rsync.

Genera el par de claves SSH dedicado al deploy

El runner de GitHub necesita una clave privada que él controla y que tu servidor reconoce. Genera un par nuevo, separado de tu clave personal, con comentario claro para saber después de dónde viene.

# En tu máquina local, una sola vez
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy -N ""
# Esto crea ~/.ssh/github_deploy (privada) y ~/.ssh/github_deploy.pub (pública)

El parámetro -N "" deja la clave sin passphrase. Esto es importante: los runners de GitHub no pueden pedirte que escribas una passphrase interactiva, así que la clave tiene que estar descifrada. La mitigación es que la clave no tiene ningún privilegio en tu servidor más allá de lo que le des al usuario deploy.

Instala la clave pública en el servidor

Copia el contenido de ~/.ssh/github_deploy.pub al archivo authorized_keys del usuario deploy en tu servidor. El método más limpio es con ssh-copy-id, pero si tu servidor no tiene ese script, hazlo manual.

# Opción A: ssh-copy-id
ssh-copy-id -i ~/.ssh/github_deploy.pub deploy@tu-servidor
# Opción B: manual, en el servidor como usuario deploy
mkdir -p ~/.ssh
chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys << 'EOF'
ssh-ed25519 AAAA...long...key... github-actions-deploy
EOF
chmod 600 ~/.ssh/authorized_keys

Los permisos importan mucho: si ~/.ssh queda con 755 o authorized_keys con 644, SSH rechaza la conexión silenciosamente. Si el deploy falla con “Permission denied (publickey)” y la clave está bien copiada, casi siempre son permisos. Verifica la conexión manualmente antes de tocar el workflow.

# Test manual desde tu máquina local
ssh -i ~/.ssh/github_deploy -o StrictHostKeyChecking=no deploy@tu-servidor "whoami; hostname; uptime"
# Debe devolver: deploy, nombre-de-tu-servidor, hora actual

Si ese comando funciona, todo lo demás va a funcionar. Si falla, arregla SSH antes de meterte con GitHub Actions: ahorras horas de debug.

Configura los secretos en GitHub

La clave privada nunca toca el repo. Va a GitHub Secrets, que se inyecta al runner en tiempo de ejecución y queda fuera del log si configuras bien el mask-value del step. Ve a Settings → Secrets and variables → Actions en tu repo y crea tres secretos.

  • SSH_HOST: el hostname o IP de tu servidor (ej. deploy.example.com).
  • SSH_USER: el usuario SSH, normalmente deploy.
  • DEPLOY_SSH_KEY: el contenido completo de ~/.ssh/github_deploy, incluyendo -----BEGIN OPENSSH PRIVATE KEY----- y el cierre.

Para que el runner use esa clave tienes que escribirla a disco en cada job, así que el workflow lleva un paso previo que la materializa con los permisos correctos y la borra al final.

Escribe el workflow de deploy

Crea .github/workflows/deploy.yml en tu repo. El siguiente ejemplo es la versión que uso en mis proyectos personales: tests primero, deploy después, y un artefacto de release que queda en GitHub por si quieres volver atrás.

name: Deploy to production
on:
  push:
    branches: [main]
  workflow_dispatch:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test
  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment:
      name: production
      url: https://tu-dominio.com
    steps:
      - uses: actions/checkout@v4
      - name: Configurar clave SSH
        env:
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: |
          mkdir -p ~/.ssh
          echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H "${{ secrets.SSH_HOST }}" > ~/.ssh/known_hosts 2>/dev/null
      - name: Sincronizar archivos al servidor
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
        run: |
          rsync -avz --delete 
            -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" 
            ./build/ ${{ secrets.SSH_USER }}@$SSH_HOST:/var/www/mi-app/
      - name: Reiniciar servicio
        env:
          SSH_HOST: ${{ secrets.SSH_HOST }}
          SSH_USER: ${{ secrets.SSH_USER }}
        run: |
          ssh -i ~/.ssh/deploy_key $SSH_USER@$SSH_HOST 
            "sudo systemctl restart mi-app.service"
      - name: Limpiar clave
        if: always()
        run: rm -f ~/.ssh/deploy_key

El job test corre en cada push y bloquea el deploy con needs: test. El job deploy solo corre en main y se puede disparar manualmente desde la Actions tab con workflow_dispatch. El step final de limpieza borra la clave del runner aunque el job falle, así no queda persistida en el sistema de archivos efímero.

Hardening: limita lo que el usuario deploy puede hacer

El usuario deploy solo necesita tres cosas: leer su propio authorized_keys, escribir en /var/www/mi-app y ejecutar systemctl restart mi-app.service. El resto no le interesa. Configúralo en /etc/sudoers.d/deploy para que solo pueda correr ese systemctl específico con sudo, sin contraseña.

# En el servidor, como root
cat > /etc/sudoers.d/deploy << 'EOF'
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart mi-app.service
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl status mi-app.service
EOF
chmod 440 /etc/sudoers.d/deploy
# Validar sintaxis con visudo -c
visudo -c

Si tu app no usa systemd sino un proceso con pm2, screen o un script manual, ajusta el comando. La idea es la misma: el runner solo tiene permiso para la operación de restart, no para tocar nada más. Si alguien roba la clave privada, lo máximo que puede hacer es reiniciar tu app.

Self-hosted runner: cuándo vale la pena

GitHub te ofrece runners ubuntu-latest gratis con límites generosos para proyectos públicos y 2000 minutos al mes para privados. Si tu deploy necesita software específico que no está en el runner estándar (compiladores viejos, binarios propietarios, acceso a una base de datos interna) o quieres ahorrar minutos, puedes correr tu propio runner. Es un binario que instalas en tu servidor y que se registra con tu repo o tu organización.

El tradeoff: ahora tú operas ese runner, lo actualizas, lo vigilas, y si se cae, los deploys se quedan en cola. Para la mayoría de sysadmins con 1-5 servidores, los runners hosted de GitHub son suficientes y la fricción operativa no vale la pena. Reserva self-hosted para casos donde necesitas GPU, acceso a una red privada sin NAT traversal, o el job tarda más de 30 minutos.

Cómo revertir un deploy rápido

El rsync --delete del workflow borra archivos que ya no existen en la rama, así que un rollback no es un comando trivial. La forma más limpia es volver a la release anterior del workflow y re-dispararla manualmente.

  1. Ve a la pestaña Actions del repo y abre el workflow Deploy to production.
  2. Encuentra un run anterior que sabes que estaba bien. Copia el SHA del commit de ese run.
  3. Desde local: git checkout <sha-bueno> -- . y git commit -m "Rollback to <sha>" y push.
  4. El workflow corre el deploy con el código del commit bueno.

Si deployas binarios o imágenes Docker en vez de código fuente, etiqueta cada release con git tag y cambia el workflow para que deploye el tag específico en lugar del HEAD. Eso te da rollback de un comando: git checkout v1.4.2 y re-deploy.

Troubleshooting: los tres errores que vas a ver la primera vez

Permission denied (publickey). La clave pública no está bien instalada o los permisos están mal. Verifica con ssh -i ~/.ssh/github_deploy deploy@tu-servidor desde local; si funciona local, el problema es que el workflow no escribe bien la clave o el ssh-keyscan no se ejecutó.

Host key verification failed. El ssh-keyscan falla porque el puerto 22 no responde o devuelve un host key distinto al esperado. Añade set -e al step de ssh-keyscan para que el workflow falle ruidoso en vez de seguir con known_hosts vacío, lo que enmascara el problema y termina en el error anterior en el siguiente step.

rsync: connection unexpectedly closed. Casi siempre es el firewall del servidor bloqueando la conexión del runner de GitHub. Si tu servidor está detrás de un NAT estricto, configura Cloudflare Tunnel, Tailscale o un bastión SSH. Si está expuesto al Internet, revisa que el puerto 22 (o el que sea) esté abierto con ss -tlnp y que el usuario deploy no esté en /etc/ssh/sshd_config con AllowUsers restringido. Una traza rápida desde el runner te dice exactamente dónde se corta la conexión.

# Test de conectividad rápido desde un runner de GitHub
# Lo agregas como step temporal en el workflow para diagnosticar
- name: Debug conexión SSH
  run: |
    nc -zv "${{ secrets.SSH_HOST }}" 22 || echo "Puerto 22 cerrado"
    ssh -vvv -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "echo OK"
    -o ConnectTimeout=10

El -vvv imprime el intercambio de claves completo y normalmente te dice al primer vistazo qué falla: si es la negociación, si es la autenticación, o si el servidor cortó la conexión a mitad de camino.

Resumen

El patrón es siempre el mismo: clave SSH dedicada, secretos en GitHub, workflow con test antes de deploy, usuario sin privilegios en el servidor, sudo limitado a un comando, y cleanup de la clave al final. Una vez que lo tienes montado, copiarlo a otro repo son diez minutos y un nuevo par de secretos.

La próxima capa, si la necesitas, es deploy blue-green con dos directorios y un symlink que rota entre current y previous, o un workflow de promoción que deploya primero a staging, espera 5 minutos, y promueve a producción solo si no hay errores. Ambos patrones parten de la base que ya tienes.

Preguntas frecuentes

¿Es seguro subir la clave privada a GitHub Secrets?

Sí, con matices. GitHub Secrets cifra los valores en reposo y solo los expone a jobs del workflow autorizado. La clave sin passphrase es un riesgo aceptado: el atacante necesita acceso a tu repo Y a un runner con permiso de leer secretos. Limita el blast radius con sudo restringido y sin acceso a bases de datos desde el usuario deploy.

¿Necesito un self-hosted runner para deployar a mi servidor?

No. Los runners hosted de GitHub salen a Internet por IPs públicas y se conectan a tu servidor por SSH como cualquier cliente. Solo necesitas self-hosted si tu server está en una red privada sin inbound y no quieres abrir puertos, o si el build necesita software específico no disponible en ubuntu-latest.

¿Cómo hago deploy sin downtime con rsync –delete?

El truco es no hacer –delete sobre el directorio activo. Crea /var/www/mi-app/releases/<timestamp>, sincroniza ahí, y luego rota un symlink /var/www/mi-app/current al nuevo release. El proceso en ejecución (systemd) lee del symlink, así que el cambio es atómico y puedes volver atrás cambiando el symlink.

¿Puedo deployar sin sudo y reiniciar el servicio igual?

Sí, si tu app se reinicia sola al cambiar archivos (modo watch de Node, autoreload de Flask, recarga de nginx con inotifywait) o si usas un process manager como pm2 que el usuario deploy puede controlar. El sudo solo es necesario si quieres reiniciar systemd units, lo cual es la forma más limpia para apps de larga duración.

¿Qué pasa si el deploy falla a mitad de camino?

El rsync queda parcialmente escrito en el servidor, pero si usas el patrón de releases con symlink, el directorio activo nunca queda inconsistente: o se rota completo o no se rota. Si deployas sobre el directorio activo, la app puede quedar en estado roto hasta el próximo deploy bueno. Por eso el patrón release + symlink es superior.

Lee también

Alfonso Moreno | Sysadmin Senior

Soy Alfonso, sysadmin senior con RHCE y más de 10 años administrando sistemas Linux para empresas y proyectos personales. Me especializo en infraestructura, redes, virtualización, monitoring, automation y self-hosting. Sobre SoyLinux.com SoyLinux.com es un blog técnico en español sobre Linux, software libre y self-hosting. Lo mantengo en mi tiempo libre. Áreas de expertise - Linux server administration: RHEL, CentOS, Rocky, Alma, Fedora, Debian, Ubuntu - Network services: Postfix, BIND, Samba, NFS, Squid - Virtualización y containers: KVM, Proxmox, Docker, Podman - Monitoring: Prometheus, Grafana, Zabbix - Automation: Ansible, Bash scripting - Self-hosting: Vaultwarden, Nextcloud, Jellyfin, Immich, Homelab Contacto - Email: [email protected]

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *