Shipping to Cloud Run with Terraform and Artifact Registry

Why this stack works

Writing the code is only half of shipping a web app. The other half is delivery: building it, storing each version, deploying it, and updating it safely.

On Google Cloud, these four tools cover that whole path:

  • Cloud Build builds the container image.
  • Artifact Registry stores each version of the image.
  • Terraform describes the infrastructure as code.
  • Cloud Run runs the app and scales it automatically.

That gives you one pipeline, a clear owner for each step, and far fewer manual deploys.

What each tool does

ToolIts jobWhy it matters
Cloud BuildBuilds and pushes the imageBuilds are repeatable and come straight from source
Artifact RegistryStores versioned imagesYou can see exactly what was deployed, and roll back
TerraformCreates the infrastructureDev, staging, and production are set up the same way
Cloud RunRuns the containerScales automatically, and you pay for what you use

When Terraform is worth it

Terraform pays off once infrastructure starts repeating across environments or people. Use it when:

  • You have dev, staging, and production, and want them all set up the same way.
  • Your team wants infrastructure changes reviewed in pull requests, like application code.
  • You need a repeatable setup for Cloud Run, IAM, Artifact Registry, networking, and secrets.
  • You want a clear record of what changed, when, and by whom, and an easy way to undo it.

For a single demo app, clicking through the console is fine. Once you have more than one environment, Terraform quickly earns its keep.

The flow from Git to production

Cloud delivery flow from Git to Cloud Run

There are three paths through this setup:

  • Build: a push to Git triggers Cloud Build, which builds the Docker image and pushes it to Artifact Registry.
  • Deploy: Terraform applies the infrastructure and points Cloud Run at the new image tag.
  • Rollback: set the image tag back to the last good version and run Terraform again.

The key split is that build and registry handle the app's versions, while Terraform handles the infrastructure it runs on.

Example: deploying a Next.js app

Here's a realistic example: Next.js is built inside Docker, Cloud Build pushes the image to Artifact Registry, and the image runs on Cloud Run.

A) Decide how Next.js runs in the container

Option 1: static export (output: "export"). This suits marketing sites, docs, and blogs that can be plain HTML in an out/ folder. Serve it with nginx inside the container. Cloud Run sends traffic to the port in the PORT variable, which is 8080 by default, so nginx should listen there.

Option 2: Node server (output: "standalone"). Use this when you need server-side rendering, route handlers, or other dynamic features. The build produces .next/standalone, and you run node server.js, which listens on the PORT that Cloud Run sets.

Pick one option per project. Mixing both in one Dockerfile just makes the build confusing.

B) Dockerfile for a static export with nginx

# --- build ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# next.config must set output: "export", so `next build` writes to out/
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, listening on 8080 to match Cloud Run's default:

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

  location / {
    try_files $uri $uri/ $uri.html =404;
  }

  error_page 404 /404.html;
}

A static export produces a real page for each route, plus a 404.html, so missing pages should return a proper 404 instead of quietly showing the home page.

.dockerignore, to keep the build context small:

node_modules
.next
out
.git
.env*

Test it locally:

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

C) Dockerfile for a Next.js standalone server

Set output: "standalone" in next.config, 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 HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
CMD ["node", "server.js"]

Cloud Run sets PORT for you, and the standalone server reads it, so there's no need to hard-code a port in the image.

D) cloudbuild.yaml: build the image and push it

The substitutions match the gcloud builds submit command below. Change the project and names to fit yours.

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}"

The service account Cloud Build runs as needs permission to push to Artifact Registry, for example the Artifact Registry Writer role on the repository.

E) Run the build

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

This gives you a tagged image such as asia-southeast1-docker.pkg.dev/my-web-project/web-repo/website:v1, which you can deploy to any environment.

F) Why images live in Artifact Registry

  • Every release has a clear version.
  • Rolling back is easy: deploy an older tag.
  • Vulnerability scanning and access control are in one place.

G) Writing the Terraform

Work in this order: providers, then variables, then resources, then outputs.

1. Provider and state

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"
    }
  }

  # Keep state in a shared bucket, not on someone's laptop.
  backend "gcs" {
    bucket = "my-web-project-tfstate"
    prefix = "website"
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

Pin the provider to the major version you've tested. And store state remotely, in a Cloud Storage bucket with versioning turned on. If state lives on one person's laptop, nobody else can safely run apply.

2. Variables (the values that change between environments)

variable "project_id" { type = string }
variable "region"     { type = string }
variable "service_name" {
  type    = string
  default = "website"
}
variable "image_uri" { type = string }

3. Resources (what Terraform manages)

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
      }
    }
  }
}

# Cloud Run services are private by default. For a public website,
# allow unauthenticated requests explicitly.
resource "google_cloud_run_v2_service_iam_member" "public" {
  name     = google_cloud_run_v2_service.website.name
  location = google_cloud_run_v2_service.website.location
  role     = "roles/run.invoker"
  member   = "allUsers"
}

Set container_port to the port your container really listens on. Here that's 8080, the same as nginx and Cloud Run's default PORT.

4. Outputs (what later steps need)

output "cloud_run_uri" {
  value = google_cloud_run_v2_service.website.uri
}

5. One variables file per environment, such as dev.tfvars and prod.tfvars:

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"

6. The usual cycle

terraform fmt
terraform init
terraform validate
terraform plan -var-file=dev.tfvars
terraform apply -var-file=dev.tfvars

Write small pieces, validate early, always read the plan before you apply, and promote the same code to the next environment by changing only the .tfvars file.

One more thing: pick a single tool to deploy new images. If Terraform owns the Cloud Run service, deploy by changing image_uri and running apply. If you also deploy with gcloud run deploy, Terraform will see the difference on its next run and try to change it back.

H) A simple folder layout

terraform/
  providers.tf
  variables.tf
  main.tf
  outputs.tf
  dev.tfvars
  prod.tfvars
  • providers.tf: the provider, the Terraform version, and the state backend.
  • variables.tf: typed inputs such as project_id, region, and image_uri.
  • main.tf: Cloud Run, Artifact Registry, and IAM.
  • outputs.tf: the service URL and other useful IDs.
  • *.tfvars: the values for each environment, and nothing else.

A predictable layout makes onboarding easier and saves everyone from asking where a setting lives. Now your infrastructure and deploy settings are reviewed in Git, the same way your application code is.

Choices that save time later

Deploy a specific image tag every time

Don't deploy a moving tag like latest to production. Use explicit tags (v1, v2, or the commit SHA) so you always know what's running.

Keep environments separate

Use separate Terraform state for each environment, and ideally separate Google Cloud projects. A mistake in dev then can't touch production.

Make rollback easy

Because Cloud Run deploys by image tag, rolling back should be one variable change and one apply.

When this is overkill

This setup is solid, but you don't always need all of it:

  • For a tiny prototype, full Terraform can feel heavy.
  • A team new to infrastructure as code needs time to learn it.
  • Strict release controls add process.

If the app is still very early, start small and bring in Terraform once you have more services or environments.

Checklist

  • Images are built in CI, not on a laptop.
  • Only versioned tags are pushed to Artifact Registry.
  • Cloud Run is deployed through Terraform, not the console.
  • Terraform state is stored remotely, one state per environment.
  • Runtime settings and secrets are in Secret Manager or secure config, not in the image.
  • The rollback path has been tested before the first production release.

Cloud Build, Artifact Registry, Terraform, and Cloud Run work best together as one delivery system. Set up this way, deploys stay predictable, and every change can be reviewed when something breaks at 2am.