IaC GCP

Terraform Module: GCP Artifact Registry — One Reusable Repository Pattern with CMEK, Cleanup Policies and IAM

Quick take — Build a production-ready Terraform module for GCP Artifact Registry using google_artifact_registry_repository — standard/remote/virtual repos, CMEK encryption, cleanup policies, reader/writer IAM bindings and clean outputs from one var-driven interface. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "artifact_registry" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-artifact-registry?ref=v1.0.0"

  project_id    = "..."  # GCP project ID that owns the repository.
  location      = "..."  # Region/multi-region for the repo (e.g. `asia-south1`, `…
  repository_id = "..."  # Repository ID; lowercase letter start, letters/digits/h…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Artifact Registry is Google Cloud’s managed, regional store for build outputs: Docker/OCI images, Maven and npm packages, Python wheels, Go modules, Apt and Yum packages, plus generic blobs. It superseded the older Container Registry (gcr.io), is the default push target for Cloud Build and the default pull source for GKE and Cloud Run, and bills on storage (GB-month) plus network egress. The unit of management is the repository — a regional, single-format container addressed as LOCATION-docker.pkg.dev/PROJECT/REPOSITORY, where every repo carries its own IAM policy, optional customer-managed encryption (CMEK), and optional automatic cleanup rules.

Creating one repo by hand is trivial; running them consistently across a fleet is not. The same questions come back every time: which format, which mode (standard vs. remote pull-through cache vs. virtual aggregate), is it CMEK-encrypted, do old/untagged images get garbage-collected, and who can push versus only pull. Wrapping google_artifact_registry_repository in a module gives you one opinionated interface that:

The result: every repo in the org is created the same way, CMEK and cleanup are not forgotten, and “who can push to prod” is a reviewed list rather than a console click.

When to use it

Reach for this module when:

Skip it (or extend it) if you only need the legacy gcr.io hosts (use the Container Registry redirect instead), or if you need cross-region replication of a single logical repo — Artifact Registry repositories are regional, and multi-region distribution is a separate design (per-region repos or us/eu/asia multi-region locations) rather than a flag on one resource.

Module structure

terraform-module-gcp-artifact-registry/
├── versions.tf       # provider + Terraform version pins
├── main.tf           # google_artifact_registry_repository + IAM members
├── variables.tf      # var-driven inputs with validations
└── outputs.tf        # repo id/name + registry hostname/path

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Per-format registry host. Docker/KFP use "-docker.pkg.dev"; everything
  # else resolves on the same host but is pulled with format-native clients.
  registry_host = "${var.location}-docker.pkg.dev"

  # Fully-qualified path consumers push/pull, e.g.
  # asia-south1-docker.pkg.dev/kloudvin-prod/app-images
  repository_path = "${local.registry_host}/${var.project_id}/${var.repository_id}"

  # De-duplicated reader/writer principal sets, expanded to one IAM member each.
  reader_members = { for m in distinct(var.reader_members) : m => m }
  writer_members = { for m in distinct(var.writer_members) : m => m }
}

resource "google_artifact_registry_repository" "this" {
  project       = var.project_id
  location      = var.location
  repository_id = var.repository_id
  description   = var.description
  format        = var.format
  mode          = var.mode

  labels = var.labels

  # Customer-managed encryption. Omitting kms_key_name uses Google-managed keys.
  kms_key_name = var.kms_key_name

  # Docker-specific knobs (immutable tags) — only emitted for DOCKER repos.
  dynamic "docker_config" {
    for_each = var.format == "DOCKER" && var.docker_immutable_tags != null ? [1] : []
    content {
      immutable_tags = var.docker_immutable_tags
    }
  }

  # Pull-through cache config — only for REMOTE_REPOSITORY mode.
  dynamic "remote_repository_config" {
    for_each = var.mode == "REMOTE_REPOSITORY" && var.remote_config != null ? [1] : []
    content {
      description = var.remote_config.description

      dynamic "docker_repository" {
        for_each = var.format == "DOCKER" ? [1] : []
        content {
          public_repository = var.remote_config.docker_public_repository
        }
      }

      dynamic "maven_repository" {
        for_each = var.format == "MAVEN" ? [1] : []
        content {
          public_repository = var.remote_config.maven_public_repository
        }
      }

      dynamic "python_repository" {
        for_each = var.format == "PYTHON" ? [1] : []
        content {
          public_repository = var.remote_config.python_public_repository
        }
      }

      dynamic "npm_repository" {
        for_each = var.format == "NPM" ? [1] : []
        content {
          public_repository = var.remote_config.npm_public_repository
        }
      }
    }
  }

  # Virtual aggregate config — only for VIRTUAL_REPOSITORY mode.
  dynamic "virtual_repository_config" {
    for_each = var.mode == "VIRTUAL_REPOSITORY" ? [1] : []
    content {
      dynamic "upstream_policies" {
        for_each = var.virtual_upstream_policies
        content {
          id         = upstream_policies.value.id
          repository = upstream_policies.value.repository
          priority   = upstream_policies.value.priority
        }
      }
    }
  }

  # Automatic garbage collection of old/untagged versions.
  dynamic "cleanup_policies" {
    for_each = var.cleanup_policies
    content {
      id     = cleanup_policies.value.id
      action = cleanup_policies.value.action

      dynamic "condition" {
        for_each = cleanup_policies.value.condition != null ? [cleanup_policies.value.condition] : []
        content {
          tag_state             = condition.value.tag_state
          tag_prefixes          = condition.value.tag_prefixes
          older_than            = condition.value.older_than
          newer_than            = condition.value.newer_than
          package_name_prefixes = condition.value.package_name_prefixes
        }
      }

      dynamic "most_recent_versions" {
        for_each = cleanup_policies.value.most_recent_versions != null ? [cleanup_policies.value.most_recent_versions] : []
        content {
          keep_count            = most_recent_versions.value.keep_count
          package_name_prefixes = most_recent_versions.value.package_name_prefixes
        }
      }
    }
  }

  # When true, cleanup_policies are evaluated and logged but DELETE nothing.
  cleanup_policy_dry_run = var.cleanup_policy_dry_run
}

# Read-only access (pull). Maps to roles/artifactregistry.reader on the repo.
resource "google_artifact_registry_repository_iam_member" "reader" {
  for_each = local.reader_members

  project    = google_artifact_registry_repository.this.project
  location   = google_artifact_registry_repository.this.location
  repository = google_artifact_registry_repository.this.name
  role       = "roles/artifactregistry.reader"
  member     = each.value
}

# Read-write access (push). Maps to roles/artifactregistry.writer on the repo.
resource "google_artifact_registry_repository_iam_member" "writer" {
  for_each = local.writer_members

  project    = google_artifact_registry_repository.this.project
  location   = google_artifact_registry_repository.this.location
  repository = google_artifact_registry_repository.this.name
  role       = "roles/artifactregistry.writer"
  member     = each.value
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns the repository."
  type        = string
}

variable "location" {
  description = "Region or multi-region for the repository (e.g. \"asia-south1\", \"us\", \"europe\"). Repositories are regional; pick the location closest to your builders and runtimes."
  type        = string
}

variable "repository_id" {
  description = "Repository ID (the last path segment). Lowercase letters, digits and hyphens; must start with a letter and not end with a hyphen."
  type        = string

  validation {
    condition     = can(regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$", var.repository_id))
    error_message = "repository_id must start with a lowercase letter, contain only lowercase letters/digits/hyphens, not end with a hyphen, and be at most 63 chars."
  }
}

variable "description" {
  description = "Human-readable description shown in the console and API."
  type        = string
  default     = "Managed by Terraform"
}

variable "format" {
  description = "Artifact format for the repository. Immutable after creation."
  type        = string
  default     = "DOCKER"

  validation {
    condition     = contains(["DOCKER", "MAVEN", "NPM", "PYTHON", "GO", "APT", "YUM", "KFP", "GENERIC"], var.format)
    error_message = "format must be one of: DOCKER, MAVEN, NPM, PYTHON, GO, APT, YUM, KFP, GENERIC."
  }
}

variable "mode" {
  description = "Repository mode: STANDARD_REPOSITORY (you push/pull), REMOTE_REPOSITORY (pull-through cache of an upstream), or VIRTUAL_REPOSITORY (aggregate of upstreams)."
  type        = string
  default     = "STANDARD_REPOSITORY"

  validation {
    condition     = contains(["STANDARD_REPOSITORY", "REMOTE_REPOSITORY", "VIRTUAL_REPOSITORY"], var.mode)
    error_message = "mode must be one of: STANDARD_REPOSITORY, REMOTE_REPOSITORY, VIRTUAL_REPOSITORY."
  }
}

variable "kms_key_name" {
  description = "Full resource ID of a Cloud KMS CryptoKey for CMEK encryption (projects/P/locations/L/keyRings/R/cryptoKeys/K). Null = Google-managed keys. The Artifact Registry service agent must have roles/cloudkms.cryptoKeyEncrypterDecrypter on the key BEFORE create."
  type        = string
  default     = null

  validation {
    condition     = var.kms_key_name == null || can(regex("^projects/.+/locations/.+/keyRings/.+/cryptoKeys/.+$", var.kms_key_name))
    error_message = "kms_key_name must be a full CryptoKey resource ID: projects/P/locations/L/keyRings/R/cryptoKeys/K."
  }
}

variable "docker_immutable_tags" {
  description = "For DOCKER repos: if true, tags cannot be moved or overwritten once pushed (prevents :latest mutation). Null leaves the provider default. Ignored for non-Docker formats."
  type        = bool
  default     = null
}

variable "remote_config" {
  description = <<-EOT
    Pull-through cache settings; used only when mode = REMOTE_REPOSITORY.
    Set the ONE *_public_repository matching var.format. Allowed values are
    the provider enums, e.g. docker_public_repository = "DOCKER_HUB",
    maven_public_repository = "MAVEN_CENTRAL", python_public_repository = "PYPI",
    npm_public_repository = "NPMJS".
  EOT
  type = object({
    description              = optional(string, "Remote pull-through cache")
    docker_public_repository = optional(string)
    maven_public_repository  = optional(string)
    python_public_repository = optional(string)
    npm_public_repository    = optional(string)
  })
  default = null
}

variable "virtual_upstream_policies" {
  description = "Ordered upstream repositories for a VIRTUAL_REPOSITORY. Each item: id (label), repository (full resource ID of an upstream repo), priority (higher = preferred). Used only when mode = VIRTUAL_REPOSITORY."
  type = list(object({
    id         = string
    repository = string
    priority   = number
  }))
  default = []
}

variable "cleanup_policies" {
  description = <<-EOT
    Automatic version cleanup rules. Each policy has an id and an action of
    "DELETE" or "KEEP", plus EITHER a condition block OR a most_recent_versions
    block. Example:
      [
        {
          id     = "delete-untagged-after-30d"
          action = "DELETE"
          condition = {
            tag_state  = "UNTAGGED"
            older_than = "2592000s"
          }
        },
        {
          id     = "keep-10-newest-tagged"
          action = "KEEP"
          most_recent_versions = { keep_count = 10 }
        }
      ]
  EOT
  type = list(object({
    id     = string
    action = string
    condition = optional(object({
      tag_state             = optional(string)
      tag_prefixes          = optional(list(string))
      older_than            = optional(string)
      newer_than            = optional(string)
      package_name_prefixes = optional(list(string))
    }))
    most_recent_versions = optional(object({
      keep_count            = optional(number)
      package_name_prefixes = optional(list(string))
    }))
  }))
  default = []

  validation {
    condition     = alltrue([for p in var.cleanup_policies : contains(["DELETE", "KEEP"], p.action)])
    error_message = "Each cleanup policy action must be either \"DELETE\" or \"KEEP\"."
  }

  validation {
    condition = alltrue([
      for p in var.cleanup_policies :
      (p.condition != null) != (p.most_recent_versions != null)
    ])
    error_message = "Each cleanup policy must set exactly one of condition or most_recent_versions (not both, not neither)."
  }
}

variable "cleanup_policy_dry_run" {
  description = "If true, cleanup_policies are evaluated and logged but never delete anything. Run dry-run first in prod, confirm in logs, then flip to false."
  type        = bool
  default     = true
}

variable "reader_members" {
  description = "Principals granted roles/artifactregistry.reader (pull) on this repo, e.g. [\"serviceAccount:gke-nodes@kloudvin-prod.iam.gserviceaccount.com\", \"group:developers@kloudvin.com\"]."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.reader_members : can(regex("^(user|group|serviceAccount|domain|allUsers|allAuthenticatedUsers):?", m))])
    error_message = "Each reader member must be a valid IAM principal (user:, group:, serviceAccount:, domain:, allUsers, allAuthenticatedUsers)."
  }
}

variable "writer_members" {
  description = "Principals granted roles/artifactregistry.writer (push) on this repo, e.g. [\"serviceAccount:cloud-build@kloudvin-prod.iam.gserviceaccount.com\"]. Keep this list tight."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.writer_members : can(regex("^(user|group|serviceAccount|domain):", m))])
    error_message = "Each writer member must be user:, group:, serviceAccount:, or domain: — wildcards (allUsers/allAuthenticatedUsers) are not allowed for push."
  }
}

variable "labels" {
  description = "Labels applied to the repository for cost/ownership reporting."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Fully-qualified resource id of the repository (projects/P/locations/L/repositories/R)."
  value       = google_artifact_registry_repository.this.id
}

output "name" {
  description = "Short repository name (the repository_id), used as the repository field in IAM and gcloud commands."
  value       = google_artifact_registry_repository.this.name
}

output "location" {
  description = "Location (region/multi-region) the repository lives in."
  value       = google_artifact_registry_repository.this.location
}

output "format" {
  description = "Artifact format of the repository."
  value       = google_artifact_registry_repository.this.format
}

output "registry_host" {
  description = "Registry hostname to authenticate against, e.g. asia-south1-docker.pkg.dev (use with gcloud auth configure-docker)."
  value       = local.registry_host
}

output "repository_path" {
  description = "Fully-qualified path consumers push/pull, e.g. asia-south1-docker.pkg.dev/kloudvin-prod/app-images. Append /IMAGE:TAG for Docker."
  value       = local.repository_path
}

output "repository_gcp_resource" {
  description = "The full google_artifact_registry_repository object for advanced composition."
  value       = google_artifact_registry_repository.this
}

How to use it

A standard production Docker repository with CMEK, immutable tags, a cleanup policy that prunes untagged images after 30 days while keeping the 10 newest tagged versions, and push/pull granted to the CI account and GKE nodes respectively:

module "artifact_registry" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-artifact-registry?ref=v1.0.0"

  project_id    = "kloudvin-prod"
  location      = "asia-south1"
  repository_id = "app-images"
  description   = "Production application container images"
  format        = "DOCKER"
  mode          = "STANDARD_REPOSITORY"

  # CMEK — the AR service agent already holds encrypterDecrypter on this key.
  kms_key_name          = "projects/kloudvin-prod/locations/asia-south1/keyRings/artifacts/cryptoKeys/registry"
  docker_immutable_tags = true

  cleanup_policies = [
    {
      id     = "delete-untagged-after-30d"
      action = "DELETE"
      condition = {
        tag_state  = "UNTAGGED"
        older_than = "2592000s" # 30 days
      }
    },
    {
      id     = "keep-10-newest-tagged"
      action = "KEEP"
      most_recent_versions = {
        keep_count = 10
      }
    }
  ]
  cleanup_policy_dry_run = false

  writer_members = [
    "serviceAccount:cloud-build@kloudvin-prod.iam.gserviceaccount.com",
  ]
  reader_members = [
    "serviceAccount:gke-nodes@kloudvin-prod.iam.gserviceaccount.com",
    "group:platform@kloudvin.com",
  ]

  labels = {
    env  = "prod"
    team = "platform"
  }
}

A downstream resource that consumes an output — deploying a Cloud Run service whose image lives in the repo, built from the module’s repository_path so the registry address is never hard-coded:

# Pin the exact image by combining the module's path output with an image+tag.
locals {
  api_image = "${module.artifact_registry.repository_path}/checkout-api:1.8.3"
}

resource "google_cloud_run_v2_service" "checkout_api" {
  project  = "kloudvin-prod"
  name     = "checkout-api"
  location = "asia-south1"

  template {
    containers {
      image = local.api_image
    }
    # The runtime SA must be in reader_members above (or hold reader at a higher scope).
    service_account = "gke-nodes@kloudvin-prod.iam.gserviceaccount.com"
  }
}

# Hand the registry host to a build pipeline / docs without copy-paste.
output "configure_docker_host" {
  description = "Run: gcloud auth configure-docker <this> to push/pull."
  value       = module.artifact_registry.registry_host
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/artifact_registry/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-artifact-registry?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  location = "..."
  repository_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/artifact_registry && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string Yes GCP project ID that owns the repository.
location string Yes Region/multi-region for the repo (e.g. asia-south1, us).
repository_id string Yes Repository ID; lowercase letter start, letters/digits/hyphens, max 63 chars, no trailing hyphen.
description string "Managed by Terraform" No Description shown in console/API.
format string "DOCKER" No Artifact format; one of DOCKER, MAVEN, NPM, PYTHON, GO, APT, YUM, KFP, GENERIC. Immutable.
mode string "STANDARD_REPOSITORY" No STANDARD, REMOTE (pull-through cache), or VIRTUAL (aggregate).
kms_key_name string null No Full CryptoKey ID for CMEK; null = Google-managed keys.
docker_immutable_tags bool null No DOCKER only: prevent tag overwrite/movement when true.
remote_config object(...) null No Pull-through cache upstream; used only when mode = REMOTE_REPOSITORY.
virtual_upstream_policies list(object({id,repository,priority})) [] No Ordered upstreams; used only when mode = VIRTUAL_REPOSITORY.
cleanup_policies list(object(...)) [] No Automatic DELETE/KEEP version rules with condition or most_recent_versions.
cleanup_policy_dry_run bool true No Evaluate cleanup rules without deleting; flip to false to enforce.
reader_members list(string) [] No Principals granted roles/artifactregistry.reader (pull).
writer_members list(string) [] No Principals granted roles/artifactregistry.writer (push).
labels map(string) {} No Labels applied to the repository.

Outputs

Name Description
id Fully-qualified resource id (projects/P/locations/L/repositories/R).
name Short repository name (repository_id); used in IAM and gcloud commands.
location Location (region/multi-region) of the repository.
format Artifact format of the repository.
registry_host Registry hostname to authenticate against (e.g. asia-south1-docker.pkg.dev).
repository_path Fully-qualified push/pull path (e.g. asia-south1-docker.pkg.dev/kloudvin-prod/app-images).
repository_gcp_resource The full google_artifact_registry_repository object for advanced composition.

Enterprise scenario

KloudVin standardizes its container supply chain on this module. In each environment project, the platform team instantiates a STANDARD_REPOSITORY Docker repo (app-images) with docker_immutable_tags = true, CMEK on a per-region KMS key, and a cleanup policy that deletes untagged layers after 30 days while keeping the 10 newest tagged versions — cleanup_policy_dry_run runs for one sprint, the deletion logs are reviewed, then it flips to enforce. Alongside it they stand up a REMOTE_REPOSITORY mirroring Docker Hub so GKE pulls of public base images hit a cached, egress-controlled endpoint instead of the internet, and a VIRTUAL_REPOSITORY that fronts both so every workload pulls from a single hostname. Only the Cloud Build service account appears in writer_members; GKE node and Cloud Run runtime SAs get reader_members, so push rights to production images are an auditable, PR-reviewed list.

Best practices

TerraformGCPArtifact RegistryModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading