Continuous Delivery to AWS, Azure, Google Cloud, and Kubernetes: Guided Hands-On Workflow
Promote one verified artifact into a disposable local Kubernetes target while comparing the same contract with current AWS, Azure, and Google Cloud OIDC adapters.
Learning objectives
- Create a tiny release subject once, transfer it between jobs, and verify its SHA-256 before deployment.
- Use a GitHub environment boundary and target-specific concurrency without granting cloud or repository write permissions.
- Create a disposable kind cluster with pinned kind and Kubernetes node identities and prove the active context before mutation.
- Roll out exact content identity, verify readiness and external HTTP health, and capture a Kubernetes deployment revision.
- Compare the local deployment contract with optional AWS, Azure and Google Cloud OIDC adapters without requiring cloud accounts.
1. Scenario: same delivery contract, local target first
Create a throwaway repository named gha-delivery-lab.
The mandatory path builds a static page as the release subject,
uploads it as a GitHub Actions artifact, then a second job downloads
and verifies the exact digest before touching a disposable kind
cluster. No cloud account, package registry, paid runner or
long-lived credential is required.
The deployment job references an environment named
lab-k8s. On a public repository, configure protection
if your plan supports the rule you want to practice. If reviewer
protection is unavailable, keep the environment reference, use the
typed deploy confirmation as a faithful procedural
gate, and explicitly record that this is not equivalent to
GitHub-enforced reviewer approval.
2. Preflight: prove repository, runner and tool assumptions
# Read-only local preflight before committing the workflow.
git status --short
git rev-parse HEAD
docker version
uname -a
# In the Actions run, also record $GITHUB_RUN_ID, $GITHUB_RUN_ATTEMPT,
# $GITHUB_SHA and runner image metadata before deployment.
The current lab assumptions are timestamped September 10, 2026:
ubuntu-24.04; kind v0.33.0;
Kubernetes/kubectl v1.37.0; and the kind node image
kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5. The workflow verifies the kind binary checksum and the downloaded
kubectl checksum.
3. Progressive workflow: build once, verify, target, rollout, health, cleanup
The workflow deliberately keeps the build job read-only and the
local deployment job at permissions: {}. The cluster
credential is created locally by kind and lives only for the
disposable job. The GitHub environment creates deployment/governance
state but does not inject any cloud secret.
name: Disposable provider-neutral delivery lab
on:
workflow_dispatch:
inputs:
deploy:
description: "Deploy the verified subject to the disposable target"
type: boolean
required: true
default: false
permissions: {}
jobs:
build:
name: Build immutable delivery subject
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
digest: ${{ steps.subject.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: subject
shell: bash
run: |
set -euo pipefail
mkdir -p dist
printf '<h1>delivery-lab</h1>
<p>source=%s</p>
' "$GITHUB_SHA" > dist/index.html
digest=$(sha256sum dist/index.html | awk '{print $1}')
printf '%s index.html
' "$digest" > dist/SHA256SUMS
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: delivery-subject-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/
retention-days: 7
deploy_local:
name: Promote exact subject to disposable kind cluster
if: ${{ inputs.deploy }}
needs: build
runs-on: ubuntu-24.04
environment: lab-k8s
concurrency:
group: deploy-lab-k8s
cancel-in-progress: false
permissions: {}
env:
EXPECTED_DIGEST: ${{ needs.build.outputs.digest }}
KIND_VERSION: v0.33.0
KUBECTL_VERSION: v1.37.0
KIND_NODE_IMAGE: kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
CLUSTER: gha-cd-${{ github.run_id }}
NS: lab-delivery
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: delivery-subject-${{ github.run_id }}-${{ github.run_attempt }}
path: dist
- name: Verify the exact bytes before any rollout
shell: bash
run: |
set -euo pipefail
actual=$(sha256sum dist/index.html | awk '{print $1}')
test "$actual" = "$EXPECTED_DIGEST"
(cd dist && sha256sum -c SHA256SUMS)
echo "verified_artifact_sha256=$actual"
- name: Install pinned kind and kubectl
shell: bash
run: |
set -euo pipefail
curl -fsSLo kind "https://kind.sigs.k8s.io/dl/$KIND_VERSION/kind-linux-amd64"
echo "aee6151561422756b764a4ae28e7f44cda5af5a9eead3cc9985112b1de8d8e0d kind" | sha256sum -c -
install -m 0755 kind "$RUNNER_TEMP/kind"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
curl -fsSLo kubectl "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/amd64/kubectl"
curl -fsSLo kubectl.sha256 "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
install -m 0755 kubectl "$RUNNER_TEMP/kubectl"
- name: Create one disposable target and prove context
shell: bash
run: |
set -euo pipefail
cat > kind.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 18080
listenAddress: "127.0.0.1"
protocol: TCP
EOF
kind create cluster --name "$CLUSTER" --image "$KIND_NODE_IMAGE" --config kind.yaml --wait 120s
test "$(kubectl config current-context)" = "kind-$CLUSTER"
kubectl cluster-info
kubectl create namespace "$NS"
- name: Resolve runtime image and deploy exact content revision
id: deploy
shell: bash
run: |
set -euo pipefail
docker pull nginx:alpine
runtime_ref=$(docker inspect --format='{{index .RepoDigests 0}}' nginx:alpine)
test -n "$runtime_ref"
short="${EXPECTED_DIGEST:0:12}"
config="content-$short"
kubectl -n "$NS" create configmap "$config" --from-file=index.html=dist/index.html
kubectl -n "$NS" label configmap "$config" "academy.example/artifact-sha256=$EXPECTED_DIGEST"
cat > deployment.yaml <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: delivery-demo
namespace: $NS
spec:
replicas: 1
selector:
matchLabels: {app: delivery-demo}
template:
metadata:
labels: {app: delivery-demo}
annotations:
academy.example/artifact-sha256: "$EXPECTED_DIGEST"
spec:
containers:
- name: web
image: "$runtime_ref"
ports:
- containerPort: 80
readinessProbe:
httpGet: {path: /, port: 80}
initialDelaySeconds: 2
periodSeconds: 2
volumeMounts:
- name: content
mountPath: /usr/share/nginx/html
volumes:
- name: content
configMap: {name: "$config"}
---
apiVersion: v1
kind: Service
metadata:
name: delivery-demo
namespace: $NS
spec:
selector: {app: delivery-demo}
type: NodePort
ports:
- port: 80
targetPort: 80
nodePort: 30080
EOF
kubectl apply -f deployment.yaml
kubectl -n "$NS" rollout status deployment/delivery-demo --timeout=120s
revision=$(kubectl -n "$NS" get deployment delivery-demo -o jsonpath='{{.metadata.annotations.deployment\.kubernetes\.io/revision}}')
echo "runtime_ref=$runtime_ref" >> "$GITHUB_OUTPUT"
echo "revision=$revision" >> "$GITHUB_OUTPUT"
- name: Verify external health and promoted identity
shell: bash
run: |
set -euo pipefail
body=$(curl -fsS --retry 10 --retry-delay 2 http://127.0.0.1:18080/)
printf '%s
' "$body"
grep -F "source=$GITHUB_SHA" <<<"$body"
test "$(kubectl -n "$NS" get deploy delivery-demo -o jsonpath='{{.spec.template.metadata.annotations.academy\.example/artifact-sha256}}')" = "$EXPECTED_DIGEST"
echo "deployment_revision=${{ steps.deploy.outputs.revision }}"
echo "runtime_image=${{ steps.deploy.outputs.runtime_ref }}"
- name: Preserve target evidence
if: ${{ always() }}
shell: bash
run: |
set +e
mkdir -p evidence
printf 'run=%s
attempt=%s
source=%s
artifact_sha256=%s
context=%s
' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA" "$EXPECTED_DIGEST" "$(kubectl config current-context 2>/dev/null)" > evidence/identity.txt
kubectl -n "$NS" get all -o wide > evidence/resources.txt 2>&1
kubectl -n "$NS" rollout history deployment/delivery-demo > evidence/rollout-history.txt 2>&1
kubectl -n "$NS" describe deployment delivery-demo > evidence/deployment.txt 2>&1
- name: Cleanup exact disposable target
if: ${{ always() }}
shell: bash
run: |
set +e
kind delete cluster --name "$CLUSTER"
4. Why the build and deploy jobs are separate
The separation proves that deployment consumes a previously produced subject. The build job records the digest and uploads bytes. The deployment job has no checkout and no build command; it downloads the named Actions artifact, recomputes SHA-256 and refuses to continue if the bytes differ. That is the deployment contract inherited from Chapters 23–25.
An Actions artifact is transfer/storage state, not deployment state. The Kubernetes ConfigMap name and Pod-template annotation encode the verified digest, while the Deployment revision and endpoint response prove what the target actually rolled out.
5. Target proof before mutation
The cluster name contains the workflow run ID, making the target
disposable and collision-resistant. After creation, the workflow
asserts that kubectl config current-context is exactly
kind-$CLUSTER before creating the namespace. This guard
is intentionally boring: many destructive Kubernetes incidents begin
with valid YAML applied to the wrong context.
Never “fix” a context mismatch by skipping the check. Stop, preserve the current context/cluster evidence, select the intended target explicitly, then re-run the smallest equivalent scope.
6. Artifact identity and runtime-image identity are separate
The application artifact in this lab is index.html; its
SHA-256 is the subject being promoted. Nginx is merely the runtime
serving that content. The workflow also resolves the pulled Nginx
image to a content-addressed repository digest and places that exact
runtime reference in the Deployment so the runtime does not drift
behind a mutable nginx:alpine tag during rollout.
A container-centric production pipeline would normally make the OCI image itself the release subject and pass its registry digest directly into the deployment adapter. The same rule applies: do not verify one digest and deploy a different tag.
7. Readiness and external health close different loops
Kubernetes readiness proves the Pod is eligible to receive Service
traffic. kubectl rollout status proves the Deployment
controller reached its desired rollout state. The final
curl through the mapped NodePort proves the service
path returns content from the expected source SHA. Keeping all three
signals prevents “rollout complete” from being misread as
“application behavior is correct.”
8. Optional AWS adapter: OIDC → IAM role → deployment API
If you have an authorized disposable AWS sandbox, use GitHub OIDC instead of storing access keys. The provider trust policy should bind the actual repository/environment subject and audience. AWS does not support GitHub OIDC custom claims in the same way some other providers do, so design around the supported audience/subject conditions and the IAM role’s own resource permissions.
permissions:
contents: read
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-production-deployer
aws-region: us-east-1
- run: aws sts get-caller-identity
This example stops at identity verification. A real adapter would then call one selected service API—such as ECS or EKS—and return a target-side deployment/revision identifier plus health/rollback data.
9. Optional Azure adapter: OIDC → federated credential → scoped resource
Azure Login uses Microsoft Entra workload identity federation. The
recommended audience is normally
api://AzureADTokenExchange. Keep the
client/tenant/subscription identifiers as non-secret configuration
variables, bind the federated credential to the actual GitHub
subject, then give the service principal only the resource-scope
role needed by the chosen deployment target.
permissions:
contents: read
id-token: write
steps:
- uses: Azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az account show --query '{id:id,tenantId:tenantId,user:user.name}'
10. Optional Google Cloud adapter: WIF → principal/service account → target
Google Cloud Workload Identity Federation maps GitHub claims into a workload identity pool/provider and can authorize a direct federated principal or service-account impersonation. The provider must have an attribute condition; do not wildcard an entire organization just because the workload later selects a project.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
project_id: fake-project-123
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/actions
- run: gcloud config get-value project
11. Challenge: choose the layer, not the command
Your team verifies image digest sha256:AAA, but the
deployment adapter receives web:stable. The registry
currently resolves that tag to sha256:BBB. Which layer
should reject the operation? The deployment contract should fail
before provider mutation because artifact identity no
longer matches. Changing health checks or runner capacity would not
repair the causal problem.
12. Cleanup and retained evidence
The workflow deletes only the kind cluster whose name contains its own run ID. Before cleanup, capture context, namespace resources, Deployment description and rollout history. In a cloud sandbox, preserve the provider deployment ID and identity response before deleting the disposable resource. Cleanup removes target state; it should not erase the GitHub run/attempt or artifact/provenance evidence needed to explain what happened.
13. Guided-workflow summary
You promoted one verified subject through an environment boundary into a disposable target, proved context before mutation, observed rollout and health separately, and mapped the same contract onto three cloud federation adapters without requiring cloud infrastructure for core completion.
Knowledge check
Why does the deploy job omit checkout?
To reinforce that delivery consumes the previously built artifact rather than rebuilding or reading mutable source again.
What does the ConfigMap digest label prove?
Which verified content identity was placed into that target-side object; it is independent from the Kubernetes Deployment revision.
Why record kubectl config current-context before
apply?
A syntactically valid deployment against the wrong cluster is still an operational failure. The context is target identity evidence.
Does a successful OIDC login prove the application was deployed?
No. Authentication is only the identity step; provider mutation, rollout and health remain separate.
If required environment reviewers are unavailable on the chosen plan, what must the learner record?
That the typed/manual confirmation is only a simulation/procedural gate and does not equal GitHub-enforced reviewer protection.
Official references and version notes
- GitHub OIDC overview — Why short-lived federation is preferred to long-lived cloud secrets.
- OIDC reference — Current claims, immutable subject format, audiences, reusable-workflow behavior and token-request permissions.
- OIDC in AWS — Current AWS trust conditions and official authentication action pattern.
- OIDC in Azure — Current Microsoft Entra workload identity federation pattern.
- OIDC in Google Cloud — Current Workload Identity Federation integration.
- Managing environments — Environment protection, deployment branches/tags, secrets and target governance.
- Deployments and environments — Conceptual distinction between deployment records, environment gates and external target health.
- kind quick start — Pinned local Kubernetes cluster tool used by the free lab.
- kind local registry — Current localhost registry/network model when digest-addressable local images are needed.
- Kubernetes Deployments — Rollout, revision and rollback model.
- kubectl rollout — Status, history, undo and restart operations.
- AWS credential action v6.2.4 — Full-SHA production reference used by the optional AWS adapter.
- Azure Login v3.1.0 — Full-SHA production reference used by the optional Azure adapter.
- Google auth v3.0.0 — Full-SHA production reference used by the optional Google Cloud adapter.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.