GCP CI/CD — Cloud Build, Artifact Registry, Cloud Deploy
CI/CD Service Map
| Stage | AWS | GCP |
|---|---|---|
| Build | CodeBuild | Cloud Build |
| Container registry | ECR | Artifact Registry |
| Delivery / deploy | CodeDeploy + CodePipeline | Cloud Deploy |
| Source repo | CodeCommit | Cloud Source Repositories (or GitHub/GitLab) |
| Secrets in pipeline | Secrets Manager | Secret Manager |
Most GCP teams use GitHub Actions or Jenkins for CI and Cloud Build + Cloud Deploy for the GCP-native delivery step. Cloud Build alone handles most use cases without needing a full pipeline service.
Cloud Build
Cloud Build = AWS CodeBuild. Runs containerized build steps, triggered by source events (git push, PR, tag).
Build Config
Everything runs in a cloudbuild.yaml (equivalent to buildspec.yml):
# cloudbuild.yaml
steps:
# Step 1: Run tests
- name: 'python:3.11'
entrypoint: pip
args: ['install', '-r', 'requirements.txt']
- name: 'python:3.11'
entrypoint: pytest
args: ['tests/', '-v', '--tb=short']
env:
- 'ENVIRONMENT=test'
# Step 2: Build Docker image
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'
- '-t'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:latest'
- '.'
# Step 3: Push to Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
args: ['push', '--all-tags', 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app']
# Step 4: Deploy to Cloud Run (simple deploy, no progressive delivery)
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'my-service'
- '--image=us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'
- '--region=us-central1'
# Built images (Cloud Build caches these)
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'
# Timeout and machine type
timeout: '1200s'
options:
machineType: 'E2_HIGHCPU_8' # more CPU for faster builds
logging: CLOUD_LOGGING_ONLY
Built-in Substitutions
# Available automatically in every build
$PROJECT_ID # GCP project ID
$BUILD_ID # unique build ID
$SHORT_SHA # first 7 chars of git commit SHA
$COMMIT_SHA # full git commit SHA
$BRANCH_NAME # git branch
$TAG_NAME # git tag (if triggered by tag push)
$REPO_NAME # repo name
# Custom substitutions
substitutions:
_DEPLOY_ENV: production
_SERVICE_NAME: my-api
steps:
- name: 'ubuntu'
args: ['echo', 'Deploying ${_SERVICE_NAME} to ${_DEPLOY_ENV}']
What's the difference between $SHORT_SHA and $COMMIT_SHA, and which one does the Build Config example above actually use to tag images?
my-app:$SHORT_SHA) — short enough to be a readable, still-unique-per-commit tag, whereas the full $COMMIT_SHA is used where exact traceability matters more than a tidy tag.Triggers
# Trigger on push to main branch
gcloud builds triggers create github \
--repo-name=my-repo \
--repo-owner=my-org \
--branch-pattern=^main$ \
--build-config=cloudbuild.yaml
# Trigger on tag push (release)
gcloud builds triggers create github \
--repo-name=my-repo \
--repo-owner=my-org \
--tag-pattern="v[0-9]+\.[0-9]+\.[0-9]+" \
--build-config=cloudbuild-release.yaml
# Manual trigger for a specific commit
gcloud builds submit \
--config=cloudbuild.yaml \
--substitutions=SHORT_SHA=$(git rev-parse --short HEAD) \
.
Accessing Secrets in Build
Two ways to get a Secret Manager value into a build step — pick one per step, not both.
Decode the secret inside a bash step and hand it to your own script. Works anywhere gcloud runs, but the plaintext exists as a shell variable for that step's whole lifetime.
steps:
- name: 'gcr.io/cloud-builders/gcloud'
entrypoint: 'bash'
args:
- '-c'
- |
DB_PASSWORD=$$(gcloud secrets versions access latest --secret=db-password)
./deploy.sh --password=$$DB_PASSWORD
Declare the secret once in availableSecrets, then reference it by name in secretEnv on whichever step needs it. Cloud Build injects it as an environment variable scoped to just that step — no explicit gcloud secrets versions access call in your script.
availableSecrets:
secretManager:
- versionName: projects/$PROJECT_ID/secrets/db-password/versions/latest
env: 'DB_PASSWORD'
steps:
- name: 'gcr.io/cloud-builders/gcloud'
secretEnv: ['DB_PASSWORD']
script: |
echo "Using password from Secret Manager: ${DB_PASSWORD:0:3}***"
Build Caching
# Cache dependencies between builds using GCS
steps:
- name: 'gcr.io/cloud-builders/gsutil'
args: ['cp', 'gs://my-build-cache/pip-cache.tar.gz', '/tmp/pip-cache.tar.gz']
id: restore-cache
- name: 'python:3.11'
script: |
tar xzf /tmp/pip-cache.tar.gz -C / 2>/dev/null || true
pip install -r requirements.txt --cache-dir /root/.cache/pip
waitFor: ['restore-cache']
- name: 'gcr.io/cloud-builders/gsutil'
args: ['cp', '-r', '/root/.cache/pip', 'gs://my-build-cache/pip-cache.tar.gz']
waitFor: ['-'] # run after all steps
Why does this pattern round-trip the pip cache through a GCS bucket instead of just relying on the previous build reusing the same machine's disk?
Cloud Build vs AWS CodeBuild
| Cloud Build | AWS CodeBuild | |
|---|---|---|
| Config file | cloudbuild.yaml |
buildspec.yml |
| Build units | "build steps" (each is a container) | "phases" within one environment |
| Parallelism | Steps can run in parallel with waitFor |
Phases are sequential |
| Machine types | e2-medium to n1-highcpu-32 | Small to 72 vCPU |
| Free tier | 120 build-minutes/day | 100 build-minutes/month |
| GitHub integration | First-class | First-class |
| Caching | GCS-based or Docker layer cache | S3-based or local cache |
| Cost | $0.003/build-minute (n1-standard-1) | $0.005/build-minute (general1.small) |
Artifact Registry
Artifact Registry = AWS ECR + CodeArtifact. Stores Docker images, Helm charts, Maven/PyPI/npm packages.
# Create a Docker repository
gcloud artifacts repositories create my-repo \
--repository-format=docker \
--location=us-central1 \
--description="Production images"
# Authenticate Docker to Artifact Registry
gcloud auth configure-docker us-central1-docker.pkg.dev
# Build and push
docker build -t us-central1-docker.pkg.dev/my-project/my-repo/my-app:v1.0.0 .
docker push us-central1-docker.pkg.dev/my-project/my-repo/my-app:v1.0.0
# Pull
docker pull us-central1-docker.pkg.dev/my-project/my-repo/my-app:v1.0.0
# List images
gcloud artifacts docker images list us-central1-docker.pkg.dev/my-project/my-repo
# Delete old images (cleanup)
gcloud artifacts docker images delete \
us-central1-docker.pkg.dev/my-project/my-repo/my-app:old-tag
Vulnerability Scanning
# Enable automatic scanning on push
gcloud artifacts repositories update my-repo \
--location=us-central1 \
--enable-vulnerability-scanning
# View scan results
gcloud artifacts docker images list-vulnerabilities \
us-central1-docker.pkg.dev/my-project/my-repo/my-app@sha256:abc123
Cleanup Policies (= ECR Lifecycle Policies)
# Auto-delete untagged images older than 14 days
gcloud artifacts repositories set-cleanup-policies my-repo \
--location=us-central1 \
--policy='[
{
"name": "delete-old-untagged",
"action": "DELETE",
"condition": {
"tagState": "UNTAGGED",
"olderThan": "1209600s"
}
}
]'
This cleanup policy's condition is scoped to tagState: UNTAGGED. If an old image still carries a tag nobody uses anymore, will this policy delete it after 14 days?
Helm Charts in Artifact Registry
# Create OCI-compatible Helm repo
gcloud artifacts repositories create helm-charts \
--repository-format=docker \
--location=us-central1
# Push Helm chart
helm package ./my-chart
helm push my-chart-1.0.0.tgz oci://us-central1-docker.pkg.dev/my-project/helm-charts
# Install from Artifact Registry
helm install my-release \
oci://us-central1-docker.pkg.dev/my-project/helm-charts/my-chart \
--version 1.0.0
Cloud Deploy — Progressive Delivery
Cloud Deploy = AWS CodeDeploy + CodePipeline. Manages delivery pipelines with stages (dev → staging → prod), approval gates, and built-in rollback.
graph TD
classDef gcp fill:#4285f4,stroke:#2a56c6,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef amber fill:#e67e22,stroke:#d35400,color:#fff
classDef gate fill:#9b59b6,stroke:#71368a,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
subgraph CI["Continuous Integration"]
BUILD["Cloud Build<br/>runs tests, builds image,<br/>pushes to Artifact Registry"]:::gcp
REL["gcloud deploy releases create<br/>new Cloud Deploy Release"]:::gcp
BUILD --> REL
end
subgraph PIPE["Cloud Deploy — Delivery Pipeline"]
DEV["dev target<br/>auto-deploy, no approval"]:::green
STAGING["staging target<br/>auto-deploy, no approval"]:::amber
GATE{"requireApproval: true<br/>rollout paused"}:::gate
PROD["production target<br/>canary 10% → 25% → 50% → 100%<br/>verify: true at each step"]:::red
end
REL -->|"stage 1: deploy"| DEV
DEV -->|"gcloud deploy releases promote"| STAGING
STAGING -->|"gcloud deploy releases promote"| GATE
GATE -->|"gcloud deploy rollouts approve"| PROD
requireApproval, no canary strategy: the whole image rolls out at once.
gcloud deploy releases promote --to-target=staging. A release can sit in dev indefinitely without ever reaching staging.
requireApproval: true. Promotion creates the rollout, but it stays paused until gcloud deploy rollouts approve runs against that specific rollout ID. Once approved, it still doesn't jump to 100% — the canary strategy staggers it through 10% → 25% → 50%, with verify: true checking health at each step before continuing.
Delivery Pipeline Config
# clouddeploy.yaml
apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
name: my-app-pipeline
location: us-central1
description: My App delivery pipeline
serialPipeline:
stages:
- targetId: dev
profiles: [dev]
- targetId: staging
profiles: [staging]
- targetId: production
profiles: [production]
strategy:
canary:
runtimeConfig:
cloudRun:
automaticTrafficControl: true
canaryDeployment:
percentages: [10, 25, 50]
verify: true
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
name: dev
location: us-central1
run:
location: projects/my-project/locations/us-central1
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
name: production
location: us-central1
requireApproval: true # manual approval before deploy to prod
run:
location: projects/my-project/locations/us-central1
Deploy Workflow
# Apply pipeline and target definitions
gcloud deploy apply --file=clouddeploy.yaml --region=us-central1
# Create a release (triggers deployment to dev)
gcloud deploy releases create release-$(date +%Y%m%d-%H%M) \
--delivery-pipeline=my-app-pipeline \
--region=us-central1 \
--images=my-app=us-central1-docker.pkg.dev/my-project/my-repo/my-app:$SHORT_SHA
# Promote to staging (after dev passes)
gcloud deploy releases promote \
--delivery-pipeline=my-app-pipeline \
--region=us-central1 \
--release=release-20240115-1430 \
--to-target=staging
# Approve production deployment
gcloud deploy rollouts approve \
my-app-pipeline-20240115-1430-to-production-0001 \
--delivery-pipeline=my-app-pipeline \
--region=us-central1 \
--release=release-20240115-1430
# Rollback if needed
gcloud deploy rollouts rollback \
my-app-pipeline \
--region=us-central1 \
--release=release-20240115-1430 \
--to-target=production
Canary Rollout With Approval Gates, Step by Step
gcloud deploy releases create registers a new release and immediately kicks off stage 1 — auto-deploy to the dev target. No approval needed here.
gcloud deploy releases promote --to-target=staging triggers another unattended auto-deploy — same mechanics as dev, just a second environment.
production creates a rollout, but because that Target has requireApproval: true, Cloud Deploy pauses it before anything reaches production traffic. Zero percent has moved.
gcloud deploy rollouts approve my-app-pipeline-...-to-production-0001. Nothing about promoting from staging approves production automatically — this is a separate, explicit action.
canaryDeployment.percentages list one step at a time. Because verify: true, each percentage's health is checked before the next jump — traffic never doubles onto an unverified step.
gcloud deploy rollouts rollback ... --to-target=production reverts production to the previous release without touching dev or staging.
Only the production Target sets requireApproval: true — dev and staging don't. What actually changes about the rollout process for production versus the other two stages?
gcloud deploy releases promote deploys immediately — no human involved. For production, promoting only creates the rollout; it stays paused until someone explicitly runs gcloud deploy rollouts approve against that specific rollout ID. Nothing reaches production traffic, not even the first canary percentage, until that manual gate clears.Full CI/CD Pipeline Pattern
graph TD
classDef trigger fill:#4285f4,stroke:#2a56c6,color:#fff
classDef build fill:#34495e,stroke:#212f3c,color:#fff
classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
classDef amber fill:#e67e22,stroke:#d35400,color:#fff
classDef gate fill:#9b59b6,stroke:#71368a,color:#fff
classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
PUSH["Developer pushes to main"]:::trigger --> TRIGGER["Cloud Build trigger fires"]:::trigger
subgraph CI["Cloud Build"]
TEST["Run tests<br/>pytest / go test / jest"]:::build
DOCKER["Build Docker image"]:::build
PUSHAR["Push to Artifact Registry"]:::build
SCAN["Run vulnerability scan"]:::build
RELEASE["Create Cloud Deploy release"]:::build
TEST --> DOCKER --> PUSHAR --> SCAN --> RELEASE
end
TRIGGER --> TEST
RELEASE --> DEV["Auto-deploy to dev<br/>smoke test / integration test"]:::green
DEV --> STAGING["Promote to staging<br/>manual QA or automated regression"]:::amber
STAGING --> APPROVAL{"Approval gate<br/>Jira ticket / PR approval"}:::gate
APPROVAL --> PROD["Deploy to production<br/>canary 10% → 25% → 50% → 100%"]:::red
PROD --> MONITOR{"Monitor error rate<br/>for 10 min"}:::gate
MONITOR -->|"healthy"| FULL["Full rollout complete"]:::green
MONITOR -->|"regression"| ROLLBACK["Automatic rollback"]:::red
The vulnerability scan step runs after "Push to Artifact Registry," not before it. What does that ordering mean the scan alone can and can't prevent?
Cloud Build vs GitHub Actions
Most teams use GitHub Actions for CI and Cloud Build for GCP-specific deploy steps. Here's the integration:
# .github/workflows/deploy.yml — uses gcloud in GHA
name: Deploy to GCP
on:
push:
branches: [main]
permissions:
id-token: write # for Workload Identity Federation (no service account keys)
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Authenticate to GCP via Workload Identity (no keys needed)
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/github'
service_account: 'github-actions@my-project.iam.gserviceaccount.com'
- uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker
run: gcloud auth configure-docker us-central1-docker.pkg.dev
- name: Build and push
run: |
docker build -t us-central1-docker.pkg.dev/my-project/my-repo/my-app:${{ github.sha }} .
docker push us-central1-docker.pkg.dev/my-project/my-repo/my-app:${{ github.sha }}
- name: Deploy to Cloud Run
run: |
gcloud run deploy my-service \
--image=us-central1-docker.pkg.dev/my-project/my-repo/my-app:${{ github.sha }} \
--region=us-central1
GCP Workload Identity Federation for GitHub Actions = AWS OIDC provider in IAM. No service account keys in GitHub secrets.
Workload Identity Federation: The Token Exchange
permissions: id-token: write and google-github-actions/auth@v2 aren't just boilerplate — they drive an actual token exchange, not a stored credential:
sequenceDiagram
participant GH as GitHub Actions job
participant OIDC as GitHub OIDC provider
participant WIF as GCP Workload Identity Pool
participant SA as github-actions@ service account
participant API as GCP APIs — Artifact Registry, Cloud Run
Note over GH: workflow declares permissions, id-token: write
GH->>OIDC: request short-lived OIDC ID token for this run
OIDC-->>GH: signed JWT with repo, branch, run claims
GH->>WIF: present JWT to the configured workload identity provider
WIF->>WIF: verify JWT signature and attribute-condition mapping
WIF-->>GH: exchange for short-lived federated GCP token
GH->>SA: impersonate service account using federated token
SA-->>GH: short-lived GCP access token
GH->>API: call gcloud / docker push using that access token
Note over GH,API: no long-lived service account key ever stored in GitHub secrets
Why does the workflow need permissions: id-token: write at all — what does that token actually get used for?