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
giten tu equipo local. - Un repositorio en GitHub con una aplicación Node.js que tenga un
package.jsoncon los scriptsstartytest, y supackage-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
Importanteel comando del flujo debe coincidir exactamente con la regla, incluido el sufijo
.service.sudo systemctl restart myappsin sufijo pediría contraseña y el despliegue fallaría.
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:
| Nombre | Valor |
|---|---|
SSH_HOST | La IP o el nombre de tu servidor (your_server_ip) |
SSH_USER | deploy |
SSH_PRIVATE_KEY | El contenido completo de myapp_deploy_key, incluidas las líneas BEGIN y END |
SSH_KNOWN_HOSTS | La 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 cadapushamainy 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 dospushseguidos, el segundo despliegue espera a que termine el primero en lugar de ejecutarse a la vez.testydeployson dos trabajos.needs: testhace que el despliegue solo se ejecute si las pruebas pasan.environment: productionda acceso a los secretos de ese entorno y aplica sus reglas de protección.rsync --deletedeja en el servidor una copia exacta del repositorio. Las exclusiones evitan subirnode_modulesy, sobre todo, que se borre el.envque solo existe en el servidor.- En el servidor,
npm ci --omit=devinstala las dependencias de producción a partir depackage-lock.jsony después se reinicia el servicio con el comando exacto permitido en sudoers.
Notalos secretos se pasan a los comandos mediante
enven lugar de escribir${{ secrets.X }}dentro del script. Así su valor no se interpreta como código de shell si contiene caracteres especiales.
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.