GitLab CI/CD ejecuta automáticamente las tareas que definas en un archivo .gitlab-ci.yml cada vez que haces push a tu repositorio. En este tutorial construirás un pipeline completo para una aplicación Node.js: ejecutará las pruebas, generará la versión de producción y la desplegará por SSH en un servidor Ubuntu 24.04, solo desde la rama principal. Por el camino verás cómo funcionan las etapas, la caché, los artefactos, las variables protegidas y los runners.

Requisitos previos

Para seguir esta guía necesitas:

  • Un proyecto en GitLab (GitLab.com o una instancia propia) con una aplicación que tenga package.json, package-lock.json y los scripts test y build. El ejemplo supone que npm run build genera la carpeta dist/.
  • Un servidor de destino con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con Nginx sirviendo el sitio desde /var/www/tu_dominio y un usuario con sudo.
  • Un runner para ejecutar los trabajos. En GitLab.com puedes usar los runners de instancia incluidos; si prefieres uno propio, el paso 1 explica cómo instalarlo. Para el runner propio necesitas otro servidor Ubuntu 24.04 con Docker Engine instalado.

Conceptos básicos

Antes de escribir el archivo, conviene tener claros cuatro términos:

ConceptoQué es
PipelineEl conjunto de trabajos que se ejecuta con cada push, merge request o programación.
Etapa (stage)Un grupo de trabajos. Las etapas se ejecutan en orden; los trabajos de una misma etapa, en paralelo.
Trabajo (job)Una tarea con un script, que se ejecuta dentro de un contenedor limpio en un runner.
RunnerEl proceso (gitlab-runner) que recoge los trabajos y los ejecuta.

Como cada trabajo empieza en un entorno limpio, los archivos que un trabajo necesita de otro se pasan como artefactos, y las dependencias que se descargan en cada ejecución se reutilizan con la caché.

Paso 1: Registrar un runner propio (opcional)

Si usas los runners de instancia de GitLab.com, salta al paso 2. Un runner propio te da más control sobre los recursos y no consume minutos de cómputo.

En el servidor del runner, descarga el script oficial que añade el repositorio de GitLab Runner, revísalo y ejecútalo:

curl -fsSL -o script.deb.sh https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh
less script.deb.sh
sudo bash script.deb.sh

Instala el runner:

sudo apt install gitlab-runner

En GitLab, abre tu proyecto y ve a Settings > CI/CD > Runners. Pulsa New project runner, marca Run untagged jobs, crea el runner y copia el token de autenticación, que empieza por glrt-.

Registra el runner con el ejecutor Docker, sustituyendo tu_token (y la URL si usas una instancia propia):

sudo gitlab-runner register --non-interactive --url "https://gitlab.com" --token "tu_token" --executor "docker" --docker-image "alpine:latest"

Comprueba que el runner está registrado y conecta con GitLab:

sudo gitlab-runner verify
Runtime platform                                    arch=amd64 os=linux pid=5123 revision=... version=18.4.0
Running in system-mode.

Verifying runner... is valid                        runner=AbCdEf12

En Settings > CI/CD > Runners, el runner aparece ahora con un círculo verde. Si no quieres que el proyecto use también los runners de instancia, desactívalos en esa misma página.

Paso 2: Preparar el servidor de destino

El pipeline se conectará al servidor por SSH con un usuario sin privilegios que solo puede escribir en el directorio del sitio. En el servidor de destino, crea el usuario deploy y dale la propiedad del directorio:

sudo adduser --disabled-password --gecos "" deploy
sudo chown -R deploy:deploy /var/www/tu_dominio

En tu equipo local, genera un par de claves exclusivo para el despliegue, sin frase de paso porque lo usará un proceso automático:

ssh-keygen -t ed25519 -f ~/.ssh/gitlab_deploy -N "" -C "gitlab-ci-deploy"

Instala la clave pública en el usuario deploy del servidor. Copia el contenido de ~/.ssh/gitlab_deploy.pub y, en el servidor, ejecuta:

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

Pega la clave pública, guarda y ajusta los permisos:

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

Comprueba desde tu equipo que puedes entrar con esa clave:

ssh -i ~/.ssh/gitlab_deploy deploy@ip_de_tu_servidor 'whoami'
deploy

Por último, obtén la huella del servidor para que el pipeline pueda verificar que se conecta al equipo correcto:

ssh-keyscan -t ed25519 ip_de_tu_servidor
ip_de_tu_servidor ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH...

Paso 3: Definir las variables de CI/CD

Los secretos nunca deben ir en .gitlab-ci.yml, porque queda en el historial del repositorio. En GitLab, ve a Settings > CI/CD > Variables y pulsa Add variable para cada una de estas:

ClaveTipoValorOpciones
SSH_PRIVATE_KEYFileContenido de ~/.ssh/gitlab_deploy (con una línea en blanco al final)Protected
SSH_KNOWN_HOSTSFileLa línea que devolvió ssh-keyscanProtected
DEPLOY_HOSTVariableip_de_tu_servidorProtected
DEPLOY_USERVariabledeployProtected

El tipo File hace que GitLab escriba el valor en un archivo temporal y ponga su ruta en la variable, que es justo lo que necesitan ssh y rsync. La opción Protected hace que la variable solo esté disponible en ramas y etiquetas protegidas, así que una rama cualquiera no puede leer la clave. La rama principal está protegida por defecto.

Paso 4: Escribir el archivo .gitlab-ci.yml

Crea el archivo .gitlab-ci.yml en la raíz del repositorio:

stages:
  - test
  - build
  - deploy

default:
  image: node:22-alpine

variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"

cache:
  key:
    files:
      - package-lock.json
  paths:
    - .npm/

test:
  stage: test
  script:
    - npm ci --prefer-offline
    - npm test

build:
  stage: build
  script:
    - npm ci --prefer-offline
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 week

deploy:
  stage: deploy
  image: alpine:3.22
  needs:
    - build
  cache: []
  environment:
    name: production
    url: https://tu_dominio
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  before_script:
    - apk add --no-cache openssh-client rsync
    - chmod 600 "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - cp "$SSH_KNOWN_HOSTS" ~/.ssh/known_hosts
  script:
    - rsync -az --delete -e "ssh -i $SSH_PRIVATE_KEY" dist/ "$DEPLOY_USER@$DEPLOY_HOST:/var/www/tu_dominio/"

Qué hace cada parte:

  • stages: define el orden. Si falla un trabajo de test, no se ejecutan build ni deploy.
  • default.image: todos los trabajos se ejecutan en un contenedor node:22-alpine, salvo que indiquen otra imagen.
  • cache: guarda la caché de npm (.npm/) entre ejecuciones. La clave depende de package-lock.json, así que la caché se invalida sola cuando cambian las dependencias. Se usa npm ci, que instala exactamente lo que dice el lockfile.
  • artifacts: el trabajo build guarda dist/ en GitLab; el trabajo deploy lo descarga automáticamente porque lo declara en needs. Los artefactos caducan a la semana para no ocupar espacio indefinidamente.
  • rules: el despliegue solo se crea en pipelines de la rama por defecto (normalmente main). En otras ramas y merge requests se ejecutan solo las pruebas y la compilación.
  • environment: registra cada despliegue en Operate > Environments, con el historial y un botón para volver a desplegar una versión anterior.
  • deploy: usa una imagen Alpine mínima, instala el cliente SSH y rsync, y sincroniza dist/ con el servidor. --delete elimina del servidor los archivos que ya no existen en la compilación.

Paso 5: Validar la configuración

Antes de hacer push, valida la sintaxis. En GitLab, ve a Build > Pipeline editor: el editor comprueba el archivo mientras escribes y muestra Pipeline syntax is correct cuando es válido. La pestaña Visualize dibuja las etapas y las dependencias entre trabajos, y Validate simula un pipeline en la rama que elijas para ver qué trabajos se crearían.

Los errores más habituales son la indentación (YAML usa espacios, nunca tabuladores) y las referencias a etapas que no están declaradas en stages.

Paso 6: Ejecutar el pipeline

Añade el archivo al repositorio y súbelo a la rama principal:

git add .gitlab-ci.yml
git commit -m "Add CI/CD pipeline"
git push origin main

En Build > Pipelines aparecerá un pipeline nuevo con las tres etapas. Pulsa sobre cualquier trabajo para ver su registro en tiempo real. El trabajo deploy debería terminar así:

$ rsync -az --delete -e "ssh -i $SSH_PRIVATE_KEY" dist/ "$DEPLOY_USER@$DEPLOY_HOST:/var/www/tu_dominio/"
Cleaning up project directory and file based variables
Job succeeded

Comprueba en el servidor que los archivos se han actualizado:

ls -l --time-style=long-iso /var/www/tu_dominio | head

Y desde tu equipo, que el sitio responde:

curl -I https://tu_dominio
HTTP/2 200
server: nginx/1.24.0 (Ubuntu)

Para ver el comportamiento en otra rama, crea una rama nueva, haz un cambio y súbela: el pipeline tendrá solo los trabajos test y build.

Paso 7: Ampliar el pipeline

Con esta base, las ampliaciones más útiles son pequeñas:

Informes de pruebas en los merge requests. Si tu herramienta de pruebas genera un informe JUnit (por ejemplo jest-junit escribe junit.xml), decláralo como artefacto de informe en el trabajo test y GitLab mostrará las pruebas fallidas en el propio merge request:

test:
  stage: test
  script:
    - npm ci --prefer-offline
    - npm test
  artifacts:
    when: always
    reports:
      junit: junit.xml

Despliegue manual a producción. Si prefieres que alguien apruebe cada despliegue, añade when: manual a la regla. El pipeline se detendrá en deploy hasta que alguien pulse el botón de ejecutar:

  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

Pruebas en paralelo con varias versiones. parallel:matrix ejecuta el mismo trabajo con distintos valores de una variable:

test:
  stage: test
  image: node:${NODE_VERSION}-alpine
  parallel:
    matrix:
      - NODE_VERSION: ["20", "22"]
  script:
    - npm ci --prefer-offline
    - npm test

Solución de problemas

El trabajo se queda en pending con el mensaje "This job is stuck": no hay ningún runner disponible para el proyecto. Revisa en Settings > CI/CD > Runners que tu runner esté en línea y acepte trabajos sin etiquetas, o que los runners de instancia estén activos.

Host key verification failed en deploy: el contenido de SSH_KNOWN_HOSTS no corresponde al servidor o se generó con otro nombre. Vuelve a ejecutar ssh-keyscan con la misma dirección que pones en DEPLOY_HOST.

Load key ... error in libcrypto: la variable SSH_PRIVATE_KEY no es de tipo File o le falta el salto de línea final. Vuelve a pegar la clave completa, incluida la línea en blanco del final.

Las variables llegan vacías en otras ramas: es el comportamiento esperado de las variables protegidas. Solo están disponibles en ramas y etiquetas protegidas.

rsync: failed to set times o Permission denied: el usuario deploy no es propietario de /var/www/tu_dominio o de alguno de sus archivos. Repite el chown del paso 2.

Conclusión

Tienes un pipeline de GitLab CI/CD que prueba cada push, compila la aplicación con caché de dependencias y despliega automáticamente la rama principal en tu servidor por SSH, con la clave protegida en variables de CI/CD. Como siguientes pasos, añade un entorno de staging con su propia regla de rama, publica imágenes de contenedor en el GitLab Container Registry si tu aplicación corre en Docker, y programa pipelines periódicos en Build > Pipeline schedules para detectar dependencias rotas.