Cloud Delivery Flow: Cloud Run + Terraform + Artifact Registry

Why this stack works

When we build a web app, coding is only half of the story.
The other half is delivery: build, store, deploy, and update safely.

This stack solves that flow clearly:

  • Cloud Build builds the container image.
  • Artifact Registry stores the image version.
  • Terraform defines infra as code.
  • Cloud Run runs the app with autoscaling.

Short version: one pipeline, clear ownership, less manual work.

Tool roles (simple view)

ToolMain roleWhy it matters
Cloud BuildBuild and push imageReproducible builds from source
Artifact RegistryStore versioned imageTraceable deploys and rollback
TerraformProvision infraSame setup across dev/stage/prod
Cloud RunRun container appServerless scale and pay-per-use

Terraform use-cases: when and why

Terraform is most useful when infrastructure starts to repeat across environments or people.

Use Terraform when:

  • You have dev/stage/prod and want the same infra shape in all of them.
  • Your team wants infra changes reviewed in PRs, same as app code.
  • You need repeatable setup for Cloud Run, IAM, Artifact Registry, networking, and secrets.
  • You want safer rollback and auditing (what changed, when, and by whom).

For a single demo app, console clicks may be fine.
For real delivery flow with more than one environment, Terraform quickly pays back.

End-to-end flow

Git push
  -> Cloud Build
      -> Build Docker image
      -> Push image to Artifact Registry
  -> Terraform apply
      -> Create/update Cloud Run service
      -> Deploy new image tag
Rollback
  -> Change image tag to previous stable version
  -> Terraform apply

The key idea is separation:

  • App artifact lifecycle is handled by build + registry.
  • Runtime infrastructure lifecycle is handled by Terraform.

Scenario: Deploy a Next.js app

Below is a real-shaped example: Next.js builds inside Docker, the image runs on Cloud Run, and Cloud Build pushes to Artifact Registry.

A) Pick how Next.js runs in the container

Option 1 — Static export (output: 'export')
Good for marketing sites, docs, and blogs that ship as HTML under out/.
Run it behind nginx in the container. Cloud Run expects the process to listen on PORT (often 8080), so nginx should listen on that port.

Option 2 — Node server (output: 'standalone')
Use when you need SSR, Route Handlers, or dynamic rendering.
Build produces .next/standalone; you run node server.js and set PORT to what Cloud Run gives you.

Pick one path per project. Mixing export and standalone in one Dockerfile only confuses the build.

B) Dockerfile: static export + nginx (concrete)

Dockerfile (multi-stage: build Next, serve out/):

# --- build ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
# Your app should run `next build` and emit `out/` (next.config: output: "export")
RUN npm run build

# --- run ---
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/out /usr/share/nginx/html
EXPOSE 8080

nginx.conf (listen on 8080 so Cloud Run’s default port matches):

server {
  listen 8080;
  server_name _;
  root /usr/share/nginx/html;
  index index.html;

  location / {
    try_files $uri $uri/ $uri.html /index.html;
  }
}

.dockerignore (keeps the build context small):

node_modules
.next
out
.git
.env*

Local smoke test:

docker build -t website:local .
docker run --rm -p 8080:8080 website:local
# Open http://localhost:8080

C) Dockerfile: Next.js standalone (when you are not using export)

In next.config use output: "standalone", then:

FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Cloud Run sets PORT; the standalone server reads it, so you usually do not hardcode another port.

D) cloudbuild.yaml: build image and push to Artifact Registry

Substitutions match the gcloud builds submit example later. Adjust project IDs and names.

substitutions:
  _REGION: asia-southeast1
  _REPO: web-repo
  _IMAGE_NAME: website
  _TAG: v1

steps:
  - name: gcr.io/cloud-builders/docker
    args:
      - build
      - "-t"
      - "${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/${_IMAGE_NAME}:${_TAG}"
      - .

images:
  - "${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/${_IMAGE_NAME}:${_TAG}"

Cloud Build’s service account needs permission to push to Artifact Registry (e.g. Artifact Registry Writer on the repo or project).

E) Build and push image with Cloud Build

Example command:

gcloud builds submit --config=cloudbuild.yaml \
  --substitutions=_REGION=asia-southeast1,_REPO=web-repo,_IMAGE_NAME=website,_TAG=v1 \
  --project=my-web-project

What this gives you:

  • A tagged image like asia-southeast1-docker.pkg.dev/my-web-project/web-repo/website:v1
  • A deployment artifact you can reuse across environments

F) Keep image in Artifact Registry

Why this is important:

  • Every release has a clear version.
  • Rollback is easy: deploy an older tag.
  • Security scanning and access control are centralized.

G) Manage Cloud Run with Terraform

Small Terraform sample:

resource "google_cloud_run_v2_service" "website" {
  name     = "website"
  location = var.region

  template {
    containers {
      image = var.image_uri
      ports {
        container_port = 8080
      }
    }
  }
}

H) How to write Terraform (practical workflow)

Think in this order: providers -> variables -> resources -> outputs.

  1. Provider and project context
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}
  1. Input variables (things that change by environment)
variable "project_id" { type = string }
variable "region"     { type = string }
variable "service_name" {
  type    = string
  default = "website"
}
variable "image_uri" { type = string }
  1. Resources (what you want Terraform to manage)
resource "google_artifact_registry_repository" "web_repo" {
  location      = var.region
  repository_id = "web-repo"
  format        = "DOCKER"
}

resource "google_cloud_run_v2_service" "website" {
  name     = var.service_name
  location = var.region

  template {
    containers {
      image = var.image_uri
      ports {
        container_port = 8080
      }
    }
  }
}
  1. Outputs (what other steps need)
output "cloud_run_uri" {
  value = google_cloud_run_v2_service.website.uri
}
  1. Environment files

Use a terraform.tfvars (or dev.tfvars, prod.tfvars) per environment:

project_id   = "my-web-project-dev"
region       = "asia-southeast1"
service_name = "website"
image_uri    = "asia-southeast1-docker.pkg.dev/my-web-project-dev/web-repo/website:v1"
  1. Execution cycle
terraform fmt
terraform init
terraform validate
terraform plan -var-file=dev.tfvars
terraform apply -var-file=dev.tfvars

That is the full writing loop: write small blocks, validate early, plan before apply, then promote the same code to the next environment by changing only tfvars.

Keep Terraform files boring and predictable:

terraform/
  providers.tf
  variables.tf
  main.tf
  outputs.tf
  dev.tfvars
  prod.tfvars

Minimal example:

  • providers.tf: provider and Terraform version.
  • variables.tf: typed inputs (project_id, region, image_uri).
  • main.tf: Cloud Run + Artifact Registry + IAM resources.
  • outputs.tf: service URL, useful IDs.
  • *.tfvars: environment-specific values only.

This layout makes onboarding easier and reduces "where is that config?" confusion.

Run:

terraform init
terraform plan
terraform apply

Set container_port to the port the container actually listens on (here 8080, same as nginx and the standalone example with PORT=8080).

Now infra and deploy config are reviewable in Git, same as application code.

Design choices that save time later

Pin every deployment by image tag

Do not deploy floating tags like latest in production. Use explicit tags (v1, v2, or commit SHA) so release history is deterministic.

Keep environments separate

Use separate Terraform state or projects for dev/stage/prod. This avoids accidental cross-environment impact.

Make rollback a first-class path

Because Cloud Run deploys from image tags, rollback should be one fast command or one Terraform variable change.

Trade-offs (when this may be too much)

This setup is strong, but not always needed.

  • For tiny prototypes, full Terraform can feel heavy.
  • Teams new to IaC need onboarding time.
  • Strict release controls add process overhead.

If your app is still very early, start small and introduce Terraform once services or environments grow.

Practical checklist

  • Build image in CI, not on local machine
  • Push only versioned tags to Artifact Registry
  • Deploy Cloud Run via Terraform, not console clicks
  • Keep runtime variables in secure config/secrets
  • Verify rollback path before production release

Final note

Cloud Run, Terraform, Artifact Registry, and Cloud Build work best as one delivery system, not separate tools.
Once connected, your deployments become predictable, reviewable, and easier to operate under pressure.