Services
Vamos a añadir un Dockerfile para crear una imagen. No vamos a entrar en detalles sobre cómo montar un Dockerfile.
# Etapa 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
# Copia los archivos e instala dependencias
COPY package*.json ./
RUN npm ci
# Copia el resto y ejecuta el build
COPY . .
RUN npm run build
# Etapa 2: Runtime
FROM node:22-alpine
# Crea directorio de la app
WORKDIR /app
# Copia solo el build y los archivos necesarios para ejecutar
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Expone el puerto usado por serve
EXPOSE 3000
# Comando de inicio
CMD ["serve", "-s", "build", "-l", "3000"]
Si fuéramos a ejecutar localmente entonces ejecutaríamos.
❯ docker build -t learn-gitlab-app .
❯ docker run -p 3000:3000 learn-gitlab-app
INFO Accepting connections at http://localhost:3000
¿Cómo sería crear un job simple para esto?
docker-build:
stage: build
image: docker:24.0.2
services: # nuevo concepto
- docker:dind
script:
- docker build -t learn-gitlab-app .
when: manual # Vamos a probar.
En GitLab CI, cada job se ejecuta dentro de un contenedor aislado. Pero a veces ese contenedor necesita comunicarse con otro servicio (como una base de datos o un daemon de Docker). Ahí es donde entra services.
Services X Privileged
Cuando configuras GitLab Runner para usar el Docker executor, se ejecuta dentro de un contenedor Docker, pero ese contenedor puede o no tener permiso para gestionar otros contenedores dependiendo de la configuración que tenga. Esto se hace a través de la interacción con el Docker daemon (responsable de la creación y control de contenedores).
[[runners]]
###...
[runners.docker]
tls_verify = false
image = "debian:bullseye-slim"
# Esto permite que el runner cree y gestione otros contenedores si está en TRUE
privileged = false
En este caso privileged no está en true y vamos a observar lo que sucede ahora.
Al añadir ese job solo con el when: manual, solo conseguimos disparar el pipeline si lo solicitamos. Cuando hacemos un push el job estará en skipped pero con la posibilidad de ejecución en el botón de play.

Pero también podemos ejecutar en la pestaña pipelines. Ese new pipeline no es para crear un pipeline sino para ejecutar. ¡Deberían mejorar esto! Elige la rama o tag y crea.


Presiona para ejecutar y ¡mira ejecutándose!
En el log tenemos exactamente lo esperado, no consigue comunicarse con el host de docker.
$ docker build -t learn-gitlab-app .
ERROR: error during connect: Get "http://docker:2375/_ping": dial tcp: lookup docker on 10.0.0.1:53: no such host
Cleaning up project directory and file based variables
00:01
ERROR: Job failed: exit code 1
Tenemos algunas opciones para que esto funcione.
- Activar el privileged true y aceptar algunos riesgos.
- Usar un runner propio de GitLab.
- Usar kaniko
Resolver el problema depende de cada escenario donde el runner está ejecutándose. En mi escenario tengo un servidor HomeLab que ejecuta contenedores dentro de un host, pero para acceder al docker.sock necesito montarlo en el contenedor como volumen, además de privileged = true en config.toml.
[runners.docker]
privileged = true
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
Con esto configurado, reiniciando el runner y ejecutando nuevamente el pipeline tenemos el job ejecutado. Recuerda que podemos reiniciar el job sin necesidad de crear el pipeline.


¿Vamos a hacer una mejora simple en esto? El docker build utiliza el motor antiguo de docker pero utilizar el nuevo motor conocido como Docker BuildKit es más eficiente. No voy a entrar en detalles, pero queda el consejo.
docker-build:
stage: build
image: docker:24.0.2
services:
- docker:24.0.2-dind
script:
#- docker build -t learn-gitlab-app .
- docker buildx build -t learn-gitlab-app .
when: manual
Ve la diferencia de tiempo. 15 segundos contra 24 en un build simple. Si fuera algo más complejo la diferencia sería aún mayor.

Alternativas Mejores al Dind
El docker:dind (Docker-in-Docker) funciona, pero no es la mejor práctica en la mayoría de los casos — especialmente para DevSecOps o Platform Engineering.
Si observaste bien necesitamos dar acceso privileged y no me gusta mucho este escenario.
- Ejecuta un daemon dentro del contenedor → esto puede ser pesado e inseguro.
- Requiere privileged mode, lo que abre riesgos de seguridad.
- Puede dar problemas con caching y permisos dependiendo del runner.
Dos opciones interesantes son Kaniko y Buildah + Podman
Buildah y Podman
El host donde el runner está instalado necesita tener Podman y/o Buildah instalados.
Para usar Buildah o Podman de forma tranquila y segura, el mejor tipo de executor es el shell. No me gusta este enfoque, pero es bueno que lo conozcas.
Kaniko (by Google)
Kaniko es una herramienta open-source creada por Google para construir imágenes de contenedor sin necesidad de Docker daemon. Está orientada a entornos CI/CD, cloud y Kubernetes, donde ejecutar el docker build tradicional puede ser un problema (por causa del daemon, permisos, seguridad, etc).
- Lee el Dockerfile y el contexto, igual al docker build.
- En vez de usar un daemon, ejecuta los comandos directamente en user space. No necesita daemon ni privileged mode.
- Crea la imagen capa por capa y después empaqueta como una imagen OCI compatible.
- Al final, hace push directo al registry (Docker Hub, GitLab Registry, etc).
- GitLab CI, Jenkins, Tekton, Argo, etc.
Ideal para cloud-native pipelines (y tú, como futuro platform engineer, vas a amarlo).
Build de imágenes con comandos complejos puede ser más lento.
| Criterio | Kaniko | Docker-in-Docker (dind) | Docker BuildKit |
|---|---|---|---|
| ¿Necesita daemon? | ❌ No | ✅ Sí | ✅ Sí (pero integrado en Docker) |
| ¿Necesita privileged? | ❌ No (rootless) | ✅ Sí (inseguro en CI) | ⚠️ Sí (cuando usa con dind) |
| Seguridad | 🔒 Alta (rootless, sin daemon) | 🔓 Baja (privileged + daemon) | ⚠️ Media (depende del setup) |
| Cache eficiente | ⚠️ Limitado, pero posible (remoto) | ❌ Cache inconsistente | ✅ Muy eficiente (inline + remoto) |
| Velocidad | Media | Lenta en entornos CI | Rápido, especialmente con cache |
| Soporte en CI/CD | ✅ Óptimo (K8s, GitLab, Argo, etc.) | ✅ Pero inseguro y truco | ✅ Nativo vía Docker CLI |
| Complejidad de setup | ✅ Simple (1 contenedor) | ⚠️ Simple pero exige cuidado con seguridad | ⚠️ Simple localmente, pero más complicado en CI |
| Compatibilidad con Dockerfile | ✅ Alta | ✅ Alta | ✅ Alta |
| Rootless | ✅ Sí | ❌ No | ⚠️ Todavía depende del daemon/root |
Hoy sin duda kaniko es la solución más sólida y tenemos documentación en GitLab.
image-build:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--no-push
#--tar-path /kaniko/learn-gitlab-app.tar
when: manual
En el job de arriba, estamos solo realizando un build de prueba. Normalmente, usamos la flag --destination para que la imagen sea enviada directamente a un registry, lo que requiere credenciales que no pasamos todavía. Si no deseas hacer el push es necesario usar el --no-push y utilizar la flag --tar-path para guardar la imagen como un archivo .tar si vas a guardar la imagen. Ese archivo puede almacenarse como artifact. Como no hay un daemon Docker disponible en el entorno, comandos como docker image ls no funcionan y por eso la imagen necesita ser manipulada vía .tar o enviada a un registry.
Kaniko es una elección excelente: seguro, rootless, y sin necesidad de daemon, ¡pero un poco más demorado!
Observa que no usamos services, pero no vamos a escapar del asunto!

Descomplicando Services
Cuando defines un servicio en GitLab CI, creas un entorno completo de dependencias para tu job. Muchas veces queremos probar la ejecución del job, y para eso es necesario crear todo un entorno que satisfaga las dependencias para que se ejecute. Un ejemplo clásico es la comunicación con una base de datos.
Los services funcionan en gitlab-ci como un mini docker-compose integrado en el pipeline.
La diferencia es que GitLab hace todo esto detrás de escena, basado en lo que declaras en el .gitlab-ci.yml.
Observa el ejemplo de abajo.
test:
stage: check
image: node:22-alpine
services: # un contenedor
- name: postgres:16
alias: db
variables: # Esta variables está en nivel de job
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
DB_HOST: db
DB_PORT: 5432
before_script: # Instalando el cliente postgresql para hacer pruebas
- echo "Instalando cliente PostgreSQL..."
- apk add --no-cache postgresql-client
script:
- echo "Esperando que PostgreSQL esté disponible..."
- until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done
- echo "Conectando a la base de datos y creando la tabla..."
- PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY, name TEXT);"
- echo "Insertando datos..."
- PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "INSERT INTO users(name) VALUES ('DevSecOps');"
- echo "Consultando datos..."
- PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "SELECT * FROM users;"
Services es una lista, pero arriba solo creamos uno. El services ejecuta contenedores separados para los servicios (en este caso, PostgreSQL) y enlaza esos contenedores al job principal. Los contenedores creados por services y el del job principal comparten la misma red lo que permite acceder al servicio por el alias, por ejemplo db:5432 como si fuera dominio:puerto.
- El contenedor del servicio (PostgreSQL) es un contenedor hermano, no es un subproceso del contenedor principal.
- El alias: db se convierte en un hostname interno accesible en tu job.
- Variables de entorno definidas en el job no se pasan automáticamente al servicio. Esas variables existen solo dentro del contenedor del job (el node:22-alpine, en este caso). El contenedor del service (postgres:16) es otro contenedor separado, y no hereda esas variables automáticamente.
¿Pero por qué PostgreSQL todavía parece funcionar?
Porque la imagen oficial postgres fue hecha para leer esas variables de entorno en el momento en que el contenedor de ella se inicia. Y aquí viene el detalle.
GitLab Runner detecta esas variables del job y, en algunos casos, las inyecta en el contenedor si ese service es una imagen oficial reconocida de GitLab. En el caso de PostgreSQL funciona porque GitLab repasa las variables al contenedor del service como parte de la creación de él vía Docker Compose-like.
Esto no está garantizado para cualquier service, ni para cualquier executor (ej: Kubernetes executors no hacen esto de la misma manera).
Para algunos services oficiales como ese PostgreSQL, si las mismas variables de entorno que el service necesita están presentes en el job, él hereda, pero solo las que necesita.
Otro detalle es que ese service es un runner también y recibe todas las variables principales del gitlab-ci.
Sería lo mismo que hacer esto.
job:
#...
services:
- name: postgres:16
alias: db
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
DB_HOST: db
DB_PORT: 5432
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
Particularmente prefiero ser explícito, a pesar de más verboso evita suposiciones.
Abajo tenemos algunos services que consiguen hacer ese reaprovechamiento de las variables del job.
- MySQL y MariaDB usan las mismas
- MYSQL_DATABASE
- MYSQL_ROOT_PASSWORD
- MYSQL_USER
- MYSQL_PASSWORD
- PostgreSQL
- POSTGRES_DB
- POSTGRES_USER
- POSTGRES_PASSWORD
- POSTGRES_HOST_AUTH_METHOD
- PGDATA
- POSTGRES_INITDB_ARGS
- RabbitMQ
- RABBITMQ_ERLANG_COOKIE
- RABBITMQ_DEFAULT_USER
- RABBITMQ_DEFAULT_PASS
- RABBITMQ_DEFAULT_VHOST
- Elasticsearch
- ELASTICSEARCH_URL
- discovery.type
- xpack.security.enabled
- Docker-in-Docker (DinD)
- DOCKER_TLS_CERTDIR: Define el directorio para certificados TLS
- DOCKER_HOST: Usado para configurar la conexión con el daemon Docker

Vamos a ejecutar el job y ver lo que sucede.
Aunque dos contenedores se ejecutan tenemos solo un job. Cada contenedor genera un runner, pero están enlazados en el mismo job.
Running with gitlab-runner 17.11.0 (0f67ff19)
on general-debian jyvyfkmfg, system ID: r_szdZCOX2meST
Preparing the "docker" executor
00:22
Using Docker executor with image node:22-alpine ...
Starting service postgres:16... ### VE QUE TAMBIÉN ESTÁ INICIANDO OTRO CONTENEDOR
Using locally found image version due to "if-not-present" pull policy
Using docker image sha256:2698c2096ca78a41ae7477580afb30fe36d5368564511b2ea593dbfb26401fdd for postgres:16 with digest postgres@sha256:301bcb60b8a3ee4ab7e147932723e3abd1cef53516ce5210b39fd9fe5e3602ae ...
Waiting for services to be up and running (timeout 30 seconds)... # ATENCIÓN AQUÍ.
Using locally found image version due to "if-not-present" pull policy
Using docker image sha256:461edc13e56b039ebc3d898b858ac3acea00c47f31e93ec1258379cae8990522 for node:22-alpine with digest node@sha256:ad1aedbcc1b0575074a91ac146d6956476c1f9985994810e4ee02efd932a68fd ...
Preparing environment
00:01
Running on runner-jyvyfkmfg-project-69186599-concurrent-0 via 27213b29e8e9...
Getting source from Git repository
00:03
Fetching changes with git depth set to 20...
Reinitialized existing Git repository in /builds/puziol/learn-gitlab-app/.git/
Created fresh repository.
Checking out c5421d02 as detached HEAD (ref is pipe/rules)...
Skipping Git submodules setup
Executing "step_script" stage of the job script
00:04
Using docker image sha256:461edc13e56b039ebc3d898b858ac3acea00c47f31e93ec1258379cae8990522 for node:22-alpine with digest node@sha256:ad1aedbcc1b0575074a91ac146d6956476c1f9985994810e4ee02efd932a68fd ...
$ echo "Instalando cliente PostgreSQL..."
Instalando cliente PostgreSQL...
$ apk add --no-cache postgresql-client
fetch https://dl-cdn.alpinelinux.org/alpine/v3.21/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.21/community/x86_64/APKINDEX.tar.gz
(1/8) Installing postgresql-common (1.2-r1)
Executing postgresql-common-1.2-r1.pre-install
(2/8) Installing lz4-libs (1.10.0-r0)
(3/8) Installing libpq (17.4-r0)
(4/8) Installing ncurses-terminfo-base (6.5_p20241006-r3)
(5/8) Installing libncursesw (6.5_p20241006-r3)
(6/8) Installing readline (8.2.13-r0)
(7/8) Installing zstd-libs (1.5.6-r2)
(8/8) Installing postgresql17-client (17.4-r0)
Executing busybox-1.37.0-r12.trigger
Executing postgresql-common-1.2-r1.trigger
* Setting postgresql17 as the default version
WARNING: opening from cache https://dl-cdn.alpinelinux.org/alpine/v3.21/main: No such file or directory
WARNING: opening from cache https://dl-cdn.alpinelinux.org/alpine/v3.21/community: No such file or directory
OK: 15 MiB in 25 packages
$ echo "Esperando que PostgreSQL esté disponible..."
Esperando que PostgreSQL esté disponible...
$ until PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done
$ echo "Conectando a la base de datos y creando la tabla..."
Conectando a la base de datos y creando la tabla...
$ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY, name TEXT);"
CREATE TABLE
$ echo "Insertando datos..."
Insertando datos...
$ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "INSERT INTO users(name) VALUES ('DevSecOps');"
INSERT 0 1
$ echo "Consultando datos..."
Consultando datos...
$ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U $POSTGRES_USER -d $POSTGRES_DB -c "SELECT * FROM users;"
id | name
----+------------------
1 | DevSecOps
(1 row)
Cleaning up project directory and file based variables
00:01
Job succeeded
Podríamos hacer una analogía con el docker-compose de abajo.
version: '3.8'
services:
app:
image: node:22-alpine
environment:
- DB_HOST=db
- DB_PORT=5432
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpass
depends_on:
- db
command: |
sh -c "apk add --no-cache postgresql-client &&
until PGPASSWORD=$POSTGRES_PASSWORD psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done &&
psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY, name TEXT);' &&
psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'INSERT INTO users(name) VALUES (\"DevSecOps\");' &&
psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c 'SELECT * FROM users;'"
db:
image: postgres:16
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
Para garantizar que el contenedor de PostgreSQL esté en ejecución, puse esa línea en el script como una forma de forzar una espera en caso de que el service no esté listo.
until PGPASSWORD=$POSTGRES_PASSWORD psql -h $DB_HOST -U $POSTGRES_USER -d $POSTGRES_DB -c '\q' 2>/dev/null; do sleep 1; done &&
El valor de espera default es 30s pero puede alterarse en las configuraciones del runner, por ejemplo para 60 segundos. Ese sería el tiempo máximo. Generalmente los contenedores suben rápido.
[runner.docker]
wait_for_services_timeout = 60
Eliminando el until de arriba el job correría normalmente pues PostgreSQL sube rápido.
El bloque service tiene esta estructura.
job:
services:
# service 1
- name:
alias:
entrypoint:
command:
variables:
# service 2
- name:
alias:
entrypoint:
command:
variables: