Artifact Registry & Cloud Build
Verified against Google Cloud SDK 553.0.0, flags verified via `gcloud artifacts repositories create · official docs
What it is and where it fits 🎯#
Artifact Registry is GCP's managed registry for container images and language packages (npm, Maven, Python,
Go, apt/yum); Cloud Build is GCP's managed CI build/test/push pipeline runner. They're covered together here
because they're almost always used as a pair, Cloud Build produces an artifact, Artifact Registry stores it,
and every deploy on pages 02, 05, and 10 pulls from exactly this registry. Page 03 already showed
gcloud builds submit in passing to build from a Dockerfile; this page covers both services properly,
repositories, IAM, cleanup policies, and the trigger-based pipelines Cloud Build is actually built around.
Core concepts: how an image gets from source to a running workload#
Two products, one pipeline: Cloud Build never stores the artifact itself, it always pushes to a registry
(Artifact Registry, in every current example, Container Registry's gcr.io is deprecated), and a deploy
step then pulls from that same registry by digest or tag. Tagging by the Git commit SHA (shown in the
diagram) rather than latest is what makes a specific deployed revision traceable back to the exact source
commit that produced it, worth adopting as a default rather than an afterthought.
Creating an Artifact Registry repository#
gcloud artifacts repositories create my-app-images \
--repository-format=docker --location=us-central1 \
--description="Container images for my-app" \
--immutable-tags
gcloud artifacts repositories create my-app-npm \
--repository-format=npm --location=us-central1
gcloud artifacts repositories list --location=us-central1
gcloud artifacts repositories describe my-app-images --location=us-central1--repository-format is fixed at creation, docker, npm, python, maven, go, apt, yum, one
repository holds one format, mixing package types means separate repositories, not one shared one.
--immutable-tags rejects re-pushing an existing tag entirely, a genuinely strong guardrail against the
classic "someone force-pushed over :latest and now nobody knows what's actually running" failure mode,
worth enabling on any repository backing a production deploy pipeline.
Pushing and pulling images#
gcloud auth configure-docker us-central1-docker.pkg.dev # from page 00 — registers gcloud as the credential helper
docker build -t us-central1-docker.pkg.dev/my-project-id/my-app-images/api:$(git rev-parse --short HEAD) .
docker push us-central1-docker.pkg.dev/my-project-id/my-app-images/api:$(git rev-parse --short HEAD)
gcloud artifacts docker tags list us-central1-docker.pkg.dev/my-project-id/my-app-images/api
gcloud artifacts docker images list us-central1-docker.pkg.dev/my-project-id/my-app-images \
--include-tags --filter="tags:*"The registry hostname is <region>-docker.pkg.dev, distinct from the older, now-deprecated gcr.io
Container Registry hostname a lot of existing tutorials and Stack Overflow answers still reference, new
setups should target Artifact Registry's own hostname from the start. docker itself performs the actual
push/pull, gcloud artifacts is for repository administration and inspecting what's already there, not a
substitute for docker push/docker pull.
Access control and cleanup policies#
gcloud artifacts repositories add-iam-policy-binding my-app-images \
--location=us-central1 \
--member="serviceAccount:ci@my-project-id.iam.gserviceaccount.com" \
--role="roles/artifactregistry.writer"
gcloud artifacts repositories add-iam-policy-binding my-app-images \
--location=us-central1 \
--member="serviceAccount:deployer@my-project-id.iam.gserviceaccount.com" \
--role="roles/artifactregistry.reader"
cat > cleanup-policy.json <<'EOF'
[
{
"name": "delete-untagged-after-30d",
"action": {"type": "Delete"},
"condition": {"tagState": "UNTAGGED", "olderThan": "2592000s"}
},
{
"name": "keep-last-20-tagged",
"action": {"type": "Keep"},
"mostRecentVersions": {"tagState": "TAGGED", "keepCount": 20}
}
]
EOF
gcloud artifacts repositories set-cleanup-policies my-app-images \
--location=us-central1 --policy=cleanup-policy.jsonSplitting writer (CI, which pushes new images) from reader (a deploy identity, which only ever pulls) is
the same least-privilege split as Secret Manager's secretAccessor on page 08, a deploy identity almost
never needs to push. Without a cleanup policy, a repository grows unbounded, every build's untagged
intermediate layers and every superseded tagged image stay forever, set-cleanup-policies is what actually
caps that cost automatically instead of relying on someone remembering to prune manually.
Cloud Build: running a build from local source#
gcloud builds submit --tag=us-central1-docker.pkg.dev/my-project-id/my-app-images/api:latest .
gcloud builds submit --config=cloudbuild.yaml --substitutions=_ENV=staging .
gcloud builds list --limit=5
gcloud builds log <build-id>
gcloud builds describe <build-id> --format="value(status)"--tag is shorthand for an implicit single-step docker build + push; --config points at a full
cloudbuild.yaml defining a real multi-step pipeline (build, run tests, push, trigger a deploy), the path
that scales past "just build one image." gcloud builds submit uploads the current directory as the build
context the same way docker build . does, run it from the repository root, and add a .gcloudignore
(mirroring .gitignore's syntax) to keep node_modules/.git/build artifacts out of the uploaded context.
Config file format: cloudbuild.yaml#
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/my-app-images/api:$SHORT_SHA', '.']
- name: 'gcr.io/cloud-builders/docker'
args: ['run', 'us-central1-docker.pkg.dev/$PROJECT_ID/my-app-images/api:$SHORT_SHA', 'npm', 'test']
- name: 'gcr.io/cloud-builders/docker'
args: ['push', 'us-central1-docker.pkg.dev/$PROJECT_ID/my-app-images/api:$SHORT_SHA']
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args: ['run', 'deploy', 'my-app', '--image=us-central1-docker.pkg.dev/$PROJECT_ID/my-app-images/api:$SHORT_SHA', '--region=us-central1']
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-app-images/api:$SHORT_SHA'
timeout: 1200sEach step is its own container invocation, $PROJECT_ID and $SHORT_SHA are built-in substitution
variables (no need to pass them via --substitutions), a custom one like _ENV shown earlier must start
with an underscore. The final images: block is what Cloud Build actually treats as this build's tracked
output artifacts, distinct from just running a docker push step, it's what shows up in the build's own
provenance metadata.
Build triggers: running automatically on push#
gcloud builds triggers create github \
--repo-owner=my-org --repo-name=my-app \
--branch-pattern="^main$" --build-config=cloudbuild.yaml \
--name=deploy-on-main --region=us-central1
gcloud builds triggers list --region=us-central1
gcloud builds triggers run deploy-on-main --branch=main --region=us-central1 # trigger manually, outside a pushA trigger is what turns Cloud Build from "something I invoke manually" into an actual CI/CD pipeline,
--branch-pattern is a regex, not a literal branch name, ^main$ matches only main exactly while a
pattern like ^release/.*$ would match every release branch. triggers run is genuinely useful for
re-running a pipeline against a specific ref without needing a real push to test it.
Private/dedicated worker pools#
gcloud builds worker-pools create my-private-pool \
--region=us-central1 \
--worker-machine-type=e2-standard-4 --worker-disk-size=100 \
--peered-network=projects/my-project-id/global/networks/prod-network \
--no-public-egress
gcloud builds submit --config=cloudbuild.yaml --worker-pool=my-private-pool .A private pool runs builds inside your own VPC (via --peered-network) instead of Google's shared build
infrastructure, the pattern for a build that needs to reach a private resource (an internal artifact mirror,
a database for integration tests) that isn't reachable from the public internet at all. --no-public-egress
additionally denies the pool any outbound internet access, appropriate for a fully locked-down build
environment where even outbound calls should be explicitly allowlisted rather than open by default.
Real-world scenario: image provenance from commit to production#
A team needs to answer "what exact code is running in production" reliably during an incident, not approximately:
# In cloudbuild.yaml, tag every image with the immutable commit SHA, never `latest`
# Image: us-central1-docker.pkg.dev/my-project/my-app-images/api:$SHORT_SHA
gcloud run services describe my-app --region=us-central1 --format="value(spec.template.spec.containers[0].image)"
# → us-central1-docker.pkg.dev/my-project/my-app-images/api:a1b2c3d
git show a1b2c3d --stat # the exact commit that produced the running imageBecause the deployed image tag is the short commit SHA, answering "what's running" is a direct
describe call away, with no separate deployment log or spreadsheet to cross-reference, the entire
mechanism relies on never deploying a :latest-tagged image, which --immutable-tags on the repository
(shown earlier) helps enforce structurally rather than by convention alone.
Real-world scenario: pre-flight checklist for a new production build pipeline#
- Repository created with
--immutable-tagsso a compromised or buggy pipeline can't silently overwrite a previously-deployed tag - A cleanup policy is in place before the repository accumulates its first month of untagged layers, not added retroactively once storage costs are already a problem
- The CI service account has
artifactregistry.writeronly, neverartifactregistry.admin, which would also allow deleting the repository itself - The build trigger's
--branch-patternmatches exactly the branches meant to auto-deploy, an overly broad pattern (.*catching every feature branch) is a real, easy mistake with real consequences
CI/CD integration recipe: this page's own build, wired into GitHub Actions#
# .github/workflows/build-and-push.yml
name: Build and push
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/<project-number>/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: ci@my-project-id.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud builds submit --config=cloudbuild.yaml --substitutions=_ENV=production .Running the build through Cloud Build (rather than docker build/docker push directly inside the GitHub
Actions runner) means the exact same cloudbuild.yaml pipeline runs identically whether triggered by
GitHub Actions, a native Cloud Build trigger, or a manual gcloud builds submit, one pipeline definition,
not two parallel ones to keep in sync.
Common pitfalls#
- Pushing to the deprecated
gcr.iohostname out of habit. New repositories and pipelines should target Artifact Registry's<region>-docker.pkg.devhostname;gcr.iostill works for existing setups but isn't the current recommended path. - Deploying
:latestinstead of an immutable, traceable tag. Makes "what's actually running" a guess instead of adescribecall, see the provenance scenario above. - No cleanup policy on a repository backing frequent CI builds. Untagged layers accumulate silently and become a real, unnecessary storage cost within weeks.
- Granting the CI service account
artifactregistry.admininstead ofwriter. Admin can delete the repository entirely; a pipeline identity almost never needs that. - An overly broad
--branch-patternon a trigger. Confirm it matches only the branches meant to auto-build/deploy before enabling the trigger, not after a feature branch accidentally deploys to prod.
Exit codes#
0 success, non-zero on any API/validation error or a failed build step, gcloud builds submit blocks and
streams logs by default, returning the build's own final exit code, a failing test step inside
cloudbuild.yaml fails the whole gcloud builds submit invocation with a non-zero exit, exactly the signal
a CI pipeline step needs to fail the job correctly.
When to reach for something else#
For a build pipeline with substantial existing investment in another CI system (GitHub Actions, GitLab CI,
Jenkins) that only needs to push to Artifact Registry rather than run the build itself, skip Cloud Build
entirely and authenticate that existing pipeline via Workload Identity Federation (page 01) plus docker push, standing up Cloud Build in parallel adds a second pipeline to maintain for no real benefit. For
declarative, reviewable repository and trigger provisioning across environments, prefer Terraform's
google_artifact_registry_repository/google_cloudbuild_trigger resources over a growing shell script of
the commands on this page, consistent with the IaC guidance on every earlier page.