Skip to content

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:

  1. 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.
  2. 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).
  3. 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.
  4. 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.

.github/workflows/deploy-cloud-run.yml
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.

.github/workflows/deploy-ecs.yml
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.

multi-cloud-push.sh
#!/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:

  1. Triggers on push to main.
  2. Uses OIDC to authenticate to GCP (assume the Workload Identity Pool is already created with ARN projects/99/locations/global/workloadIdentityPools/gh/providers/gh-provider and service account deploy@myproject.iam.gserviceaccount.com).
  3. Builds a Docker image and pushes it to Artifact Registry at us-central1-docker.pkg.dev/myproject/apps/api:<sha>.
  4. Deploys to Cloud Run service api in us-central1 using the exact SHA tag.
Show solution
.github/workflows/deploy.yml
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.