Tutorials

GitLab CI/CD for Next.js on Cloud Run

Null World Productions
4 min read

A Next.js app on Cloud Run needs three things from CI: a reproducible Docker build, build-time public env vars baked into the client bundle, and a deploy step that updates the service image without wiping Terraform-managed settings.

This is the pipeline shape we use for the Null World Productions website. It runs on GitLab.com, authenticates to GCP with Workload Identity Federation (OIDC), and deploys only from main.

Pipeline stages

Keep the graph simple:

  1. test — lint, unit tests, typecheck
  2. build — Docker image to Artifact Registry
  3. tag — promote latest on main (optional but useful for cache)
  4. deploygcloud run deploy with the new image

Merge requests run test and build. Deploy runs on main after build succeeds.

stages:
  - test
  - build
  - tag
  - deploy

Build-time env vars for Next.js

NEXT_PUBLIC_* variables embed in the client JavaScript at build time. Setting them only on Cloud Run at runtime is too late for anything the browser needs (analytics IDs, canonical site URL, feature flags exposed to the client).

Pass them as Docker build args in CI:

build_image:
  script:
    - |
      BUILD_ARGS=""
      if [ -n "${NEXT_PUBLIC_GA_ID:-}" ]; then
        BUILD_ARGS="${BUILD_ARGS} --build-arg NEXT_PUBLIC_GA_ID=${NEXT_PUBLIC_GA_ID}"
      fi
      if [ -n "${NEXT_PUBLIC_SITE_URL:-}" ]; then
        BUILD_ARGS="${BUILD_ARGS} --build-arg NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}"
      fi
      docker build ${BUILD_ARGS} -t "${IMAGE_URI}" -f Dockerfile .

In the Dockerfile:

ARG NEXT_PUBLIC_GA_ID
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL

Store secrets and environment-scoped values in GitLab CI/CD variables (protected on main, scoped per environment). Never commit them.

Docker layer cache from Artifact Registry

Cold Docker builds on every pipeline waste minutes. Pull the previous image and use --cache-from:

LATEST_IMAGE="${REPO_PATH}:latest"
docker pull "${LATEST_IMAGE}" 2>/dev/null || true
docker build --cache-from "${LATEST_IMAGE}" -t "${IMAGE_URI}" .
docker push "${IMAGE_URI}"

Tag images with the commit SHA for traceability (:${CI_COMMIT_SHORT_SHA}) and maintain a latest tag on main for cache and deploy convenience.

Authenticate without JSON keys

Long-lived GCP service account keys in GitLab variables rot poorly and leak badly. Use GitLab OIDC tokens and Workload Identity Federation.

Each job that touches GCP declares:

id_tokens:
  GITLAB_OIDC_TOKEN:
    aud: https://gitlab.com

The job exchanges that token for a short-lived GCP access token, then runs gcloud commands. GitLab documents the curl-based exchange; we follow that pattern in deploy jobs.

Required CI variables (names vary by org):

  • GCP_PROJECT_ID
  • GCP_REGION
  • GCP_ARTIFACT_REPO
  • GCP_SERVICE_NAME
  • GCP_WORKLOAD_IDENTITY_POOL / provider identifiers
  • GCP_SERVICE_ACCOUNT_EMAIL

Terraform usually creates the pool, provider, and IAM bindings. CI only consumes them.

Deploy to Cloud Run

Deploy should change the image (and maybe NODE_ENV), not recreate the entire service config. Infrastructure settings (CPU, memory, min instances, VPC, custom domains) belong in Terraform.

gcloud run deploy "${GCP_SERVICE_NAME}" \
  --image "${IMAGE_URI}" \
  --region "${GCP_REGION}" \
  --platform managed \
  --quiet

Verify the image exists in Artifact Registry before deploy. A failed push should fail the pipeline before Cloud Run rolls forward.

Rules example:

deploy_production:
  stage: deploy
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  needs:
    - build_image

MR vs main behavior

Event test build deploy
Merge request yes yes (verify image builds) no
Push to main yes yes yes

Building on MRs catches Dockerfile and dependency breaks before merge. Skipping deploy on MRs avoids preview URL complexity unless you add a dedicated review environment.

Failure modes we watch for

  • Missing NEXT_PUBLIC_* at build — site loads but analytics or absolute URLs break silently
  • Deploy without image verify — Cloud Run keeps old revision; pipeline looks green
  • Overwriting Cloud Run flags in deploy — fights Terraform; pick one source of truth for infra
  • Unscoped CI variables — staging secrets leaking into production builds

Minimal checklist

  • Stages: test → build → deploy
  • NEXT_PUBLIC_* as build args, not runtime-only
  • OIDC to GCP, no committed keys
  • Image tagged by commit SHA
  • Deploy gated to main
  • Terraform owns service shape; CI owns image promotion

Need help with Cloud Run delivery or GitLab pipeline setup? Contact us.