Cloud Deploy: Pipelines, Targets and Releases

The four objects, why rendering once at release time is the whole point, deploy parameters instead of a values file per environment, and the execution identity behind each target.

advanced 24 min lesson hands-on task included

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

CLOUD DEPLOY — ONE RELEASE, PROMOTED THROUGH ORDERED TARGETS RELEASE skaffold render, once manifests frozen per target dev auto on release target staging promote + verify target prod approval + canary target gcloud deploy releases promote — the same rendered manifests move right THE FOUR OBJECTS DeliveryPipeline — the ordered stages Target — a cluster + its approval rule Release — an immutable set of manifests Rollout — one release applied to one target Skaffold does the rendering; Cloud Deploy owns the promotion. WHAT IT GIVES YOU OVER A DEPLOY SCRIPT · the same manifests in every environment · approvals as a first-class object, audited · canary phases with automatic rollback · verify jobs that gate the promotion · one-command rollback to any prior release CANARY, DECLARED RATHER THAN SCRIPTED strategy: canary: { percentages: [25, 50, 100] } — with a verify job between phases, and rollback if it fails The split worth remembering: Cloud Build produces an artifact; Cloud Deploy moves it through environments. Trying to do both in one is where deploy scripts grow.
A release renders once, per target, and freezes the result. Promotion moves that frozen set rightward — which is what makes staging a genuine prediction of production.

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

ONE MANIFEST SET, PER-TARGET VALUES — WITHOUT A CHART PER ENVIRONMENT the manifests replicas: from-param target: dev deployParameters: replicas: 1 target: staging deployParameters: replicas: 2 target: prod deployParameters: replicas: 12 WHAT THIS REPLACES A values file per environment that drifts, or a skaffold profile per environment that duplicates. Parameters are recorded on the release — inspectable later. KEEP THEM SMALL Parameters are for values that legitimately differ: replicas, resource sizes, a hostname, a flag. Different behaviour per environment is not a parameter.
One manifest set with placeholders, resolved per target at render time. The right-hand panel is the boundary — parameters are for values that differ, not for behaviour that differs.

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.developer on one cluster. cd-deploy-prod cannot 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 describe is 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.