Terraform Import GCP Cloud Run Deployments

draft
April 1, 2026other721 words · 4 min read
#gcp#serverless#cloud#terraform

Table of Contents

Importing Cloud Run Deployment

Importing a manually created resource into Terraform is a common move as I scale after PoC or MVP.

Assuming you already have manually deployed application with gcloud command:

gcloud run deploy ${SERVICE_NAME} \
  --image ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${AR_REPO_NAME}/${IMAGE_NAME}:${CI_COMMIT_SHORT_SHA} \
  --region ${GCP_REGION} \
  --platform managed \
  --allow-unauthenticated \
  --network "vpc" \
  --subnet "subnet-app-fra" \
  --vpc-egress all-traffic \
  --service-account="gitlab-pusher-sa@${GCP_PROJECT_ID}.iam.gserviceaccount.com" \
  --add-cloudsql-instances ${DB_INSTANCE_NAME} \
  --env-vars-file ${DEPLOY_ENV_VARS_FILE} \
  --memory 2Gi \
  --cpu 1 \
  --min-instances 1 \
  --max-instances 2 \
  --quiet

And you want to import this deployment in your Terraform stage.

Prepare the Terraform blocks:

resource "google_cloud_run_v2_service" "product_api_stage_service" {
  name     = "product-api" # Match ${SERVICE_NAME} in deployment scripts
  location = "europe-west3"
  ingress  = "INGRESS_TRAFFIC_ALL"

  template {
    service_account = google_service_account.gitlab_pusher.email # defined in artifact registry declaration file

    max_instance_request_concurrency = 80

    scaling {
      min_instance_count = 1
      max_instance_count = 2
    }

    # Networking Direct VPC Egress
    vpc_access {
      network_interfaces {
        network    = "vpc"
        subnetwork = "subnet-app-fra"
      }
      egress = "ALL_TRAFFIC" # Match --vpc-egress in Gitlab CICD
    }

    containers {
      name  = "main"
      image = "europe-west3-docker.pkg.dev/product-dev/app-repo/image:placeholder" # Managed by GitLab

      resources {
        cpu_idle = true
        limits = {
          cpu    = "1"
          memory = "2Gi"
        }
        startup_cpu_boost = true
      }

      # Environment Variables
      # I put a placeholder here because the pipeline manages the real values
      env {
        name  = "MANAGED_BY"
        value = "gitlab-ci"
      }

      # Cloud SQL Connection
      volume_mounts {
        name       = "cloudsql"
        mount_path = "/cloudsql"
      }
    }

    volumes {
      name = "cloudsql"
      cloud_sql_instance {
        instances = ["product-dev:europe-west3:product-api-stage-pg"]
      }
    }
  }

  # CRITICAL: This allows GitLab to update the image and env vars
  lifecycle {
    ignore_changes = [
      client,
      client_version,
      template[0].labels,
      template[0].containers[0].image,
      template[0].containers[0].env,
      # template[0].containers[0].resources,
      # template[0].scaling,
    ]
  }
}

# Public Access Match --allow-unauthenticated
resource "google_cloud_run_v2_service_iam_member" "public_access" {
  location = google_cloud_run_v2_service.product_api_stage_service.location
  name     = google_cloud_run_v2_service.product_api_stage_service.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

If you are managing everything with terraform-mgr but the actual Cloud Run app runs as gitlab-pusher-sa, you’ve run into a "Delegated Identity" issue.

When you import the service, Terraform tries to "claim" the configuration. Because that configuration says "Run as gitlab-pusher-sa," Google Cloud requires the person doing the claiming terraform-mgr to have permission to "use" that service account.

gcloud iam service-accounts add-iam-policy-binding \
  gitlab-pusher-sa@product-dev.iam.gserviceaccount.com \
  --member="serviceAccount:terraform-mgr@product-dev.iam.gserviceaccount.com" \
  --role="roles/iam.serviceAccountUser"

This will not give gitlab-pusher-sa permissions that terraform-mgr has. This is a common point of confusion with GCP IAM, but permissions only flow in one direction. By running that command, you are granting the Terraform Manager (terraform-mgr) the ability to "use" the Gitlab CI/CD Worker (gitlab-pusher-sa). The Gitlab CI/CD Worker gets absolutely zero access to the Terraform Manager's permissions.

Add this block and run terraform plan:

import {
  to = google_cloud_run_v2_service.product_api_stage_service
  id = "projects/product-dev/locations/europe-west3/services/product-os-api"
}

If you see something like Plan: 1 to import, 0 to add, 1 to change, 0 to destroy.:

~ resources {
    - cpu_idle          = true -> null
    ~ limits            = {
        ~ "cpu"    = "1" -> "1000m"
          "memory" = "2Gi"
      }
    - startup_cpu_boost = true -> null
  }

You can edit your block to make it exactly reflect current deployment state and run terraform plan again.

Also, in this particular case to support the --allow-unauthenticated gcloud option you should add:

resource "google_cloud_run_v2_service_iam_member" "public_access" {
  location = google_cloud_run_v2_service.product_api_stage_service.location
  name     = google_cloud_run_v2_service.product_api_stage_service.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

Terraform will not find any existing resource for that one. IAM bindings in Terraform are idempotent. If you "create" an IAM member that already exists, Google’s API usually just says "Okay, cool, it's already there." Terraform then just adds it to the state file and moves on.

Since you have the google_cloud_run_v2_service_iam_member block in your Terraform code, you no longer need the --allow-unauthenticated flag in your gcloud command.

Atomic Template Replacement

Whether you use gcloud run deploy or gcloud run services update, both commands behave the same way: they send a complete "Template" to Google Cloud to create a new Revision.

If your command only mentions one image/container, Google assumes the other containers (like your Prometheus collector) should be deleted. There is no "merge" feature in the CLI.

gcloud run services update ${SERVICE_NAME} \
  --region ${GCP_REGION} \
  --network "vpc" \
  --subnet "subnet-app-fra" \
  --vpc-egress all-traffic \
  --service-account="gitlab-pusher-sa@${GCP_PROJECT_ID}.iam.gserviceaccount.com" \
  --add-cloudsql-instances ${DB_INSTANCE_NAME} \
  --min-instances 1 \
  --max-instances 2 \
  --quiet \
  --container="main" \
  --image ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${AR_REPO_NAME}/${IMAGE_NAME}:${CI_COMMIT_SHORT_SHA} \
  --memory 2Gi \
  --cpu 1 \
  --env-vars-file ${DEPLOY_ENV_VARS_FILE}

Accoring to Google Documentation in multi-contrainer deployments all non-container specific parameters must be set upper and then you can specify container-related parameters.