Deploying to the Cloud: AWS, GCP & Azure¶
What this lesson gives you
Each major cloud has its own opinionated runtime for containers. The patterns are consistent; the interfaces differ. Master OIDC-based keyless auth and the image-promotion pipeline and you can deploy to any of them.
Estimated time: 18 min read · Part: CI/CD Across Cloud, On-Prem & Hybrid
The Three Clouds and Their Container Runtimes¶
Every major cloud provider offers a spectrum of container runtimes, arranged along an axis from more managed & opinionated to more flexible & DIY. Understanding that spectrum is the first step to making a good deployment choice for a client.
AWS Runtimes¶
AWS Lambda is the most managed option: you supply a function handler and AWS handles every layer beneath it — networking, scaling, patching. Cold starts are measured in milliseconds for Python, and you pay only when the code runs. The hard constraint is a 15-minute maximum execution time, which rules it out for long-running batch work or streaming workloads.
AWS App Runner sits one notch below Lambda in abstraction: you point it at a container image or a source-code repository, and App Runner handles load balancing, auto-scaling, and TLS termination. There is no cluster to manage. App Runner is excellent for web services that need to move fast with minimal ops overhead, though it offers less control over networking and IAM than the lower-level services.
ECS Fargate (Elastic Container Service with the Fargate launch type) is AWS’s flagship serverless-containers product. You define a task definition (which container image to run, how much CPU and memory to allocate, which secrets to inject) and an ECS service (how many tasks to keep alive, which load balancer to attach). AWS provisions the underlying EC2 instances invisibly; you never SSH into a node. Authentication to other AWS services happens via an IAM task role — the same credential is never stored anywhere; it is dynamically vended to the container at runtime by the ECS metadata endpoint.
EKS (Elastic Kubernetes Service) is managed Kubernetes. AWS runs the control plane (API server, etcd, scheduler) in a highly available configuration. You provision node groups, and the cluster runs any standard Kubernetes workload. EKS is the right choice when a team has existing Kubernetes expertise, needs fine-grained networking or custom admission controllers, or wants to run workloads that benefit from K8s ecosystem tooling (Argo CD, Karpenter, KEDA). The tradeoff is operational complexity: node upgrades, add-on management, and networking configuration are your responsibility.
GCP Runtimes¶
Cloud Run is GCP’s serverless-containers service and one of the most operator-friendly runtimes on any cloud. You push a container image; Cloud Run handles scale-to-zero (the service costs nothing when idle), scale-out (instantaneous), TLS, routing, and IAM-based access control. Each revision (deployed version) can receive a percentage of traffic, enabling trivial canary releases. Authentication to GCP services uses a workload’s service account — no credential files, no key rotation.
GKE Autopilot is GCP’s recommended Kubernetes mode: you submit workloads, and GCP automatically provisions appropriately sized nodes. You pay per pod, not per node, which eliminates bin-packing math. Standard GKE gives you full control over node pools if you need it.
Cloud Functions (2nd gen) is GCP’s function-as-a-service, now built on Cloud Run under the hood. Python functions deploy in seconds. Excellent for event-driven integrations (Pub/Sub triggers, HTTP webhooks, Cloud Storage events).
Azure Runtimes¶
Azure Container Apps is Microsoft’s highest-level container service, built on top of Kubernetes and KEDA (Kubernetes Event-Driven Autoscaling). It handles scale-to-zero, Dapr integration for microservice communication, and supports both HTTP and event-driven scaling triggers without exposing K8s objects directly. Well-suited to microservice architectures in Microsoft-centric shops.
AKS (Azure Kubernetes Service) is Azure’s managed K8s. Like EKS and GKE, it runs the control plane; you manage node pools. AKS integrates deeply with Azure Active Directory (Entra ID) for RBAC, Azure Monitor for observability, and Azure Container Registry for image storage.
Azure App Service supports containers alongside code deployments and is popular for traditional web applications migrating from on-premises IIS or .NET workloads. Less suited to cloud-native microservices than Container Apps.
| Runtime | Cloud | Cost Model | Cold Start | K8s Compatible | Max Concurrency | Best For |
|---|---|---|---|---|---|---|
| Lambda | AWS | Per invocation + GB-s | ~10–500 ms | No | 1,000 concurrent (default) | Event-driven, short-lived functions |
| ECS Fargate | AWS | vCPU + GB per second | 20–60 s task start | No | Service-level (configured) | Long-running services, batch jobs |
| EKS | AWS | $0.10/hr control plane + nodes | Pod scheduling | Yes | Node capacity | Complex microservices, large orgs |
| Cloud Run | GCP | Per request + vCPU-ms (scale-to-zero) | ~1–3 s (cold) | No | 1,000 per instance (config) | HTTP services, ML inference endpoints |
| GKE Autopilot | GCP | Per pod (vCPU + memory) | Node provision ~2 min | Yes | Node capacity (auto-provisioned) | K8s workloads without node management |
| Azure Container Apps | Azure | vCPU + memory per second (scale-to-zero) | ~2–5 s (cold) | Abstracted | KEDA-driven | Microservices, Dapr, event-driven Azure shops |
OIDC-Based Keyless Authentication: The Modern Standard¶
Until 2021, the standard approach to letting a GitHub Actions workflow deploy to AWS or GCP was to create a long-lived cloud credential (an IAM access key or a GCP service account JSON key), paste it into GitHub Secrets, and reference it in the workflow. This approach has a fatal flaw: the credential persists. If a log line accidentally prints it, if a malicious third-party action reads environment variables, or if the repository is compromised, the attacker has a credential that works until someone remembers to rotate it.
OIDC (OpenID Connect) eliminates the stored credential entirely. The mechanism works as follows:
- GitHub’s OIDC provider issues a short-lived, cryptographically signed JWT to the workflow at runtime. The JWT contains claims about the workflow: repository, branch, environment, workflow file name.
- The cloud IAM is configured to trust GitHub’s OIDC provider — specifically, to trust tokens whose claims match a specified pattern (e.g., only tokens from
repo:myorg/myrepo:ref:refs/heads/main). - The workflow exchanges the JWT for a short-lived cloud credential (an AWS STS token, a GCP access token). This token typically lives for one hour.
- The workflow uses the credential to deploy. When the job ends, the credential expires. There is nothing left to steal.
sequenceDiagram
participant W as GitHub Workflow
participant G as GitHub OIDC Provider
participant I as Cloud IAM
participant C as Cloud Runtime (Cloud Run / ECS / AKS)
W->>G: Request OIDC JWT (repo, branch, env claims)
G-->>W: Signed JWT (TTL: 5 min)
W->>I: Exchange JWT + trust policy claim
I-->>W: Short-lived access token (TTL: 1 hr)
W->>C: Deploy image using short-lived token
C-->>W: Deployment confirmed
Note over W,I: Token expires — nothing persists Real-World Incident: The Stolen Key
A startup’s GitHub Actions workflow used an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored as repository secrets. A third-party GitHub Action they pulled in from the Marketplace had been silently compromised in a supply chain attack. The malicious action read environment variables (which GitHub passes to every step) and exfiltrated them to an attacker-controlled endpoint. Within 24 hours, the attacker had spun up EC2 instances for crypto-mining, racking up a $14,000 bill.
After the incident, the team migrated to OIDC. The IAM role’s trust policy scoped access to exactly one repository and branch. There are no longer any credentials stored in GitHub. A compromised action can still read environment variables — but the only value it would find is a five-minute JWT whose trust policy restricts it to specific actions, and which has already expired by the time any exfiltration reaches the attacker.
Implementing OIDC: GitHub Actions to Cloud Run¶
The GCP setup requires two one-time configuration steps: creating a Workload Identity Pool and a Provider inside GCP IAM, and granting the service account the ability to be impersonated by tokens matching the pool’s conditions. Once that trust is established, no GCP credential ever lives inside GitHub.
name: Deploy to Cloud Run
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # Required: allows GitHub to mint an OIDC token
env:
PROJECT_ID: my-gcp-project
REGION: us-central1
SERVICE: my-api
IMAGE: us-central1-docker.pkg.dev/my-gcp-project/services/my-api
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Build and tag the image with the Git SHA — immutable, traceable
- name: Build image
run: |
docker build -t $IMAGE:${{ github.sha }} .
docker tag $IMAGE:${{ github.sha }} $IMAGE:latest
# Authenticate to GCP using OIDC — zero stored credentials
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: >-
projects/123456789/locations/global/workloadIdentityPools/github-pool/
providers/github-provider
service_account: deploy-sa@my-gcp-project.iam.gserviceaccount.com
# Configure Docker to push to Artifact Registry
- name: Set up Docker auth for Artifact Registry
run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
# Push the image (both SHA tag and latest)
- name: Push image
run: |
docker push $IMAGE:${{ github.sha }}
docker push $IMAGE:latest
# Deploy to Cloud Run — reference the immutable SHA tag
- name: Deploy to Cloud Run
run: |
gcloud run deploy $SERVICE \
--image $IMAGE:${{ github.sha }} \
--region $REGION \
--platform managed \
--allow-unauthenticated \
--set-env-vars="ENV=production,VERSION=${{ github.sha }}"
- name: Output service URL
run: |
gcloud run services describe $SERVICE \
--region $REGION \
--format "value(status.url)"
Why id-token: write?
GitHub’s OIDC token is only issued if the workflow explicitly requests the id-token: write permission at the job or workflow level. This is a deliberate security gate — workflows that do not need cloud authentication cannot accidentally get tokens. Always set permissions explicitly rather than relying on the default, which varies by repository setting.
Deploying to AWS ECS Fargate from a Pipeline¶
The AWS pattern mirrors GCP’s: a GitHub OIDC provider is registered in AWS IAM, a role is created with a trust policy that scopes access to the specific repository, and aws-actions/configure-aws-credentials performs the token exchange. The deployment itself updates the ECS task definition to reference the new image SHA and triggers a rolling service update.
name: Deploy to ECS Fargate
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
ECR_REGISTRY: 123456789.dkr.ecr.us-east-1.amazonaws.com
ECR_REPO: my-api
ECS_CLUSTER: production
ECS_SERVICE: my-api-service
TASK_DEF_FAMILY: my-api-task
CONTAINER_NAME: api
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# OIDC exchange: no AWS credentials stored in GitHub
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy-role
aws-region: ${{ env.AWS_REGION }}
# Log in to ECR using the short-lived credentials
- name: Login to ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
# Build and push with immutable SHA tag
- name: Build and push to ECR
run: |
IMAGE="$ECR_REGISTRY/$ECR_REPO:${{ github.sha }}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "IMAGE=$IMAGE" >> $GITHUB_ENV
# Fetch the current task definition JSON
- name: Download current task definition
run: |
aws ecs describe-task-definition \
--task-definition $TASK_DEF_FAMILY \
--query taskDefinition \
> task-def.json
# Render a new task definition revision pointing at the new image
- name: Update image in task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-def.json
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ env.IMAGE }}
# Register the new task definition and update the service
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true # Block until rollout completes
The Image Promotion Pipeline: Build Once, Deploy Everywhere¶
A common anti-pattern is rebuilding the Docker image for each environment (dev, staging, production). This risks subtle differences between environments: a dependency might update between builds, or a build-time environment variable might leak into the image. The correct pattern is build once, promote everywhere.
The image is built exactly once during the CI run, tagged with the immutable Git SHA, and pushed to a registry. Subsequent deployment jobs to staging and production pull that same SHA — not :latest, not a rebuilt image. Environment-specific configuration is injected at runtime via environment variables (12-factor style), not baked into the image. The image itself never changes between environments.
flowchart LR
subgraph CI["CI — Build Stage"]
A[git push] --> B[docker build]
B --> C["tag :sha-abc123f"]
C --> D[Push to primary registry]
end
subgraph REG["Registry"]
D --> E["ECR / GCR / ACR<br/>:sha-abc123f"]
end
subgraph DEPLOY["Deploy Stage — same SHA every env"]
E --> F["Deploy to Dev<br/>--image :sha-abc123f"]
E --> G["Deploy to Staging<br/>--image :sha-abc123f"]
E --> H["Deploy to Production<br/>--image :sha-abc123f"]
end
style CI fill:#1e3a5f,color:#e2e8f0
style REG fill:#1a3a2e,color:#e2e8f0
style DEPLOY fill:#3a1e1e,color:#e2e8f0 Multi-Cloud Promotion: Pushing to Multiple Registries¶
Large enterprises sometimes operate across multiple clouds, each with its own registry. Rather than building twice, the pipeline builds once and replicates the image. Docker supports this natively: tag the same image manifest with multiple registry URIs and push to each.
#!/usr/bin/env bash
# Called from CI after OIDC auth to both AWS and GCP
SHA="${GITHUB_SHA}"
IMAGE_BASE="my-api"
# Registry URIs
GCR="us-central1-docker.pkg.dev/my-gcp-project/services/${IMAGE_BASE}"
ECR="123456789.dkr.ecr.us-east-1.amazonaws.com/${IMAGE_BASE}"
# Build once
docker build -t "${IMAGE_BASE}:${SHA}" .
# Tag for primary (GCR) and secondary (ECR)
docker tag "${IMAGE_BASE}:${SHA}" "${GCR}:${SHA}"
docker tag "${IMAGE_BASE}:${SHA}" "${ECR}:${SHA}"
# Push to both registries — same image manifest, two locations
docker push "${GCR}:${SHA}"
docker push "${ECR}:${SHA}"
echo "Promoted ${SHA} to GCR and ECR"
Registry Mirroring vs. Multi-Push
Multi-push from the pipeline is simple but doubles push time. For large images (multiple GB), use registry replication: configure ECR replication rules or Harbor replication policies to automatically copy images from a primary registry to secondary registries after each push. The pipeline pushes once; replication handles the rest asynchronously.
Managed K8s Comparison: EKS vs. GKE vs. AKS¶
When a client asks which managed Kubernetes to use, the decision rarely comes down to pure technical capability — all three are mature, all three run upstream Kubernetes, and all three offer autoscaling, managed upgrades, and deep integration with their cloud’s IAM. The decision is usually driven by ecosystem fit:
AWS EKS: the broadest ecosystem, the most third-party tooling, the largest community. The control plane costs $0.10/hour (about $73/month), which is notable. Networking is complex (VPC CNI, security groups per pod). Node upgrades require more manual intervention than GKE. Best for teams already invested in the AWS ecosystem (IAM, RDS, ElastiCache, SQS).
GCP GKE: the smoothest operational experience. Autopilot removes node management entirely. Release channels (rapid, regular, stable) automate control plane and node upgrades. Workload Identity for K8s is cleaner than AWS’s IRSA. Network Policy is enabled by default. Best for teams doing data/ML work (BigQuery, Vertex AI integration), or teams that prioritize operational simplicity.
Azure AKS: the right choice for Microsoft-aligned enterprises. AAD integration for RBAC is first-class. Windows node pools (for .NET workloads) are a differentiator. Azure Policy for K8s (built on OPA Gatekeeper) is deeply integrated. Best for enterprises using Active Directory, Azure DevOps, and the Microsoft 365 ecosystem.
Consulting Lens: Cloud Vendor Selection
When advising a client on cloud selection, the answer is rarely “pick one and go all-in.” The stronger framing is: design for portability via containers and OIDC-based auth so vendor lock-in is a deliberate choice, not an accident. AWS for breadth and ecosystem depth. GCP for data and ML workloads anchored in BigQuery and Vertex AI. Azure for Microsoft shops where AD integration and Office 365 data are the gravity center. The right runtime for each cloud: Cloud Run for stateless HTTP services (GCP), ECS Fargate for long-running services (AWS), Container Apps for Dapr-based microservices (Azure). And for K8s: the managed offerings are operationally equivalent; choose the cloud first, then adopt its K8s.
Knowledge check
InterviewWhy is OIDC-based authentication to cloud providers preferable to storing long-lived cloud credentials as CI/CD secrets?
- OIDC tokens are stored encrypted in GitHub Secrets, making them harder to exfiltrate than plaintext keys.
- OIDC tokens have no expiry but are tied to the originating IP address, limiting their usefulness if stolen.
- OIDC tokens are short-lived, auto-rotated per run, scoped to a specific workflow by trust policy, and leave nothing to steal — stored credentials are a persistent attack surface that can leak via logs, third-party actions, or a compromised repository.
- OIDC tokens require no IAM configuration and work with all cloud providers by default.
Answer
OIDC tokens are short-lived, auto-rotated per run, scoped to a specific workflow by trust policy, and leave nothing to steal — stored credentials are a persistent attack surface that can leak via logs, third-party actions, or a compromised repository.
OIDC eliminates the stored-credential problem entirely. A GitHub Actions workflow requests a signed JWT from GitHub’s OIDC provider at runtime; the JWT is exchanged for a short-lived cloud access token (typically 1 hour). When the job ends, the token expires. There is no credential to rotate after a breach because nothing persists. Stored secrets (AWS access keys, GCP service account JSON) remain valid until manually rotated, can appear in logs, and can be read by any action with environment access — a persistent attack surface OIDC removes completely.
Exercise
Exercise 13.1 · OIDC Deploy to Cloud Run You have a Python FastAPI application in a GitHub repository. Write a GitHub Actions workflow that:
- Triggers on push to
main. - Uses OIDC to authenticate to GCP (assume the Workload Identity Pool is already created with ARN
projects/99/locations/global/workloadIdentityPools/gh/providers/gh-providerand service accountdeploy@myproject.iam.gserviceaccount.com). - Builds a Docker image and pushes it to Artifact Registry at
us-central1-docker.pkg.dev/myproject/apps/api:<sha>. - Deploys to Cloud Run service
apiinus-central1using the exact SHA tag.
Show solution
name: Deploy API to Cloud Run
on:
push:
branches: [main]
permissions:
contents: read
id-token: write # Must be explicit for OIDC token issuance
env:
PROJECT: myproject
REGION: us-central1
IMAGE: us-central1-docker.pkg.dev/myproject/apps/api
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: >-
projects/99/locations/global/workloadIdentityPools/gh/providers/gh-provider
service_account: deploy@myproject.iam.gserviceaccount.com
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- name: Build and push
run: |
docker build -t $IMAGE:${{ github.sha }} .
docker push $IMAGE:${{ github.sha }}
- name: Deploy to Cloud Run
run: |
gcloud run deploy api \
--image $IMAGE:${{ github.sha }} \
--region $REGION \
--platform managed
Key Takeaways
- Each cloud offers a runtime spectrum from fully managed functions (Lambda, Cloud Functions) to managed Kubernetes (EKS, GKE, AKS); choose based on operational complexity tolerance and ecosystem fit.
- OIDC-based keyless authentication eliminates stored cloud credentials in CI — short-lived tokens are vended per workflow run via a GitHub-to-cloud trust policy; nothing persists to steal.
- Build once, tag with the Git SHA, push to a registry; promote that exact immutable SHA to every environment — never rebuild for staging or production.
- Cloud vendor selection often comes down to ecosystem: AWS for breadth, GCP for data/ML, Azure for Microsoft enterprise shops — design for portability with containers so lock-in is a choice.