Pipeline GitLab CI/CD para Next.js en Kubernetes

Un pipeline GitLab CI/CD completo para una app Next.js: caché pnpm, tests, imagen Docker multi-stage y despliegue en Kubernetes con Helm o Argo CD.

Cables de red turquesa conectados a un panel de parcheo en un rack de servidores

Respuesta corta: un pipeline GitLab CI/CD apto para producción con una aplicación Next.js cabe en cuatro etapas — install (dependencias en caché), test (lint + tests + next build), build (imagen Docker multi-stage subida al registro de GitLab), deploy (ya sea helm upgrade --install desde un job de CI, ya sea un commit en un repositorio de manifiestos que Argo CD reconcilia). Con una caché pnpm bien declarada y el modo output: 'standalone', se obtiene una imagen de unos 150 a 200 MB y un pipeline de 3 a 6 minutos en un runner compartido.

La mayoría de los equipos que heredamos ya tienen «una CI». Lanza los tests, y el despliegue se hace con un ssh y un docker compose pull dentro de un job when: manual. Funciona hasta el día en que hay que desplegar dos veces al día, volver atrás en dos minutos o explicarle a un recién llegado qué versión está corriendo realmente en producción. Este artículo describe el pipeline que instalamos por defecto, archivo por archivo.

Cables de red turquesa conectados a un panel de parcheo en un rack de servidores

El Dockerfile antes de la CI

No sirve de nada optimizar el pipeline si la imagen pesa 1,2 GB porque incluye node_modules entero. Next.js ofrece desde la versión 12 un modo de salida autónomo que copia en .next/standalone únicamente los archivos realmente necesarios para el servidor, siguiendo el grafo de dependencias. Se activa en next.config.ts:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

El Dockerfile correspondiente, en tres etapas:

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Tres puntos que salen caros cuando se olvidan:

  • HOSTNAME=0.0.0.0. El servidor autónomo escucha en localhost por defecto; dentro de un contenedor, la sonda de vivacidad de Kubernetes nunca logra alcanzar el puerto y el pod entra en bucle con CrashLoopBackOff.
  • Las variables NEXT_PUBLIC_* quedan fijadas en el build. El compilador las inserta directamente en el JavaScript del cliente. Una imagen construida para preproducción no se puede «repromocionar» a producción si cambia la URL de la API pública: hace falta o una imagen por entorno, o consumir esos valores solo en tiempo de ejecución en el lado servidor.
  • El usuario no root. Sin él, cualquier PodSecurityPolicy o cualquier securityContext con runAsNonRoot: true rechaza el pod.

El .gitlab-ci.yml, paso a paso

Caché de las dependencias

La caché de GitLab es la primera fuente de minutos desperdiciados. La clave debe depender del archivo de bloqueo, y el store de pnpm debe vivir dentro del directorio del proyecto — GitLab solo archiva lo que se encuentra bajo CI_PROJECT_DIR.

stages: [test, build, deploy]

default:
  image: node:22-alpine
  before_script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store

variables:
  IMAGE: $CI_REGISTRY_IMAGE
  TAG: $CI_COMMIT_SHORT_SHA

.node_cache: &node_cache
  key:
    files: [pnpm-lock.yaml]
  paths:
    - .pnpm-store
    - .next/cache
  policy: pull-push

lint_test:
  stage: test
  cache: *node_cache
  script:
    - pnpm install --frozen-lockfile
    - pnpm lint
    - pnpm exec tsc --noEmit
    - pnpm test --run
  artifacts:
    when: always
    reports:
      junit: junit.xml

Incluir .next/cache en la caché no es un detalle: es la caché de compilación de Next.js. La documentación oficial de Next.js («Continuous Integration (CI) Build Caching») lo señala explícitamente — sin ella, cada build empieza de cero y el mensaje «no build cache found» aparece en los logs. En un proyecto de tamaño medio, medimos con frecuencia una bajada de 4 minutos a 90 segundos en la etapa de build.

Construcción de la imagen con Buildah o Kaniko

El clásico docker:dind sigue funcionando, pero exige un runner privilegiado, lo cual es un problema en un clúster compartido. Dos alternativas sin demonio:

build_image:
  stage: build
  image: quay.io/buildah/stable:v1.38
  variables:
    STORAGE_DRIVER: vfs
    BUILDAH_FORMAT: docker
  script:
    - echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - buildah build --layers -t "$IMAGE:$TAG" -t "$IMAGE:latest" .
    - buildah push "$IMAGE:$TAG"
    - buildah push "$IMAGE:latest"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

--layers activa la caché de capas local; en un runner efímero no aporta nada, pero en un runner dedicado al equipo divide el tiempo por dos. Kaniko (gcr.io/kaniko-project/executor:debug) presta el mismo servicio con --cache=true --cache-repo=$CI_REGISTRY_IMAGE/cache, útil precisamente cuando los runners son desechables, ya que la caché vive en el registro.

En cuanto al nombrado: etiquetar con $CI_COMMIT_SHORT_SHA y nunca solo con latest. Una etiqueta inmutable es la condición para que un kubectl rollout undo tenga sentido, y para que Argo CD detecte realmente un cambio.

El registro de GitLab crece rápido: una imagen Next.js de 180 MB subida diez veces al día son más de 50 GB al mes. Activa una política de limpieza (Settings → Packages and registries → Clean up image tags) que conserve las 10 últimas etiquetas y elimine las de más de 30 días; si no, la factura de almacenamiento llega antes que la puesta en producción.

Para que el clúster pueda descargar esas imágenes, hace falta un imagePullSecret asociado a un deploy token — el tema está detallado en nuestra guía sobre las imágenes privadas de GitLab en Kubernetes.

Despliegue: helm upgrade desde la CI

El enfoque más directo. Un chart de Helm mínimo en deploy/chart y un job que publique la nueva versión.

.deploy_template: &deploy
  stage: deploy
  image: alpine/helm:3.16.3
  script:
    - echo "$KUBE_CONFIG" | base64 -d > kubeconfig
    - export KUBECONFIG=$PWD/kubeconfig
    - helm upgrade --install $RELEASE deploy/chart
        --namespace $NAMESPACE --create-namespace
        --set image.repository=$IMAGE
        --set image.tag=$TAG
        --values deploy/values.$ENV.yaml
        --atomic --timeout 5m

deploy_staging:
  <<: *deploy
  variables: { ENV: staging, NAMESPACE: web-staging, RELEASE: site-staging }
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy_prod:
  <<: *deploy
  variables: { ENV: prod, NAMESPACE: web-prod, RELEASE: site }
  environment:
    name: production
    url: https://example.com
  when: manual
  allow_failure: false
  rules:
    - if: $CI_COMMIT_TAG

Las dos opciones que lo cambian todo: --atomic, que revierte automáticamente la release si los pods no llegan a estar listos, y --timeout, sin el cual un job puede quedarse bloqueado una hora. El KUBE_CONFIG se guarda como variable enmascarada y protegida en GitLab, y el ServiceAccount asociado solo debe poder escribir en sus dos namespaces — un ClusterRoleBinding sobre cluster-admin para un job de despliegue es el fallo más frecuente que encontramos en auditoría.

El despliegue también debe ser sin cortes. En el chart:

strategy:
  type: RollingUpdate
  rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
readinessProbe:
  httpGet: { path: /api/health, port: 3000 }
  periodSeconds: 5
livenessProbe:
  httpGet: { path: /api/health, port: 3000 }
  initialDelaySeconds: 20
resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits: { memory: 512Mi }

Basta con una ruta app/api/health/route.ts que devuelva new Response('ok') — solo tiene que no depender de ninguna base de datos, porque si no una lentitud de PostgreSQL reinicia todos los pods en cascada. Sobre el dimensionamiento de memoria de un servidor Next.js, 256 Mi de request y 512 Mi de límite cubren un sitio corporativo o un back-office modesto; más allá, con ISR intensivo, hay que medir antes de fijar el límite, porque un OOMKilled en un renderizado de servidor se traduce en errores 502 para el visitante.

Despliegue: Argo CD en GitOps

A partir de dos entornos y más de un despliegue al día, el helm upgrade desde la CI muestra sus límites: la CI tiene permisos de escritura sobre el clúster, y el estado real del clúster no está escrito en ninguna parte. El modelo GitOps invierte el flujo — la CI ya no toca el clúster, escribe un commit; un agente dentro del clúster (Argo CD, Flux) lo descarga y reconcilia.

En concreto, el job de despliegue se convierte en un job de actualización de manifiesto:

bump_manifest:
  stage: deploy
  image: alpine/git:2.47.1
  script:
    - git clone https://bot:$DEPLOY_TOKEN@gitlab.com/acme/deploy-manifests.git m
    - cd m
    - sed -i "s|tag: .*|tag: $TAG|" apps/site/values-prod.yaml
    - git commit -am "site: deploy $TAG" && git push
  rules:
    - if: $CI_COMMIT_TAG

Y la aplicación Argo CD, en el clúster:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: site
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://gitlab.com/acme/deploy-manifests.git
    targetRevision: main
    path: apps/site
    helm:
      valueFiles: [values-prod.yaml]
  destination:
    server: https://kubernetes.default.svc
    namespace: web-prod
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true]

Lo que se gana: selfHeal devuelve el clúster al estado del repositorio si alguien ha hecho un kubectl edit a mano, el historial de Git es el historial de los despliegues, y una vuelta atrás es un git revert. Lo que se paga: un repositorio más, un componente más que alojar (Argo CD consume alrededor de 1 vCPU y 1 GB de RAM en una instalación modesta, es decir unos 15 a 25 € al mes de nodo en Scaleway u OVHcloud), y una latencia de sincronización de 3 minutos por defecto si no se configura el webhook de GitLab.

Nuestra regla práctica:

ContextoRecomendación
1 o 2 aplicaciones, 1 clúster, un equipohelm upgrade desde la CI
3 aplicaciones o más, o 2 clústeresArgo CD
Varios equipos despliegan en el mismo clústerArgo CD, con un AppProject por equipo
Requisito de auditoría «quién ha desplegado qué»Argo CD

Los errores que más se repiten

Lanzar next build dos veces. Una vez en el job de test, otra en el Dockerfile. Son de dos a cuatro minutos perdidos en cada pipeline. O el job de test se limita a tsc --noEmit y a los tests unitarios, o el build produce un artefacto reutilizado — pero mezclar ambos enfoques cuesta sin aportar nada.

Olvidar --frozen-lockfile. Sin esta opción, pnpm o npm puede resolver una versión menor distinta entre la máquina del desarrollador y la CI. Es la causa clásica del «en mi máquina funciona».

Una sola réplica con maxUnavailable: 1. El sitio queda indisponible durante cada despliegue. Dos réplicas y maxUnavailable: 0, más un PodDisruptionBudget con minAvailable: 1, bastan para que las actualizaciones y los desalojos de nodo sean invisibles.

Los secretos en las variables de CI en lugar de en el clúster. Una variable de GitLab inyectada en --set aparece en el historial de Helm y, por tanto, en un Secret del namespace, en claro para cualquiera que pueda leer ese namespace. Los secretos de la aplicación pertenecen al clúster — External Secrets Operator o Sealed Secrets — no al pipeline.

No proteger las ramas ni las etiquetas. Las variables «protegidas» solo tienen sentido si la rama por defecto y el patrón de etiquetas v* están declarados como protegidos en GitLab, con el push directo prohibido.

Sobre la entrada del tráfico hacia esos pods, si tu clúster sigue con ingress-nginx, hay que planificar la migración: lo detallamos en nuestro artículo sobre la Gateway API. Y para la base de datos que acompaña a la aplicación, la comparativa CloudNativePG frente a base gestionada da los órdenes de magnitud.

Preguntas frecuentes

¿Hace falta un runner de GitLab dedicado o bastan los runners compartidos?

Los runners compartidos de GitLab.com sirven hasta una decena de pipelines al día; el plan gratuito incluye una cuota mensual de minutos de cómputo que los builds de Docker consumen muy rápido. Más allá, un runner autoalojado en una instancia pequeña (4 vCPU, 8 GB) por 30-50 € al mes se amortiza de inmediato y, sobre todo, conserva la caché de capas entre builds. El ejecutor de Kubernetes resulta práctico si ya tienes un clúster, pero acuérdate de montar un volumen persistente para la caché; si no, pierdes la ventaja.

¿Se pueden desplegar ramas de funcionalidad en entornos efímeros?

Sí, es justamente el interés de environment: name: review/$CI_COMMIT_REF_SLUG con on_stop. Cada merge request obtiene su namespace y su URL https://$CI_COMMIT_REF_SLUG.review.example.com, destruidos al cerrarse. El coste real es la base de datos: prevé un esquema por entorno de revisión en lugar de una instancia por rama.

¿Cómo gestionar las migraciones de base de datos en el pipeline?

Con un Job de Kubernetes lanzado por un hook de Helm pre-upgrade, no desde el job de CI. El hook se ejecuta en el clúster, con los mismos secretos y la misma red que la aplicación, y --atomic revierte la release si la migración falla. La regla sigue siendo la compatibilidad hacia atrás: una migración debe poder convivir con la versión antigua del código durante el despliegue progresivo.

¿Cuánto debería durar un pipeline aceptable?

Nuestro umbral es de 10 minutos desde el git push hasta la preproducción desplegada. Más allá, los desarrolladores dejan de publicar a menudo y los lotes se hacen más grandes. La palanca número uno es la caché (.pnpm-store y .next/cache), la número dos la paralelización del lint y de los tests, la número tres la caché de capas de Docker.

En resumen

  • Activar output: 'standalone' en Next.js y construir una imagen multi-stage no root con HOSTNAME=0.0.0.0: unos 150 a 200 MB en lugar de más de 1 GB.
  • Poner en caché .pnpm-store y .next/cache con una clave basada en pnpm-lock.yaml; es el ahorro de tiempo más rentable del pipeline.
  • Construir con Buildah o Kaniko para evitar un runner privilegiado, etiquetar con el SHA del commit y activar una política de limpieza del registro.
  • Para una o dos aplicaciones, helm upgrade --install --atomic --timeout 5m desde la CI, con un ServiceAccount restringido a los namespaces implicados.
  • Más allá, pasar a GitOps con Argo CD: la CI escribe un commit, el clúster se reconcilia, la vuelta atrás es un git revert.
  • Despliegue sin cortes: 2 réplicas, maxUnavailable: 0, sondas sobre una ruta /api/health que no toque la base de datos.
  • Los secretos de la aplicación viven en el clúster (External Secrets, Sealed Secrets), nunca en los --set de Helm.

¿Necesitas una mirada externa sobre tu cadena de entrega? Intervenimos en este tipo de proyectos en el marco de nuestros servicios de infraestructura y operaciones y de desarrollo de aplicaciones; puedes escribirnos para hablarlo.