Deploying to Kubernetes manually is slow and error-prone. Every deploy requires building an image, pushing it to a registry, and updating the cluster.
With CI/CD, all of that happens automatically on every push to your main branch. Write code, push to GitHub, and your new version is live in minutes — with zero-downtime rolling updates.
This tutorial builds a complete pipeline with GitHub Actions.
The Pipeline Overview
Developer pushes code to main branch
↓
GitHub Actions triggers workflow
↓
Build Docker image
↓
Push image to registry (GHCR or Docker Hub)
↓
Deploy to Kubernetes (kubectl apply or helm upgrade)
↓
Wait for rollout to complete
↓
Done — new version is live
Prerequisites
- A Kubernetes cluster (for this tutorial, we use a real cluster — not minikube). Options: k3s on a VPS, or any cloud provider.
- A GitHub repository with your app code and Kubernetes manifests
- Basic understanding of GitHub Actions (jobs, steps, secrets)
Step 1: Prepare the Kubernetes Manifests
Keep your Kubernetes YAML files in your repository, under a k8s/ directory:
myapp/
├── Dockerfile
├── k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── configmap.yaml
└── src/
└── ...
Use a placeholder for the image tag that CI will replace:
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 2
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: ghcr.io/kemalcodes/myapp:IMAGE_TAG # CI replaces IMAGE_TAG
ports:
- containerPort: 8080
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
Note the health probes — these are essential for zero-downtime deployments. Kubernetes will not route traffic to a Pod until its readiness probe passes.
Step 2: Set Up GitHub Secrets
GitHub Actions needs credentials to push to the container registry and access your Kubernetes cluster.
Go to your GitHub repository → Settings → Secrets and variables → Actions → New repository secret.
Add these secrets:
| Secret name | Value |
|---|---|
KUBE_CONFIG | Base64-encoded kubeconfig file |
Getting the kubeconfig:
# On Linux:
base64 -w 0 ~/.kube/config
# On macOS:
base64 -i ~/.kube/config | tr -d '\n'
# Copy the output and paste it as the KUBE_CONFIG secret value
For Docker Hub instead of GHCR, also add:
| Secret name | Value |
|---|---|
DOCKERHUB_USERNAME | Your Docker Hub username |
DOCKERHUB_TOKEN | Your Docker Hub access token |
Security note: Create a dedicated kubeconfig with minimal permissions — only the rights needed to update Deployments in your namespace. Do not use a cluster-admin kubeconfig in CI.
Step 3: Create the GitHub Actions Workflow
# .github/workflows/deploy.yaml
name: Build and Deploy
on:
push:
branches:
- main # Trigger on push to main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} # e.g., kemalcodes/myapp
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Required to push to GHCR
steps:
# Step 1: Check out code
- name: Checkout
uses: actions/checkout@v4
# Step 2: Set image tag to the commit SHA
- name: Set image tag
id: meta
run: |
echo "IMAGE_TAG=${{ github.sha }}" >> $GITHUB_OUTPUT
# Step 3: Log in to GitHub Container Registry
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Step 4: Build and push Docker image
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.IMAGE_TAG }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha # Use GitHub Actions cache for faster builds
cache-to: type=gha,mode=max
# Step 5: Set up kubectl
- name: Set up kubectl
uses: azure/setup-kubectl@v4
# Step 6: Configure kubeconfig
- name: Configure kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
# Step 7: Replace image tag in deployment manifest (write to temp dir)
- name: Update image tag in manifests
run: |
mkdir -p /tmp/k8s-deploy
for f in k8s/*.yaml; do
sed "s|IMAGE_TAG|${{ steps.meta.outputs.IMAGE_TAG }}|g" "$f" > /tmp/k8s-deploy/"$(basename $f)"
done
# Step 8: Apply manifests
- name: Deploy to Kubernetes
run: |
kubectl apply -f /tmp/k8s-deploy/
# Step 9: Wait for rollout to complete
- name: Wait for rollout
run: |
kubectl rollout status deployment/myapp --timeout=5m
Using Helm in CI/CD
If you use Helm (covered in Tutorial #7), replace steps 7-9 with:
# Step 7: Set up Helm
- name: Set up Helm
uses: azure/setup-helm@v4
# Step 8: Deploy with Helm
- name: Helm upgrade
run: |
helm upgrade --install myapp ./helm/myapp \
--namespace default \
--set image.tag=${{ steps.meta.outputs.IMAGE_TAG }} \
--set image.repository=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} \
--atomic \
--timeout 5m
--atomic automatically rolls back if the deployment fails.
Multi-Environment Pipeline
A real pipeline promotes changes through environments:
name: Build and Deploy
on:
push:
branches:
- main
- develop
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Required to push to GHCR
outputs:
image_tag: ${{ steps.meta.outputs.IMAGE_TAG }}
steps:
- uses: actions/checkout@v4
- name: Set image tag
id: meta
run: echo "IMAGE_TAG=${{ github.sha }}" >> $GITHUB_OUTPUT
# ... build and push steps ...
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
if: github.ref == 'refs/heads/develop'
steps:
- name: Deploy to staging
run: |
helm upgrade --install myapp ./helm/myapp \
--namespace staging \
--set image.tag=${{ needs.build.outputs.image_tag }}
deploy-production:
needs: build
runs-on: ubuntu-latest
environment: production # Requires manual approval in GitHub
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to production
run: |
helm upgrade --install myapp ./helm/myapp \
--namespace production \
--set image.tag=${{ needs.build.outputs.image_tag }}
Set up environment protection rules in GitHub (Settings → Environments → production → Required reviewers) so production deployments require manual approval.
Rolling Update Strategy
The deployment’s strategy field controls how rolling updates work:
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # Never take Pods down before new ones are ready
maxSurge: 1 # Allow 1 extra Pod during the rollout
With maxUnavailable: 0, Kubernetes always starts a new Pod and waits for it to be ready before stopping an old one. Zero downtime.
Automatic Rollback on Failure
Add a step to check deploy success and roll back if it fails:
- name: Deploy and verify
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.IMAGE_TAG }}"
kubectl set image deployment/myapp myapp=$IMAGE
if ! kubectl rollout status deployment/myapp --timeout=5m; then
echo "Rollout failed! Rolling back..."
kubectl rollout undo deployment/myapp
exit 1
fi
Testing Before Deploy (Optional)
Add a test job before deploying:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
docker compose -f docker-compose.test.yaml up --exit-code-from tests
build-and-deploy:
needs: test # Only deploy if tests pass
runs-on: ubuntu-latest
...
Common Mistakes
Using cluster-admin kubeconfig in CI
Create a dedicated ServiceAccount with minimal permissions. The CI pipeline only needs to update Deployments in specific namespaces.
# ci-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deployer
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer
namespace: production
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: production
roleRef:
kind: Role
name: ci-deployer
apiGroup: rbac.authorization.k8s.io
Not waiting for rollout to complete
If you do not wait for kubectl rollout status, the CI job may report success while the deploy is still in progress (or failing).
kubectl rollout status deployment/myapp --timeout=5m
Using the latest image tag in CI
Always use the commit SHA as the image tag. Using latest makes it impossible to know exactly what is running in production.
# Good
image: myapp:abc1234def5678 # commit SHA
# Bad
image: myapp:latest # could be anything
What’s Next?
Your deploys are automated. Before going to production, you need to secure your cluster.
Next: Kubernetes Tutorial #10: Kubernetes Security Best Practices