Guía DevOps: GitLab CI/CD desde cero para sysadmins Linux 2026
Tengo un homelab con un GitLab self-hosted desde hace años. Lo monté porque me cansé de subir código a servicios externos y quería un único panel donde vivieran los repos, los issues, los merge requests y, sobre todo, los pipelines. La parte de CI/CD de GitLab es probablemente lo más subestimado del producto, y es lo que más valor te da cuando administras uno o dos servidores Linux y quieres dejar de hacer deploy a mano un viernes a las seis de la tarde.
Esta guía te lleva desde cero: archivo .gitlab-ci.yml, primer job que pasa, instalación del runner, hasta patrones de producción como rules, workflow, DAG con needs, cache vs artifacts y los CI/CD Components que llegaron con GitLab 17 y maduraron en 18 y 19. Vamos contra la versión estable de la familia 19.x (la línea que reemplazó a la 18.11 a mediados de 2026), y todos los comandos los vas a poder correr en Debian 12, Ubuntu 24.04, Fedora 41 o cualquier servidor con Docker.
TL;DR — Lo que vas a tener al final
- Tiempo de setup: 2-3 horas la primera vez (incluye instalar GitLab Runner y registrarlo).
- Requisitos: GitLab self-hosted 19.x o gitlab.com, un servidor Linux con Docker, acceso root, y un repositorio con algo que compile o testear.
- Resultado: pipeline con stages de build, test y deploy que corre automáticamente en cada push, con reglas para que solo se ejecute lo que debe, cache para que
npm installopip installno vuelva a descargar el mundo, y artifacts compartibles entre stages. - Troubleshooting: la mayoría de fallos son por
tagsmal puestos en el runner, rutas de cache que no existen dentro del contenedor, orulesque excluyen la rama equivocada. Más abajo te dejo los tres errores más comunes y cómo diagnosticarlos.
Qué es GitLab CI/CD y cómo encaja en tu flujo
GitLab CI/CD es un motor de pipelines embebido en cada proyecto de GitLab. A diferencia de GitHub Actions o Jenkins, el archivo .gitlab-ci.yml vive dentro del propio repositorio (versionado, revisable en merge requests), y los runners — los agentes que ejecutan los jobs — pueden ser compartidos, específicos o autoescalables. Cuando haces push a una rama, GitLab lee el YAML, arma un pipeline con stages secuenciales, y le pasa los jobs a un runner disponible.
Para sysadmins Linux, lo importante es esto: un runner puede correr en tu mismo servidor (executor shell), dentro de contenedores Docker (executor docker), en Kubernetes (kubernetes) o en SSH contra otra máquina (ssh). El executor docker es el que más vas a usar en producción: cada job corre en un contenedor limpio con la imagen que tú definas, y eso evita el clásico “en mi máquina funciona”.
Anatomía del .gitlab-ci.yml
El archivo tiene tres bloques conceptuales: stages, que define el orden de ejecución; variables, que define configuración global; y los jobs, que son las unidades de trabajo. Cada job declara al menos un script — lo que se ejecuta — y puede tener stage, image, tags, rules, artifacts y cache.
El YAML mínimo que corre algo es este:
stages:
- build
- test
- deploy
build-job:
stage: build
script:
- echo "Compilando el proyecto..."
- make build
test-job:
stage: test
script:
- echo "Corriendo tests..."
- make test
deploy-job:
stage: deploy
script:
- echo "Desplegando a staging..."
- ./deploy.sh staging
environment:
name: staging
url: https://staging.ejemplo.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
Esto declara tres stages secuenciales. Dentro de cada stage, los jobs corren en paralelo por defecto. Si build-job falla, test-job y deploy-job no corren. El bloque environment le dice a GitLab que registre ese deploy y muestre un botón para volver a la versión anterior desde la UI.
Variables globales y de job
Variables a nivel de pipeline aplican a todos los jobs. Las de job aplican solo a ese job y sobrescriben las globales. Las variables sensibles (tokens, claves SSH) van en Settings > CI/CD > Variables y se inyectan enmascaradas:
variables:
POSTGRES_HOST: "postgres.default.svc.cluster.local"
RAILS_ENV: "test"
test-job:
stage: test
variables:
RAILS_ENV: "ci" # sobrescribe la global solo en este job
script:
- bundle exec rails test
Instalar y registrar un GitLab Runner self-hosted
Un runner es un binario (gitlab-runner) que se conecta al servidor GitLab, pregunta si hay jobs pendientes y los ejecuta. La forma recomendada en 2026 es el paquete oficial de Debian/Ubuntu o el container Docker. Para un homelab con Docker ya instalado, lo más limpio es correrlo como contenedor:
docker run -d --name gitlab-runner --restart always
-v /srv/gitlab-runner/config:/etc/gitlab-runner
-v /var/run/docker.sock:/var/run/docker.sock
gitlab/gitlab-runner:latest
El segundo volumen monta el socket de Docker del host, lo que permite que el runner ejecute otros contenedores dentro de los jobs (executor docker). Después de levantarlo, necesitas el token de registro, que sale del menú Settings > CI/CD > Runners en tu proyecto o grupo:
docker exec -it gitlab-runner gitlab-runner register
--non-interactive
--url "https://gitlab.tu-dominio.com/"
--registration-token "<TOKEN-DE-REGISTRO>"
--executor "docker"
--docker-image "alpine:latest"
--docker-privileged=false
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock"
--description "runner-docker-homelab"
--tag-list "docker,linux,homelab"
--run-untagged=false
--access-level="not_protected"
Los tag-list son la llave: un job corre en un runner solo si las tags del job coinciden con las del runner. Por defecto, un runner solo toma jobs con run-untagged=true; si lo pones en false, te obliga a etiquetar cada job explícitamente — buena práctica para evitar que un job de producción caiga en el runner de tu Raspberry Pi.
Verifica que quedó registrado:
docker exec gitlab-runner gitlab-runner list
# Debería listar algo como:
# Listing configured runners
# Runner con TOKEN=... url=https://gitlab.tu-dominio.com/
# Token:TOKEN, Tags: docker, linux, homelab
Reglas, workflow y el DAG con needs
El bloque rules es el sistema de control fino para decidir cuándo corre un job. La regla más común es limitar por rama:
deploy-prod:
stage: deploy
script:
- ./deploy.sh production
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: false
Aquí deploy-prod aparece en el pipeline solo cuando el commit es a main, y se queda como botón manual esperando que alguien con permisos le dé play. allow_failure: false hace que si falla el deploy, el pipeline quede en rojo y bloquee el merge del MR.
El bloque workflow controla la creación del pipeline entero, ideal para cortar pipelines en commits de merge que no te interesa testear:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
Esto deja correr pipelines solo cuando hay un MR abierto o el push va a una rama sin MR. Es un ahorra de minutos ahorra y la diferencia entre una cola de runners disponible y una saturada.
Para dependencias más finas entre jobs, needs arma un DAG (grafo acíclico dirigido): un job puede arrancar apenas su dependencia termina, sin esperar todo el stage. Útil cuando test-integration tarda 10 minutos y puede arrancar apenas build termina, sin esperar a unit-tests:
build:
stage: build
script: make build
unit-tests:
stage: test
script: make test-unit
needs: ["build"]
integration-tests:
stage: test
script: make test-integration
needs: ["build"]
Cache y artifacts: cuándo usar cada uno
El error más común es confundirlos. Artifacts son archivos que se pasan entre jobs y se pueden descargar al final (binarios compilados, reportes de tests, paquetes deployables). Cache son archivos que persisten entre pipelines para acelerar (dependencias de npm, pip, bundler, cargo).
build-job:
stage: build
script:
- npm ci --cache .npm --prefer-offline
cache:
key:
files:
- package-lock.json
paths:
- .npm/
artifacts:
paths:
- dist/
expire_in: 1 week
El cache usa package-lock.json como llave: si el lockfile no cambia, el cache se reusa. Los artifacts de dist/ viven una semana y están disponibles para cualquier job que dependa de build-job o que se descargue con dependencies: ["build-job"].
Pipeline que comparte artifacts entre stages
build:
stage: build
script: make build
artifacts:
paths: [bin/myapp]
test:
stage: test
script:
- ./bin/myapp --self-test
dependencies:
- build
Sin dependencies: ["build"], el job test no podría descargar bin/myapp. GitLab asume por defecto que cada job descarga los artifacts de los jobs anteriores en su stage, pero si reorganizas el DAG con needs, tienes que declarar dependencies explícitamente.
Reutilizar configuración: includes, extends y components
Cuando tienes 12 microservicios con la misma imagen base, los mismos pasos de lint, y la misma estructura de test, copiar el YAML no escala. GitLab ofrece tres mecanismos para evitarlo.
extends sirve para reusar bloques dentro del mismo archivo o en un archivo incluido:
.python-setup:
image: python:3.12-slim
before_script:
- pip install -r requirements.txt
cache:
paths:
- .cache/pip
test-job:
extends: .python-setup
script:
- pytest
include trae configuración desde otro archivo o proyecto:
include:
- local: 'ci/build-template.yml'
- project: 'devops/ci-templates'
ref: main
file: '/templates/lint.yml'
Y los CI/CD Components, introducidos formalmente en GitLab 17.0 y consolidados en 18 y 19, son la forma moderna de publicar unidades reutilizables con spec.inputs y spec.outputs. Viven en su propio repositorio y se versionan con tags:
include:
- component: 'gitlab.com/components/docker/build-and-push@~1.0'
inputs:
image: "mi-app"
tag: $CI_COMMIT_SHORT_SHA
Es la dirección correcta para crecer: cuando tengas tres o más proyectos compartiendo lógica de CI, mueve esa lógica a un component catalog y reduce el YAML de cada proyecto a 10-20 líneas. include:local es la recomendación oficial cuando el template vive en el mismo repositorio y quieres garantizar el mismo SHA entre el template y el pipeline que lo incluye.
Troubleshooting: los tres errores más comunes
1. “This job is stuck because you don’t have any active runners online”. El runner se registró pero no aparece como online en Settings > CI/CD > Runners. Casi siempre es que el contenedor no tiene acceso al socket de Docker del host (revisa que /var/run/docker.sock esté en el docker run), o que el --url del registro apuntaba a localhost dentro del contenedor en vez de la URL real de GitLab.
2. El job corre pero falla con “no such file or directory” en un script. El job asume que existe un archivo que está en otro job, pero no pusiste dependencies: explícito o no declaraste el artifacts: en el job que lo produce. Verifica en la UI del pipeline qué artifacts están disponibles para ese job (botón Browse en la página del job).
3. Cache que nunca pega. El cache se guarda por SHA de la llave. Si tu key es genérica (key: $CI_COMMIT_REF_SLUG), cada rama tiene uno distinto y el cache nunca reusa. Usa key: files: package-lock.json para que la llave dependa del lockfile, no de la rama. Y verifica que el path exista físicamente dentro del contenedor: un cache de .npm/ en una imagen alpine falla si la primera línea del script no es npm install o si --cache .npm no se lo pasa a npm.
Si quieres ver el log completo de un job, abre la UI del pipeline, haz clic en el job, y abajo a la derecha está el botón View raw trace — ese es el output sin colorear que puedes copiar y pegar en un issue.
Preguntas frecuentes
¿GitLab CI/CD sirve para self-hosted o solo para gitlab.com?
Sirve para ambos. La misma sintaxis .gitlab-ci.yml funciona idéntica en gitlab.com y en una instancia self-hosted; lo único que cambia es el runner: en gitlab.com usas los hosted runners (con minutos limitados según tu plan) y en self-hosted corres tus propios runners en tu infra. Para homelab o producción con datos sensibles, self-hosted es el camino.
¿Qué executor de runner debo usar: shell, docker o kubernetes?
Docker para casi todo: cada job corre en un contenedor limpio con la imagen que definas en image:, sin contaminar el host. Kubernetes solo cuando ya tienes un cluster y quieres autoescalado de runners. Shell es para casos puntuales donde necesitas acceso directo al host (por ejemplo, tests que requieren systemd); no es la opción por defecto.
¿Cómo paso secretos a un job sin que aparezcan en los logs?
Settings > CI/CD > Variables > Add variable: marca “Mask variable” y “Protect variable”. GitLab enmascara el valor en los logs automáticamente. Para archivos (claves SSH, kubeconfig), usa “File” type en lugar de “Variable”. Si necesitas secretos por ambiente, combina con rules: por rama o environment.
¿Cuál es la diferencia entre cache y artifacts?
Cache acelera pipelines guardando dependencias (node_modules, .venv): GitLab puede borrarlo en cualquier momento y el job no rompe. Artifacts transfiere archivos entre jobs del mismo pipeline (binarios compilados, reportes de test): vive hasta que expire expire_in y el job depende de él. Mezclarlos lleva al clásico error de “no such file” cuando un job asume un binario que estaba en cache.
¿Cuándo conviene migrar de GitHub Actions a GitLab CI/CD?
Cuando quieres unificar repos + issues + MR + pipelines en una sola plataforma self-hosted, o cuando tu organización necesita runners bajo control sin pagar por minuto de cómputo. La migración no es 1:1 (la sintaxis YAML es distinta, los actions/ son Marketplace en GitHub vs CI/CD Catalog en GitLab), pero el modelo conceptual es equivalente: pipelines declarativos, runners distribuibles, artifacts y cache.
