GitHub Actions es la plataforma de integración y despliegue continuo integrada en GitHub: cada push puede lanzar un flujo de trabajo que compila, prueba y publica tu código. En este tutorial crearás un flujo que ejecuta las pruebas de una aplicación Node.js y, si pasan, la copia por SSH con rsync a un servidor Ubuntu 24.04 y reinicia su servicio systemd. El servidor tendrá un usuario de despliegue dedicado, sin contraseña y con permiso sudo solo para reiniciar ese servicio.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS accesible por SSH, por ejemplo un VPS de CubePath, y un usuario no root con privilegios sudo.
  • Node.js instalado en el servidor (la versión que use tu aplicación) y git en tu equipo local.
  • Un repositorio en GitHub con una aplicación Node.js que tenga un package.json con los scripts start y test, y su package-lock.json.
  • Permisos de administración en el repositorio para crear secretos y entornos.

A lo largo de la guía, sustituye your_server_ip por la IP pública de tu servidor y myapp por el nombre de tu aplicación.

Paso 1: Crear el usuario de despliegue en el servidor

Desplegar con tu usuario personal o con root da al flujo de trabajo más permisos de los que necesita. Crea un usuario deploy sin contraseña, que solo podrá entrar con la clave SSH que añadirás después:

sudo useradd --create-home --shell /bin/bash deploy

Crea el directorio de la aplicación y asígnalo a ese usuario:

sudo mkdir -p /srv/myapp
sudo chown deploy:deploy /srv/myapp

Asegúrate de que rsync está instalado, porque el flujo lo usará en ambos extremos de la conexión:

sudo apt update
sudo apt install rsync

Comprueba que el usuario existe:

id deploy
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)

Paso 2: Crear el servicio systemd de la aplicación

El flujo reiniciará la aplicación después de copiar el código nuevo, así que la aplicación debe ejecutarse como servicio systemd. Localiza primero la ruta del binario de Node.js:

which node
/usr/bin/node

Crea la unidad del servicio:

sudo nano /etc/systemd/system/myapp.service

Añade este contenido, ajustando ExecStart a la ruta que te devolvió which node y al comando de arranque de tu aplicación:

[Unit]
Description=myapp Node.js application
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/srv/myapp
Environment=NODE_ENV=production
EnvironmentFile=-/srv/myapp/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

El guion delante de EnvironmentFile indica que el archivo .env es opcional. Recarga systemd y habilita el servicio para que arranque con el sistema. Todavía no lo inicies: el directorio está vacío hasta el primer despliegue.

sudo systemctl daemon-reload
sudo systemctl enable myapp.service

Paso 3: Permitir al usuario deploy reiniciar solo ese servicio

El usuario deploy necesita reiniciar myapp.service, pero no debe tener sudo completo. Crea una regla de sudoers limitada a ese único comando:

sudo visudo -f /etc/sudoers.d/deploy-myapp

Añade esta línea:

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service

Valida la sintaxis del archivo:

sudo visudo -cf /etc/sudoers.d/deploy-myapp
/etc/sudoers.d/deploy-myapp: parsed OK

Comprueba qué puede ejecutar el usuario:

sudo -l -U deploy
User deploy may run the following commands on server:
    (root) NOPASSWD: /usr/bin/systemctl restart myapp.service

Paso 4: Generar la clave SSH de despliegue

Genera en tu equipo local un par de claves exclusivo para GitHub Actions. No reutilices tu clave personal: si se filtra, solo tendrás que revocar esta. La opción -N "" crea la clave sin frase de paso, porque el flujo no puede escribirla de forma interactiva:

ssh-keygen -t ed25519 -C "github-actions-myapp" -N "" -f ./myapp_deploy_key

Se crean dos archivos: myapp_deploy_key (privada) y myapp_deploy_key.pub (pública). Instala la clave pública en el usuario deploy del servidor. Conéctate al servidor con tu usuario habitual y ejecuta:

sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys

Pega en el archivo el contenido de myapp_deploy_key.pub (una sola línea que empieza por ssh-ed25519) y ajusta los permisos:

sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys

Desde tu equipo local, comprueba que la clave funciona:

ssh -i ./myapp_deploy_key deploy@your_server_ip 'whoami'
deploy

Paso 5: Obtener la huella del servidor

El flujo debe verificar que se conecta a tu servidor y no a otro. En lugar de ejecutar ssh-keyscan dentro del flujo en cada despliegue (que aceptaría cualquier clave que responda), guarda la clave de host una vez como secreto. Desde tu equipo local:

ssh-keyscan -t ed25519 your_server_ip
your_server_ip ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...

Antes de confiar en esa línea, compárala con la huella real. En el servidor:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

Y en tu equipo local:

ssh-keyscan -t ed25519 your_server_ip 2>/dev/null | ssh-keygen -lf -

Las dos huellas SHA256:... deben ser idénticas. Guarda la línea completa que devolvió ssh-keyscan para el siguiente paso.

Paso 6: Guardar los secretos en GitHub

GitHub cifra los secretos y los oculta en los registros de ejecución. Usarás un entorno llamado production, que agrupa los secretos del servidor y permite añadir reglas de protección, como exigir una aprobación antes de desplegar.

En el repositorio, ve a Settings > Environments > New environment, llámalo production y, dentro de él, en Environment secrets, crea estos secretos:

NombreValor
SSH_HOSTLa IP o el nombre de tu servidor (your_server_ip)
SSH_USERdeploy
SSH_PRIVATE_KEYEl contenido completo de myapp_deploy_key, incluidas las líneas BEGIN y END
SSH_KNOWN_HOSTSLa línea que devolvió ssh-keyscan en el paso anterior

Si tienes instalada la CLI de GitHub (gh), puedes crearlos desde el directorio del repositorio:

gh secret set SSH_PRIVATE_KEY --env production < ./myapp_deploy_key
gh secret set SSH_KNOWN_HOSTS --env production --body "$(ssh-keyscan -t ed25519 your_server_ip 2>/dev/null)"
gh secret set SSH_HOST --env production --body "your_server_ip"
gh secret set SSH_USER --env production --body "deploy"

Cuando los secretos estén guardados, borra la clave privada de tu equipo local:

rm ./myapp_deploy_key

Paso 7: Crear el flujo de trabajo

Los flujos de GitHub Actions son archivos YAML dentro de .github/workflows/. En la raíz de tu repositorio local, crea el directorio y el archivo:

mkdir -p .github/workflows
nano .github/workflows/deploy.yml

Añade este contenido:

name: Test and deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v5

      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts

      - name: Copy files
        env:
          SSH_TARGET: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
        run: |
          rsync -az --delete \
            --exclude='.git/' \
            --exclude='.github/' \
            --exclude='node_modules/' \
            --exclude='.env' \
            ./ "$SSH_TARGET:/srv/myapp/"

      - name: Install dependencies and restart
        env:
          SSH_TARGET: ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
        run: |
          ssh "$SSH_TARGET" \
            'cd /srv/myapp && npm ci --omit=dev && sudo systemctl restart myapp.service'

Qué hace cada parte:

  • on: el flujo se ejecuta con cada push a main y también de forma manual desde la pestaña Actions (workflow_dispatch).
  • permissions: el token automático del flujo solo puede leer el repositorio.
  • concurrency: si haces dos push seguidos, el segundo despliegue espera a que termine el primero en lugar de ejecutarse a la vez.
  • test y deploy son dos trabajos. needs: test hace que el despliegue solo se ejecute si las pruebas pasan.
  • environment: production da acceso a los secretos de ese entorno y aplica sus reglas de protección.
  • rsync --delete deja en el servidor una copia exacta del repositorio. Las exclusiones evitan subir node_modules y, sobre todo, que se borre el .env que solo existe en el servidor.
  • En el servidor, npm ci --omit=dev instala las dependencias de producción a partir de package-lock.json y después se reinicia el servicio con el comando exacto permitido en sudoers.

Paso 8: Lanzar el primer despliegue

Confirma el flujo y súbelo a main:

git add .github/workflows/deploy.yml
git commit -m "Add test and deploy workflow"
git push origin main

En GitHub, abre la pestaña Actions del repositorio. Verás la ejecución Test and deploy con los trabajos test y deploy. Si tienes la CLI gh, puedes seguirla desde la terminal:

gh run watch

Cuando ambos trabajos terminen en verde, comprueba el servicio en el servidor:

sudo systemctl status myapp.service
● myapp.service - myapp Node.js application
     Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-24 10:15:02 UTC; 12s ago
   Main PID: 4182 (node)

Revisa también los registros de la aplicación:

sudo journalctl -u myapp.service -n 20 --no-pager

A partir de ahora, cada push a main que pase las pruebas se desplegará automáticamente.

Paso 9: Exigir aprobación antes de desplegar (opcional)

Si quieres que alguien revise cada despliegue a producción, ve a Settings > Environments > production y activa Required reviewers, añadiendo las personas o equipos que pueden aprobar. El trabajo deploy quedará en espera con el estado Waiting hasta que uno de ellos lo apruebe desde la ejecución. En el mismo apartado, Deployment branches and tags permite limitar el entorno a la rama main, de modo que un flujo lanzado desde otra rama no pueda leer los secretos de producción.

Solución de problemas

Host key verification failed: el secreto SSH_KNOWN_HOSTS no coincide con la clave del servidor o SSH_HOST no es el mismo nombre o IP que usaste con ssh-keyscan. Genera de nuevo la línea con el valor exacto de SSH_HOST.

Permission denied (publickey): la clave pública no está en /home/deploy/.ssh/authorized_keys, los permisos son demasiado abiertos o el secreto SSH_PRIVATE_KEY está incompleto. Revisa en el servidor sudo journalctl -u ssh -n 20 para ver el motivo del rechazo.

sudo: a terminal is required to read the password: el comando de reinicio del flujo no coincide exactamente con la regla de /etc/sudoers.d/deploy-myapp. Comprueba la ruta /usr/bin/systemctl y el sufijo .service.

npm: command not found en el paso de reinicio: la sesión SSH no interactiva no encuentra Node.js. Pasa si lo instalaste con nvm en el directorio de otro usuario; instálalo desde el repositorio de NodeSource o desde los paquetes de Ubuntu para que quede en /usr/bin.

Conclusión

Tienes un flujo de GitHub Actions que prueba cada cambio en main y lo despliega por SSH en tu servidor Ubuntu 24.04, con un usuario dedicado, la clave de host verificada y sudo limitado a reiniciar un único servicio. Como siguientes pasos puedes añadir un entorno staging con su propio servidor y secretos, publicar la aplicación detrás de Nginx con HTTPS, o sustituir la copia de archivos por imágenes de Docker publicadas en GitHub Container Registry.