Short answer: a production-ready GitLab CI/CD pipeline for a Next.js application fits into four stages — install (cached dependencies), test (lint + tests + next build), build (multi-stage Docker image pushed to the GitLab registry), deploy (either helm upgrade --install from a CI job, or a commit to a manifest repository that Argo CD reconciles). With a properly declared pnpm cache and output: 'standalone' mode, you end up with an image of roughly 150 to 200 MB and a pipeline of 3 to 6 minutes on a shared runner.
Most of the teams we take over already have "a CI". It runs the tests, and deployment happens with an ssh and a docker compose pull in a when: manual job. That works right up until the day you need to deploy twice a day, roll back in two minutes, or explain to a newcomer which version is actually running in production. This article describes the pipeline we set up by default, file by file.

The Dockerfile comes before the CI
There is no point optimising the pipeline if the image weighs 1.2 GB because it ships the whole of node_modules. Since version 12, Next.js has offered a standalone output mode that copies into .next/standalone only the files actually required by the server, by following the dependency graph. You enable it in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfig
The matching Dockerfile, in three stages:
# 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"]
Three points that cost you dearly when you forget them:
HOSTNAME=0.0.0.0. The standalone server listens onlocalhostby default; inside a container, the Kubernetes liveness probe then never manages to reach the port and the pod loops inCrashLoopBackOff.NEXT_PUBLIC_*variables are frozen at build time. They are inlined into the client JavaScript by the compiler. An image built for staging cannot be "repromoted" to production if the public API URL changes: you need either one image per environment, or to consume those values only at runtime on the server side.- The non-root user. Without it, any
PodSecurityPolicyor anysecurityContextwithrunAsNonRoot: truewill reject the pod.
The .gitlab-ci.yml, step by step
Dependency caching
The GitLab cache is the number one source of wasted minutes. The key must depend on the lockfile, and the pnpm store must live inside the project directory — GitLab only archives what sits under 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
Having .next/cache in the cache is not a detail: it is the Next.js compilation cache. The official Next.js documentation ("Continuous Integration (CI) Build Caching") flags this explicitly — without it, every build starts from scratch and the "no build cache found" message shows up in the logs. On a mid-sized project, we regularly measure the build stage dropping from 4 minutes to 90 seconds.
Building the image with Buildah or Kaniko
The classic docker:dind still works but requires a privileged runner, which is a problem on a shared cluster. Two daemonless alternatives:
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 enables the local layer cache; on an ephemeral runner it brings nothing, but on a runner dedicated to the team it halves the time. Kaniko (gcr.io/kaniko-project/executor:debug) provides the same service with --cache=true --cache-repo=$CI_REGISTRY_IMAGE/cache, which is useful precisely when runners are disposable since the cache lives in the registry.
On naming: tag with $CI_COMMIT_SHORT_SHA and never with latest alone. An immutable tag is what makes a kubectl rollout undo meaningful, and what allows Argo CD to actually detect a change.
The GitLab registry grows fast: a 180 MB Next.js image pushed ten times a day is more than 50 GB a month. Enable a cleanup policy (Settings → Packages and registries → Clean up image tags) that keeps the last 10 tags and deletes those older than 30 days, otherwise the storage bill arrives before you reach production.
For the cluster to be able to pull those images, you need an imagePullSecret backed by a deploy token — the topic is covered in detail in our guide on private GitLab images on Kubernetes.
Deployment: helm upgrade from the CI
The most direct approach. A minimal Helm chart in deploy/chart, and a job that rolls out the new version.
.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
The two options that change everything: --atomic, which automatically rolls back the release if the pods do not become ready, and --timeout, without which a job can stay stuck for an hour. The KUBE_CONFIG is stored as a masked and protected variable in GitLab, and the associated ServiceAccount must only be able to write in its two namespaces — a ClusterRoleBinding to cluster-admin for a deployment job is the most frequent flaw we find in audits.
The deployment also has to be zero-downtime. On the chart side:
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 }
An app/api/health/route.ts route returning new Response('ok') is enough — it just must not depend on any database, otherwise a PostgreSQL slowdown restarts every pod in a cascade. On memory sizing for a Next.js server, 256 Mi requested and 512 Mi as the limit cover a brochure site or a modest back office; beyond that, with intensive ISR, you need to measure before setting the limit, because an OOMKilled during server rendering translates into 502s for the visitor.
Deployment: Argo CD with GitOps
From two environments and more than one deployment a day onwards, helm upgrade from the CI shows its limits: the CI holds write access to the cluster, and the cluster's actual state is written down nowhere. The GitOps model reverses the flow — the CI no longer touches the cluster, it writes a commit; an agent inside the cluster (Argo CD, Flux) pulls and reconciles.
In practice, the deployment job becomes a manifest-update job:
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
And the Argo CD application, on the cluster side:
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]
What you gain: selfHeal brings the cluster back to the repository's state if someone ran a manual kubectl edit, the Git history is the deployment history, and a rollback is a git revert. What you pay: one more repository, one more component to host (Argo CD consumes around 1 vCPU and 1 GB of RAM for a modest installation, i.e. roughly €15 to €25 a month of node at Scaleway or OVHcloud), and a default sync latency of 3 minutes if you do not configure the GitLab webhook.
Our practical rule:
| Context | Recommendation |
|---|---|
| 1 to 2 applications, 1 cluster, one team | helm upgrade from the CI |
| 3 applications or more, or 2 clusters | Argo CD |
| Several teams deploying to the same cluster | Argo CD, with one AppProject per team |
| Audit requirement for "who deployed what" | Argo CD |
The most common mistakes
Running next build twice. Once in the test job, once in the Dockerfile. That is two to four minutes lost on every pipeline. Either the test job sticks to tsc --noEmit and unit tests, or the build produces a reusable artefact — but mixing the two approaches costs you without giving anything back.
Forgetting --frozen-lockfile. Without that option, pnpm or npm may resolve a different minor version on the developer's machine and in the CI. This is the classic cause of "it works on my machine".
A single replica with maxUnavailable: 1. The site is unavailable during every deployment. Two replicas and maxUnavailable: 0, plus a PodDisruptionBudget with minAvailable: 1, are enough to make updates and node evictions invisible.
Secrets in CI variables rather than in the cluster. A GitLab variable injected via --set shows up in the Helm history and therefore in a Secret of the namespace, in plain text for anyone who can read that namespace. Application secrets belong to the cluster — External Secrets Operator or Sealed Secrets — not to the pipeline.
Not protecting branches and tags. "Protected" variables only mean something if the default branch and the v* tag pattern are declared as protected in GitLab, with direct push forbidden.
On routing traffic into these pods, if your cluster is still on ingress-nginx, the migration work needs planning: we covered it in our article on the Gateway API. And for the database that goes with the application, the comparison CloudNativePG versus a managed database gives the orders of magnitude.
Frequently asked questions
Do you need a dedicated GitLab runner, or are shared runners enough?
GitLab.com's shared runners are fine up to about ten pipelines a day; the free tier includes a monthly quota of compute minutes, which Docker builds burn through very quickly. Beyond that, a self-hosted runner on a small instance (4 vCPU, 8 GB) at €30-50 a month pays for itself immediately, and above all it keeps the layer cache between builds. The Kubernetes executor is handy if you already have a cluster, but remember to mount a persistent volume for the cache, otherwise you lose the benefit.
Can feature branches be deployed to ephemeral environments?
Yes, that is the whole point of environment: name: review/$CI_COMMIT_REF_SLUG with on_stop. Each merge request gets its own namespace and its URL https://$CI_COMMIT_REF_SLUG.review.example.com, destroyed when it closes. The real cost is the database: plan for one schema per review environment rather than one instance per branch.
How do you handle database migrations in the pipeline?
With a Kubernetes Job triggered by a Helm pre-upgrade hook, not from the CI job. The hook runs inside the cluster, with the same secrets and the same network as the application, and --atomic rolls back the release if the migration fails. The rule remains backward compatibility: a migration must be able to coexist with the previous version of the code for the duration of the rolling deployment.
How long should an acceptable pipeline take?
Our threshold is 10 minutes from git push to staging deployed. Beyond that, developers stop pushing often and batches grow bigger. Lever number one is the cache (.pnpm-store and .next/cache), number two is parallelising lint and tests, number three is the Docker layer cache.
In summary
- Enable
output: 'standalone'in Next.js and build a non-root multi-stage image withHOSTNAME=0.0.0.0: roughly 150 to 200 MB instead of more than 1 GB. - Cache
.pnpm-storeand.next/cachewith a key based onpnpm-lock.yaml; this is the most profitable time saving in the pipeline. - Build with Buildah or Kaniko to avoid a privileged runner, tag with the commit SHA and enable a registry cleanup policy.
- For one or two applications,
helm upgrade --install --atomic --timeout 5mfrom the CI, with a ServiceAccount restricted to the relevant namespaces. - Beyond that, move to GitOps with Argo CD: the CI writes a commit, the cluster reconciles, and the rollback is a
git revert. - Zero-downtime deployment: 2 replicas,
maxUnavailable: 0, probes on an/api/healthroute that does not touch the database. - Application secrets live in the cluster (External Secrets, Sealed Secrets), never in Helm's
--setflags.
Need an outside view on your delivery chain? We work on this kind of project as part of our infrastructure and operations and application development services; feel free to write to us to discuss it.


