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
| Tool | Its job | Why it matters |
|---|---|---|
| Cloud Build | Builds and pushes the image | Builds are repeatable and come straight from source |
| Artifact Registry | Stores versioned images | You can see exactly what was deployed, and roll back |
| Terraform | Creates the infrastructure | Dev, staging, and production are set up the same way |
| Cloud Run | Runs the container | Scales 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
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 asproject_id,region, andimage_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.