32.3 GitHub Actions: Build, Push, and Deploy to Kubernetes
Right, so you’ve got some code, a Kubernetes cluster, and a deep-seated aversion to doing things manually more than twice. Good. Let’s automate this thing into oblivion. We’re going to use GitHub Actions because, well, it’s right there next to your code and it’s surprisingly competent once you wrestle it into shape. The goal is simple: every time you push to main, we build a new container image, push it to a registry, and tell your Kubernetes cluster to pull it and get on with its life.
This isn’t magic; it’s just gluing together a few tools with YAML, which is basically the duct tape of the cloud native world. I’ll show you the moving parts, call out the places where you’re most likely to get cut, and explain why we’re doing it this way and not some other, more “theoretically pure” way.
The Core Concept: It’s All About the YAML
Your entire pipeline lives in .github/workflows/ci-cd.yaml. This file is the conductor of our little orchestra. It defines the triggers (when this runs), the jobs (what runs), and the steps (how it runs). The entire process hinges on two key secrets: your container registry credentials and your Kubernetes cluster access. We’ll get to those. First, the skeleton.
name: CI/CD to Kubernetes
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
# The steps will go here. We'll fill this out next.
Step 1: Checking Out Code and Building the Image
First, we need your code. Then, we need to turn it into a runnable artifact. For us, that’s a Docker image. We’ll use the official docker/build-push-action because writing raw docker build commands is for masochists.
Why do we tag the image with the SHA? Because it’s unique, immutable, and traceable. Using latest is a fantastic way to have a confusing afternoon trying to figure out what’s actually running. The SHA tells you exactly what code is in that container.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io # Using GitHub Container Registry; could be Docker Hub, GAR, etc.
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/my-app:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/my-app:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Notice the cache tricks? That’s a pro-tier move. Buildx caches the layers in GitHub’s cache, so your next build will be blisteringly fast if only your code changed and not your Dockerfile dependencies.
Step 2: Deploying to Kubernetes with Kustomize
Now for the fun part: telling Kubernetes to stop running the old thing and start running the new thing. We could use raw kubectl set image, but that’s clunky and error-prone. Instead, we’ll use Kustomize, the built-in templating tool that kubectl loves. It’s the least-bad option for simple deployments.
We’ll keep a k8s/overlays/prod directory with a kustomization.yaml file. Its job is to patch the base deployment with environment-specific things, most importantly, the new image tag.
k8s/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: my-app-image
newTag: #{GITHUB_SHA}#
Wait, what’s with the #{}#? That’s not standard YAML! You’re right. We can’t put a dynamic value here directly. So we’ll use a bit of sed magic in the workflow to replace it on the fly. It’s a hack, but it’s a reliable, transparent, and widely-used hack.
- name: Update Kustomize image tag
run: |
sed -i 's/#{GITHUB_SHA}#/${{ github.sha }}/' ./k8s/overlays/prod/kustomization.yaml
- name: Deploy to Kubernetes
uses: actions/github-script@v7
with:
script: |
const { exec } = require('child_process').promisify;
const kubeConfig = Buffer.from(`${{ secrets.KUBE_CONFIG }}`, 'base64').toString('ascii');
require('fs').writeFileSync(process.env.RUNNER_TEMP + '/kubeconfig', kubeConfig);
await exec(`KUBECONFIG=${process.env.RUNNER_TEMP}/kubeconfig kubectl apply -k ./k8s/overlays/prod`);
The Big Gotcha: Cluster Access Secrets
This is where most people get stuck. The secrets.KUBE_CONFIG secret needs to contain a base64-encoded string of your entire kubeconfig file. Why base64? It ensures the YAML-within-YAML doesn’t get mangled.
cat ~/.kube/config | base64(copy the output)- Go to your GitHub repo -> Settings -> Secrets and variables -> Actions
- Create a new secret named
KUBE_CONFIGand paste the base64 string.
This gives the workflow the credentials to talk to your cluster. Protect this secret like it’s the password to your main Netflix profile.
Why This Works and When It Doesn’t
This pipeline is brilliant for its simplicity. It’s easy to understand and debug. The entire deployment process is literally just applying a directory of YAML files, which is exactly what you’d do manually.
But let’s be honest about its flaws. The sed trick is janky. For more complex needs, you’d want a proper templating engine like Helm or a GitOps tool like ArgoCD, which would watch your registry for new images and deploy them automatically. This setup also does a zero-downtime update only if you’ve configured your Deployment with a proper readinessProbe and rollingUpdate strategy. If you haven’t, well, you’re about to learn why those things are important.
This is the foundation. It gets the job done without overcomplicating things. Now go plug in your own image names and paths, and let’s see this thing run.