No siempre hace falta una plataforma de CI/CD completa para desplegar: para una aplicación en un único servidor basta con que GitHub avise al servidor en cada push y que este ejecute un script que actualiza el código y reinicia el servicio. En este tutorial instalarás webhook, una pequeña herramienta que recibe peticiones HTTP y ejecuta comandos, en Ubuntu 24.04. La configurarás para aceptar solo peticiones firmadas por GitHub desde la rama main, la publicarás detrás de Nginx con HTTPS y escribirás un script de despliegue que evita ejecuciones simultáneas y revierte el cambio si la aplicación no arranca.
Requisitos previos
Para seguir esta guía necesitas:
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, y un usuario no root con privilegios
sudo. - Una aplicación Node.js en un repositorio de GitHub, con
package.jsonypackage-lock.json, y Node.js instalado en el servidor en/usr/bin(paquetes de Ubuntu o NodeSource). - Un dominio o subdominio, como
deploy.your_domain, con un registro DNS A que apunte al servidor. - Nginx instalado y los puertos 80 y 443 abiertos en el cortafuegos.
El ejemplo usa una aplicación Node.js que se ejecuta como el servicio systemd myapp.service, pero el mismo esquema sirve para cualquier lenguaje: solo cambian los comandos de compilación del script.
Paso 1: Preparar el usuario y el código en el servidor
Todo el despliegue se ejecutará con un usuario sin privilegios llamado deploy, dueño del código de la aplicación. Créalo junto con el directorio de la aplicación:
sudo useradd --create-home --shell /bin/bash deploy
sudo mkdir -p /srv/myapp
sudo chown deploy:deploy /srv/myapp
El servidor necesita leer el repositorio. Genera una clave SSH para el usuario deploy:
sudo -u deploy install -d -m 700 /home/deploy/.ssh
sudo -u deploy ssh-keygen -t ed25519 -N "" -C "deploy@myapp-server" -f /home/deploy/.ssh/id_ed25519
sudo cat /home/deploy/.ssh/id_ed25519.pub
Copia la clave pública y añádela en GitHub como deploy key del repositorio: Settings > Deploy keys > Add deploy key, sin marcar Allow write access. Una deploy key solo da acceso a ese repositorio, y en modo lectura.
Clona el repositorio como deploy:
sudo -u deploy git clone [email protected]:your_user/myapp.git /srv/myapp
La primera vez, SSH te pedirá confirmar la huella de github.com. Compárala con las huellas publicadas en la documentación de GitHub ("GitHub's SSH key fingerprints") antes de escribir yes. Comprueba que el clon funciona:
sudo -u deploy git -C /srv/myapp log --oneline -1
a1b2c3d Initial commit
Paso 2: Crear el servicio de la aplicación
Crea la unidad systemd de la aplicación:
sudo nano /etc/systemd/system/myapp.service
Añade este contenido, ajustando ExecStart al punto de entrada 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
Instala las dependencias y arranca el servicio:
sudo -u deploy bash -c 'cd /srv/myapp && npm ci --omit=dev'
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
Comprueba que está activo:
systemctl is-active myapp.service
active
El usuario deploy tiene que poder reiniciar este servicio, y nada más. Crea una regla de sudoers limitada a ese 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 el archivo:
sudo visudo -cf /etc/sudoers.d/deploy-myapp
/etc/sudoers.d/deploy-myapp: parsed OK
Paso 3: Escribir el script de despliegue
El script hace el trabajo real: trae el último commit de main, instala dependencias y reinicia el servicio. Además, usa flock para que dos push seguidos no desplieguen a la vez (el segundo espera a que termine el primero) y, si el servicio no queda activo tras el reinicio, vuelve al commit anterior.
Crea el script:
sudo nano /usr/local/bin/deploy-myapp.sh
Añade este contenido:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/srv/myapp"
BRANCH="main"
SERVICE="myapp.service"
LOCK_FILE="/run/lock/deploy-myapp.lock"
# Only one deployment at a time; later ones wait here.
exec 9>"$LOCK_FILE"
flock 9
cd "$APP_DIR"
previous="$(git rev-parse HEAD)"
git fetch --prune origin "$BRANCH"
git reset --hard "origin/$BRANCH"
current="$(git rev-parse HEAD)"
if [[ "$previous" == "$current" ]]; then
echo "Already at ${current:0:8}, nothing to deploy"
exit 0
fi
echo "Deploying ${previous:0:8} -> ${current:0:8}"
npm ci --omit=dev
sudo /usr/bin/systemctl restart "$SERVICE"
sleep 5
if ! systemctl is-active --quiet "$SERVICE"; then
echo "$SERVICE is not active after deploy, rolling back to ${previous:0:8}" >&2
git reset --hard "$previous"
npm ci --omit=dev
sudo /usr/bin/systemctl restart "$SERVICE"
exit 1
fi
echo "Deployed ${current:0:8}"
Hazlo ejecutable:
sudo chmod 755 /usr/local/bin/deploy-myapp.sh
git reset --hard descarta cualquier cambio hecho a mano en los archivos del repositorio, pero no toca los archivos sin seguimiento, como un .env incluido en .gitignore. Ejecuta el script una vez a mano para comprobarlo:
sudo -u deploy /usr/local/bin/deploy-myapp.sh
Already at a1b2c3d4, nothing to deploy
Paso 4: Instalar y configurar webhook
La herramienta webhook está en los repositorios de Ubuntu:
sudo apt update
sudo apt install webhook
Genera un secreto aleatorio. GitHub lo usará para firmar cada petición y webhook para comprobar la firma:
openssl rand -hex 32
Guarda el valor: lo necesitarás aquí y en GitHub. Crea el archivo de definición de los hooks:
sudo mkdir -p /etc/webhook
sudo nano /etc/webhook/hooks.json
Añade este contenido, sustituyendo your_webhook_secret por el valor generado:
[
{
"id": "deploy-myapp",
"execute-command": "/usr/local/bin/deploy-myapp.sh",
"command-working-directory": "/srv/myapp",
"response-message": "Deployment started",
"trigger-rule-mismatch-http-response-code": 403,
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hmac-sha256",
"secret": "your_webhook_secret",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]
El hook deploy-myapp solo ejecuta el script si se cumplen las dos reglas: la firma HMAC SHA-256 de la cabecera X-Hub-Signature-256 es válida para el secreto, y el campo ref del evento es la rama main. Cualquier otra petición recibe un 403. webhook responde de inmediato y ejecuta el script en segundo plano, así que GitHub no espera a que termine el despliegue.
El archivo contiene el secreto, así que solo root y el grupo deploy deben poder leerlo:
sudo chown root:deploy /etc/webhook/hooks.json
sudo chmod 640 /etc/webhook/hooks.json
Paso 5: Ejecutar webhook como servicio
Crea una unidad systemd propia para que webhook se ejecute como deploy y escuche solo en 127.0.0.1, ya que las peticiones de Internet le llegarán a través de Nginx:
sudo nano /etc/systemd/system/deploy-webhook.service
Añade este contenido:
[Unit]
Description=Webhook listener for myapp deployments
After=network-online.target
Wants=network-online.target
[Service]
User=deploy
Group=deploy
ExecStart=/usr/bin/webhook -hooks /etc/webhook/hooks.json -ip 127.0.0.1 -port 9000 -verbose
Restart=on-failure
[Install]
WantedBy=multi-user.target
Habilita e inicia el servicio:
sudo systemctl daemon-reload
sudo systemctl enable --now deploy-webhook.service
Comprueba en el registro que ha cargado el hook deploy-myapp y que escucha en 127.0.0.1:9000:
sudo journalctl -u deploy-webhook.service -n 20 --no-pager
Prueba localmente que una petición sin firma se rechaza:
curl -i -X POST http://127.0.0.1:9000/hooks/deploy-myapp \
-H "Content-Type: application/json" -d '{"ref":"refs/heads/main"}'
HTTP/1.1 403 Forbidden
...
Hook rules were not satisfied.
Paso 6: Publicar el webhook con Nginx y HTTPS
GitHub necesita alcanzar el webhook por HTTPS. Crea un bloque de servidor en Nginx que reenvíe solo la ruta /hooks/:
sudo nano /etc/nginx/sites-available/deploy-webhook
Añade esta configuración:
server {
listen 80;
listen [::]:80;
server_name deploy.your_domain;
location /hooks/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
return 404;
}
}
Activa el sitio, valida la configuración y recarga Nginx:
sudo ln -s /etc/nginx/sites-available/deploy-webhook /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Instala Certbot y obtén el certificado. Certbot añadirá HTTPS y la redirección desde HTTP al bloque de servidor:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d deploy.your_domain
Prueba una petición firmada correctamente desde cualquier equipo. openssl calcula la misma firma que enviaría GitHub:
payload='{"ref":"refs/heads/main"}'
signature=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac 'your_webhook_secret' | awk '{print $2}')
curl -i -X POST https://deploy.your_domain/hooks/deploy-myapp \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$signature" \
-d "$payload"
HTTP/2 200
...
Deployment started
En el servidor, el registro de deploy-webhook mostrará la ejecución del script y su salida.
Paso 7: Configurar el webhook en GitHub
En el repositorio de GitHub, ve a Settings > Webhooks > Add webhook y rellena el formulario:
| Campo | Valor |
|---|---|
| Payload URL | https://deploy.your_domain/hooks/deploy-myapp |
| Content type | application/json |
| Secret | El valor de your_webhook_secret |
| Which events | Just the push event |
Al guardar, GitHub envía un evento ping. Ese evento no tiene el campo ref, así que webhook responde 403 y GitHub lo marca como fallido: es el comportamiento esperado. Lo importante es la siguiente entrega.
Haz un cambio en la aplicación y súbelo a main:
git commit -am "Test automatic deployment"
git push origin main
En Settings > Webhooks, la entrega del evento push debe aparecer con respuesta 200. En el servidor, sigue el despliegue en tiempo real:
sudo journalctl -u deploy-webhook.service -f
Verás que el hook deploy-myapp se activa, que se ejecuta /usr/local/bin/deploy-myapp.sh y, al terminar, la salida del script con las líneas Deploying ... y Deployed ....
Confirma que el servidor está en el nuevo commit:
sudo -u deploy git -C /srv/myapp log --oneline -1
Usar GitLab en lugar de GitHub
GitLab no firma el cuerpo de la petición: envía el secreto tal cual en la cabecera X-Gitlab-Token. Si tu repositorio está en GitLab, sustituye la primera regla de hooks.json por esta y configura el webhook en Settings > Webhooks con el mismo secreto en el campo Secret token:
{
"match": {
"type": "value",
"value": "your_webhook_secret",
"parameter": {
"source": "header",
"name": "X-Gitlab-Token"
}
}
}
Reinicia el servicio después de cambiar el archivo con sudo systemctl restart deploy-webhook.service.
Solución de problemas
GitHub muestra 403 en los eventos push: la firma no coincide. Comprueba que el secreto de GitHub y el de hooks.json son idénticos y que el Content type es application/json. Si el push es a otra rama, el 403 es correcto.
El script falla con Permission denied (publickey) en git fetch: la deploy key no está añadida al repositorio o el servicio no se ejecuta como deploy. Pruébalo con sudo -u deploy git -C /srv/myapp fetch.
sudo: a terminal is required to read the password: el comando del script no coincide exactamente con la regla de /etc/sudoers.d/deploy-myapp. Debe ser /usr/bin/systemctl restart myapp.service.
El despliegue se revierte siempre: la aplicación no arranca con el código nuevo. Revisa el motivo con sudo journalctl -u myapp.service -n 50 --no-pager.
Conclusión
Tienes un despliegue continuo ligero en Ubuntu 24.04: GitHub avisa al servidor en cada push a main, webhook comprueba la firma y lanza un script que despliega un único commit a la vez y vuelve atrás si la aplicación no arranca. Como siguientes pasos puedes añadir al script la ejecución de migraciones de base de datos, enviar una notificación al terminar, o pasar a GitHub Actions cuando necesites ejecutar pruebas antes de desplegar.