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:
- Creates a repository of any format (
DOCKER,MAVEN,NPM,PYTHON,GO,APT,YUM,KFP,GENERIC) in any mode (STANDARD_REPOSITORY,REMOTE_REPOSITORY,VIRTUAL_REPOSITORY). - Optionally enables CMEK with a Cloud KMS key, and wires Docker-specific options like immutable tags.
- Attaches cleanup policies so untagged/old versions are deleted automatically instead of silently accruing storage cost — with a
cleanup_policy_dry_runsafety switch. - Grants reader and writer IAM to lists of principals via
google_artifact_registry_repository_iam_member, so the CI service account that pushes and the GKE nodes that pull are codified next to the repo. - Emits the repository id, name, and the fully-qualified registry hostname/path you actually
docker pushto, so downstream pipelines andgcloud auth configure-dockercalls have no copy-paste.
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:
- You need one or more Artifact Registry repositories codified, reviewed, and reproduced identically across
dev/stg/prodprojects. - Your platform team wants a golden Docker repo per environment with immutable tags, CMEK, and a cleanup policy baked in by default — not left to whoever clicks first.
- You run a remote pull-through cache (e.g. mirroring Docker Hub or a public Maven repo) to cut egress, dedupe pulls, and survive upstream outages, and want it expressed as code.
- You expose a virtual repository that fans a single pull endpoint across an upstream priority list (your internal standard repo first, a remote cache second).
- You want repo-scoped push/pull permissions managed as lists of service accounts and groups, promoted through the same pipeline as the repo itself.
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 config — live/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 config — live/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
- Make production tags immutable. Set
docker_immutable_tags = trueon standard Docker repos so a tag like1.8.3(or evenlatest) can never be silently re-pointed to a different digest after it ships. Combined with deploying by digest, this closes the most common “the image changed under us” supply-chain gap. - Always attach a cleanup policy, and dry-run it first. Artifact Registry bills per GB-month and untagged layers from every CI build accumulate fast. Add a DELETE-untagged-after-N-days rule plus a KEEP-newest-N rule, but ship with
cleanup_policy_dry_run = true, confirm what would be deleted in the logs, then set it tofalse— never enable destructive cleanup blind. - Grant the service agent KMS access before enabling CMEK. For
kms_key_nameto work, the Artifact Registry service agent (service-PROJECT_NUMBER@gcp-sa-artifactregistry.iam.gserviceaccount.com) needsroles/cloudkms.cryptoKeyEncrypterDecrypteron the key before the repo is created; wire that binding (and adepends_on) in the root module, or the apply fails. Pick a KMS key in the same region as the repo. - Keep
writer_memberstiny and prefer service accounts. Push access is how malicious or accidental images enter the registry. Typically only your CI/CD service account belongs inwriter_members; humans and runtimes getreader_members. The module’s validation already blocksallUsers/allAuthenticatedUsersfrom the writer list. - Use remote and virtual repos to control egress and resilience. A
REMOTE_REPOSITORYpull-through cache means a Docker Hub or PyPI outage (or rate limit) doesn’t stall your deploys, and aVIRTUAL_REPOSITORYgives every workload one stable pull endpoint while you reorganize upstreams behind it viapriority. - Name and place repos deliberately. Pick
locationclose to your builders and runtimes to cut pull latency and cross-region egress, give each repo a purpose-clearrepository_id(app-images,mvn-internal,dockerhub-cache) sinceformatis immutable and one repo holds one format, and drive cost/ownership reporting fromlabels(env,team,cost-center).