Deploying to Kubernetes with GitHub Actions: CI/CD Pipeline Design
GitHub Actions is a popular starting point for a Kubernetes deploy pipeline. But before moving beyond the basic examples, there are a few critical design decisions to make.
Push-based or GitOps?
The first question: does the pipeline deploy directly to the cluster (push-based), or does it commit to Git and let a tool like Argo CD pull changes into the cluster (pull-based)?
For small teams, push-based is a simpler starting point with less operational overhead. If you have multiple clusters or drift detection matters, move to GitOps. This post covers the push-based approach.
Eliminate the kubeconfig secret with OIDC
The most common mistake with Kubernetes authentication in GitHub Actions: storing a long-lived kubeconfig file as a GitHub secret. Rotating it is difficult, and the blast radius when it leaks is large.
The modern approach: cloud provider OIDC integration. For Azure AKS:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/aks-set-context@v4
with:
resource-group: my-rg
cluster-name: my-aks
With Workload Identity, there is no need for a kubeconfig secret at all. A token is generated automatically for each workflow run and is short-lived.
Pipeline structure
name: Deploy to Kubernetes
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # required for OIDC
steps:
- uses: actions/checkout@v4
- name: Build & push Docker image
uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Set Kubernetes context
uses: azure/aks-set-context@v4
with:
resource-group: ${{ vars.AKS_RG }}
cluster-name: ${{ vars.AKS_CLUSTER }}
- name: Deploy with Kustomize
run: |
cd k8s/overlays/production
kustomize edit set image \
app=ghcr.io/${{ github.repository }}:${{ github.sha }}
kubectl apply -k .
kubectl rollout status deployment/app \
-n backend-prod --timeout=5m
The rollout status command is critical — the pipeline waits for a healthy rollout after deploying. If the timeout passes, the pipeline fails and a rollback hook can be triggered automatically.
Kustomize overlay structure
k8s/
├── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── overlays/
├── dev/
│ └── kustomization.yaml # replicas: 1
├── uat/
│ └── kustomization.yaml # replicas: 2
└── production/
└── kustomization.yaml # replicas: 5
Each overlay defines only what changes — it does not repeat the base. kustomize edit set image writes the image tag into the overlay file, making every deploy fully traceable in Git history.
Rollback strategy
Kubernetes built-in rollback:
# Roll back the last deployment
kubectl rollout undo deployment/app -n backend-prod
# Roll back to a specific revision
kubectl rollout history deployment/app -n backend-prod
kubectl rollout undo deployment/app --to-revision=3 -n backend-prod
On the GitHub Actions side, create a separate workflow_dispatch-triggered workflow for rollbacks. Always using the same deploy mechanism for rollbacks — rather than running ad-hoc kubectl commands — improves auditability and eliminates the “how was this rollback done?” question.
Build cache for faster pipelines
The cache-from: type=gha and cache-to: type=gha,mode=max lines use the GitHub Actions cache layer. Unchanged layers are not rebuilt. For a typical application image, this difference can cut build time from 8–10 minutes down to 2–3 minutes.
What was left out intentionally
Topics not covered here: GitOps with Argo CD, image vulnerability scanning with Trivy, SLSA provenance, Helm chart versioning. Each deserves its own post.
If you want to assess your current pipeline, we can schedule a free 30-minute technical review.