Learn GitHub Actions - Continuous Deployment (CD) to Cloud & Kubernetes
Episode 14 of 21

Learn GitHub Actions - Continuous Deployment (CD) to Cloud & Kubernetes

This episode discusses deploying to the cloud and Kubernetes automatically from the pipeline: Cloud Run and Vercel for serverless PaaS, then deploying manifests to a cluster with kubectl and updating Helm releases with helm upgrade.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

In episode 13 we deployed to our own servers via SSH and Ansible — like managing your own home: free, but all maintenance is on us. Now imagine a service that takes care of the building, security, and maintenance for you. That's Cloud PaaS and Kubernetes: we focus on the application, the platform takes care of the infrastructure.

Episode 14 covers two major CD paths: PaaS/Serverless (Cloud Run, Vercel, AWS Lambda) which hides servers completely, and Kubernetes which gives full control with equal complexity. Both can be deployed automatically from a workflow.

Main Discussion

Cloud Run: Serverless on GCP

Cloud Run runs containers without needing to manage nodes. Since it accepts container images (which we built in episode 12), the deploy flow is short: build the image, upload it to a registry, then tell Cloud Run to use the new version.

Authentication uses the OIDC from episode 10 — no static credentials. The GCP project, region, and service name can be set as vars:

Deploy to Cloud Run using OIDC
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/123456789012/locations/global/workloadIdentityPools/ci-pool/providers/github-provider
          service_account: ci-deploy@devvnull-prod.iam.gserviceaccount.com
      - uses: google-github-actions/setup-gcloud@v2
      - run: gcloud builds submit --tag gcr.io/devvnull-prod/app
      - run: |
          gcloud run deploy app \
            --image gcr.io/devvnull-prod/app \
            --region asia-southeast1 \
            --allow-unauthenticated

After auth succeeds, all gcloud commands use the trusted service account identity. gcloud run deploy creates a new revision and does traffic shifting — rolling back on Cloud Run just means pointing at an old revision.

Vercel: A Frontend That Deploys Itself

For frontends, Vercel is the darling: every push to the main branch can become a production release directly. Authentication via a token created in the Vercel dashboard, stored as a secret along with the project identity:

Deploy frontend to Vercel
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - name: Deploy ke Vercel
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
        run: npx vercel --prod --token $VERCEL_TOKEN --yes

npx vercel --prod builds and publishes a production release in a single command. The same pattern applies to similar platforms: AWS Lambda uses the aws CLI (with configure-aws-credentials from episode 10) or the SAM framework, and Netlify has the netlify deploy --prod command with an equivalent token.

Deploying to Kubernetes with kubectl

On the Kubernetes side, you hold full control over deployments, services, and autoscaling — and you're responsible for all of it. The first step in the workflow: preparing the kubeconfig so kubectl knows which cluster to talk to. The kubeconfig is base64-encoded, stored as a secret, then written to the runner:

KubernetesDeploy manifests with kubectl
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Siapkan kubeconfig
        env:
          KUBECONFIG_B64: ${{ secrets.KUBECONFIG_BASE64 }}
        run: |
          mkdir -p $HOME/.kube
          echo "$KUBECONFIG_B64" | base64 -d > $HOME/.kube/config
          chmod 600 $HOME/.kube/config
      - name: Pasang kubectl
        run: |
          curl -fsSL -o kubectl \
            https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl
          chmod +x kubectl
          sudo mv kubectl /usr/local/bin/
      - name: Terapkan manifest
        run: |
          kubectl apply -f k8s/namespace.yaml
          kubectl apply -f k8s/deployment.yaml
          kubectl rollout status deployment/app -n app

kubectl apply is declarative: it reconciles the cluster state with what's written in the manifests — not executing step by step. rollout status makes the workflow wait for the deployment to truly finish and pods to be healthy before declaring success, so a half-finished deploy can't slip through.

Tip

Limit the kubeconfig to a single namespace or a single service account with narrow RBAC. A production kubeconfig holds the ability to destroy a cluster — don't give it to a workflow that also runs on pull requests. Separate kubeconfigs per environment, and restrict that secret to the production environment only.

Helm: Packaging and Updating Releases

Raw manifests quickly get out of hand as environments and configurations accumulate. Helm packages everything into a chart — manifest templates with values injectable at deploy time. From the workflow, the chart is applied with helm upgrade --install, and the image version is injected via --set:

Update a Helm release from a workflow
      - name: Pasang Helm
        run: |
          curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
      - name: Upgrade release
        run: |
          helm upgrade --install app ./helm/app \
            --namespace app \
            --set image.tag=${{ github.sha }}

helm upgrade --install means: install if it doesn't exist, update if it does. Injecting the commit hash as the image tag ensures the cluster uses exactly the same version that was just built and tested in the pipeline. The combination of kubectl apply and Helm underlies the GitOps pattern — the cluster follows the state stored in git.

Choosing a Deployment Target

AspectCloud RunVercelKubernetes
Manages serversNo (serverless)NoYes, fully
Best fitGeneral API/containersStatic frontend/Next.jsComplex apps, HA
Deploy toolinggcloud + OIDCvercel CLIkubectl / helm
ConfigurationSmall service YAMLProject configurationManifests + charts
Learning curveLowVery lowHigh

Common Mistakes

MistakeSymptomSolution
Kubeconfig too broadWorkflow could delete other resourcesLimit to a dedicated namespace/service account
Forgetting id-token: writeOIDC fails, cloud rejectsAdd the id-token permission on the job
Deploy without waiting for rolloutPipeline succeeds though pods crashUse kubectl rollout status
Vercel token leaking in logsFrontend credentials exposedPass via env, not as a command argument
Helm without an existing --namespaceRelease fails to createCreate the namespace first with kubectl apply

Conclusion

Deployment now spreads across the whole cloud and cluster ecosystem:

  • Cloud Run accepts containers via gcloud run deploy with OIDC authentication.
  • Vercel (and Netlify, Lambda) deploys frontend/serverless apps from a single CLI command.
  • Kubernetes accepts manifests via kubectl apply -f, complete with rollout status verification.
  • Helm packages manifests into charts updated with helm upgrade --install and dynamic image tags.

In the next episode 15, we move to the most fundamental layer of the entire pipeline: Setup & Management of Self-Hosted Runners — when you need your own runner, how to register it on Linux, securing it, and autoscaling it on Kubernetes with Actions Runner Controller. Because all those great pipelines above run on runners!