Cloud Build produces an artifact. Cloud Deploy moves it through environments, with the promotion, the approval and the rollback as first-class objects rather than steps in a script. This lesson is the object model; the next is the promotion machinery.
Topic 1: The Four Objects
DeliveryPipeline — the ordered stages, in one file:
apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
name: checkout
description: checkout service
serialPipeline:
stages:
- targetId: dev
profiles: [dev]
deployParameters:
- values: { replicaCount: "1" }
- targetId: staging
profiles: [staging]
deployParameters:
- values: { replicaCount: "2" }
- targetId: prod
profiles: [prod]
deployParameters:
- values: { replicaCount: "12" }
strategy:
canary:
runtimeConfig:
kubernetes:
gatewayServiceMesh:
httpRoute: checkout
service: checkout
deployment: checkout
canaryDeployment:
percentages: [25, 50]
verify: true
Target — a destination plus its rules and its identity:
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
name: prod
description: production, europe-west1
requireApproval: true
gke:
cluster: projects/acme/locations/europe-west1/clusters/prod
executionConfigs:
- usages: [RENDER]
serviceAccount: cd-render@acme.iam.gserviceaccount.com
workerPool: projects/acme/locations/europe-west1/workerPools/private-pool
- usages: [DEPLOY, VERIFY, PREDEPLOY, POSTDEPLOY]
serviceAccount: cd-deploy-prod@acme.iam.gserviceaccount.com
workerPool: projects/acme/locations/europe-west1/workerPools/private-pool
executionTimeout: 3600s
Release — an immutable, already-rendered set of manifests:
gcloud deploy releases create checkout-$SHORT_SHA \
--delivery-pipeline=checkout --region=europe-west1 \
--images=checkout=europe-west1-docker.pkg.dev/acme/apps/checkout@sha256:9f8e7d…
Rollout — one release applied to one target. Promotion creates the next one.
gcloud deploy apply --file=pipeline.yaml --region=europe-west1
gcloud deploy delivery-pipelines describe checkout --region=europe-west1
Targets are not GKE-only. run: targets a Cloud Run service, gke: a cluster, anthosCluster: a fleet member, and multiTarget: fans one stage out to several targets at once — which is how a multi-region rollout becomes one promotion rather than three pipelines.
Topic 2: Render Once — What Skaffold Actually Does
The timing is the property that matters: Skaffold renders at release-creation time, once per target, and Cloud Deploy stores the output immutably.
# skaffold.yaml
apiVersion: skaffold/v4beta7
kind: Config
metadata: { name: checkout }
manifests:
helm:
releases:
- name: checkout
chartPath: charts/checkout
setValueTemplates:
image: '{{.IMAGE_FULLY_QUALIFIED_checkout}}'
deploy:
helm: {}
profiles:
- name: dev
manifests:
helm:
releases:
- name: checkout
chartPath: charts/checkout
valuesFiles: [charts/checkout/values-dev.yaml]
- name: prod
manifests:
helm:
releases:
- name: checkout
chartPath: charts/checkout
valuesFiles: [charts/checkout/values-prod.yaml]
Skaffold supports Helm, Kustomize and raw kubectl rendering — the freezing behaviour is identical for all three. The Helm module covers the chart side; what is Cloud Deploy-specific is:
IMAGE_FULLY_QUALIFIED_*is substituted from--images, and it carries the digest you passed. This is the mechanism that stops a moved tag from changing what deploys.- Profiles map to stages via
profiles: [prod]in the pipeline, which is how one Skaffold config serves every environment. - Rendering happens in the RENDER execution environment, with its own service account — so the identity that reads your chart is not the identity that deploys it.
# Read production's manifests before production has ever been deployed
gcloud deploy releases describe checkout-$SHORT_SHA \
--delivery-pipeline=checkout --region=europe-west1 \
--format='value(targetRenders)'
# Or fetch the rendered YAML itself from the release's GCS bucket
gcloud deploy releases describe … --format='value(targetArtifacts)'
Doing that once is what makes the model click: production’s exact YAML exists, reviewable, hours before anyone approves it.
Topic 3: Deploy Parameters
Deploy parameters inject per-target values without a chart or profile per environment:
# In the pipeline stage
- targetId: prod
deployParameters:
- values:
replicaCount: "12"
cpuLimit: "2"
# In the Target, for values that belong to the environment rather than the stage
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
name: prod
annotations: {}
deployParameters:
region: europe-west1
logLevel: info
# Consumed in the manifest
spec:
replicas: from-param(${replicaCount})
Why this is better than a values file per environment: the parameters are recorded on the release, so gcloud deploy releases describe tells you exactly what production was rendered with — and there is no separate file to drift.
Keep them small. Parameters are for replica counts, resource sizes, a hostname, a feature flag. When a parameter starts changing behaviour — a different code path, a different dependency — the environments have diverged in a way that makes staging stop predicting production, and that belongs in the chart with a review.
Parameter precedence: target-level parameters merge with stage-level ones, and the stage wins. As with Helm values, print the result rather than reasoning about it.
Topic 4: Execution Environments and Identity
Each target says where its jobs run and as whom — and this is where least privilege actually lands:
executionConfigs:
- usages: [RENDER]
serviceAccount: cd-render@acme.iam.gserviceaccount.com
- usages: [DEPLOY, VERIFY]
serviceAccount: cd-deploy-prod@acme.iam.gserviceaccount.com
workerPool: projects/acme/locations/europe-west1/workerPools/private-pool
The separation worth making:
- Render needs to read the repository and the chart, and write to the render bucket. It does not need cluster access at all.
- Deploy needs
container.developeron one cluster.cd-deploy-prodcannot touch staging, and vice versa — which is the control that stops a misconfigured pipeline from deploying to the wrong environment. - Verify often needs less than deploy: run a pod, read a service.
A private worker pool is required whenever a target is a private GKE cluster or the verify job must reach something internal — the same networking rule as Cloud Build, and the same symptom when it is missing: a timeout with no useful error.
The IAM roles you will actually grant:
roles/clouddeploy.releaser create releases (your CI's identity)
roles/clouddeploy.approver approve rollouts (humans, or a change system)
roles/clouddeploy.operator promote, retry, rollback
roles/container.developer on the target cluster, for the DEPLOY SA
roles/iam.serviceAccountUser on the execution SA, for whoever creates releases
That last one is the grant people forget, and its absence produces a permission error naming a service account rather than a role — confusing the first time.
Topic 5: Creating Releases From a Build
From Cloud Build, the natural pairing:
- id: release
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
entrypoint: bash
args:
- -c
- |
set -euo pipefail
DIGEST=$(cat /workspace/digest)
gcloud deploy releases create checkout-${SHORT_SHA} \
--delivery-pipeline=checkout \
--region=europe-west1 \
--images=checkout=${_IMAGE}@${DIGEST} \
--annotations=commit=${COMMIT_SHA},build=${BUILD_ID}
From Jenkins, which is a perfectly reasonable split — Jenkins is better at complex build orchestration, Cloud Deploy is better at promotion:
stage('Create release') {
when { branch 'main' }
steps {
sh '''
set -euo pipefail
gcloud deploy releases create checkout-${GIT_COMMIT:0:7} \
--delivery-pipeline=checkout --region=europe-west1 \
--images=checkout=${IMAGE}@${DIGEST} \
--annotations=jenkins-build=${BUILD_URL}
'''
}
}
Two habits worth adopting in both:
- Pass the digest, never the tag. Cloud Deploy stores what you give it; a tag can be moved after the release exists.
- Annotate the release with the commit, the build URL and the ticket. Six months later,
gcloud deploy releases describeis where somebody reconstructs why this shipped.
Topic 6: Inspecting an Estate
# What is deployed where, right now
gcloud deploy delivery-pipelines describe checkout --region=europe-west1
# Every release, newest first
gcloud deploy releases list --delivery-pipeline=checkout --region=europe-west1 \
--format='table(name, createTime, renderState)'
# Every rollout for one release, with state
gcloud deploy rollouts list --delivery-pipeline=checkout \
--release=checkout-9f8e7d --region=europe-west1 \
--format='table(name, targetId, state, approvalState, deployStartTime)'
# What is actually running in prod, as a digest
gcloud deploy targets describe prod --region=europe-west1
The question this makes answerable in one command — “which commit is in production, and who approved it” — is the reason to model promotion as objects rather than as pipeline stages. A Jenkins build log rotates; a rollout with an approval and an annotation does not.
Manage the pipeline definition in Git and apply it from CI, exactly like any other configuration:
gcloud deploy apply --file=deploy/pipeline.yaml --region=europe-west1
gcloud deploy apply --file=deploy/targets.yaml --region=europe-west1
Terraform’s google_clouddeploy_delivery_pipeline and google_clouddeploy_target do the same declaratively, and are the better fit if the rest of your infrastructure is already Terraform.
Try it yourself: create a release, then read the rendered manifests for production before promoting anything. Seeing production’s YAML frozen and inspectable while the release is still in dev is the single observation that separates this model from a deploy script.
Common mistake: creating one release per environment because that is how the old script worked — a staging release, then a production release. It defeats the entire model: two renders, two artifact references, and no guarantee that what was approved is what ships. One release, promoted through targets, is the object the system is built around.