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.json y package-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:

CampoValor
Payload URLhttps://deploy.your_domain/hooks/deploy-myapp
Content typeapplication/json
SecretEl valor de your_webhook_secret
Which eventsJust 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.