Build a Kubernetes Deployment Pipeline with GitHub Actions (Step by Step)
Build a Kubernetes deployment pipeline using GitHub Actions that builds a Docker image, pushes to a container registry, deploys to K3s (or Minikube) with kubectl, implements rolling updates, and configures health checks.
What You’ll Build
You’ll create a fully automated CI/CD pipeline. Every time you push to the main branch, GitHub Actions builds your application, packages it as a Docker image, pushes it to Docker Hub (or GitHub Container Registry), then deploys it to a local Kubernetes cluster (K3s or Minikube) using kubectl with zero-downtime rolling updates. This is the deployment pipeline used by DodaTech to ship updates for DodaZIP and Durga Antivirus Pro backend services.
Why Kubernetes Pipelines Matter
Manual deployment is error-prone and slow. A CI/CD pipeline automates testing, building, and deploying so that every commit can reach production safely. Kubernetes rolling updates ensure zero downtime — old pods stay running until new ones pass health checks. This pattern is the industry standard for cloud-native applications, used by organizations from startups to FAANG.
Prerequisites
- Docker and Kubernetes basics
- A Kubernetes cluster (K3s or Minikube)
- A Docker Hub account (or GitHub Container Registry)
- Git and GitHub Actions familiarity
- Go or Node.js (for the sample app)
Step 1: Sample Application
Let’s use a simple Go HTTP server as our deployment target:
// main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
version := os.Getenv("VERSION")
if version == "" {
version = "1.0.0"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "App version %s running on pod %s", version, os.Getenv("HOSTNAME"))
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
http.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Ready"))
})
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}# Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]Step 2: Kubernetes Manifests
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
labels:
app: myapp
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
ports:
- containerPort: 8080
env:
- name: PORT
value: "8080"
- name: VERSION
value: "${IMAGE_TAG}"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
name: myapp-service
spec:
type: NodePort
selector:
app: myapp
ports:
- port: 80
targetPort: 8080# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
spec:
rules:
- host: myapp.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-service
port:
number: 80Expected output: A deployment with 3 replicas, rolling update strategy (max 1 pod down during update), liveness probe (checks every 10s), readiness probe (checks every 5s), and resource limits. A NodePort service exposes the app. An optional Ingress routes traffic.
Step 3: GitHub Actions Workflow
# .github/workflows/deploy.yml
name: Build and Deploy to K3s
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: docker.io
IMAGE_NAME: dodatech/myapp
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.21'
- run: go test ./...
build-and-push:
needs: test
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Generate image tag
id: tag
run: |
SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7)
echo "tag=${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
deploy:
needs: build-and-push
runs-on: [self-hosted, k3s]
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.K3S_KUBECONFIG }}" > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Substitute environment variables
run: |
export REGISTRY=${{ env.REGISTRY }}
export IMAGE_NAME=${{ env.IMAGE_NAME }}
export IMAGE_TAG=${{ needs.build-and-push.outputs.image_tag }}
envsubst < k8s/deployment.yaml > k8s/deployment.prod.yaml
- name: Deploy to K3s
run: |
kubectl apply -f k8s/deployment.prod.yaml
kubectl apply -f k8s/service.yaml
kubectl rollout status deployment/myapp --timeout=120s
- name: Verify deployment
run: |
kubectl get pods
kubectl get svc myapp-service
curl -s http://$(kubectl get svc myapp-service -o jsonpath='{.spec.clusterIP}'):80/ | grep -q "App version"
echo "Deployment verified successfully!"Expected output: On push to main, the workflow:
- Runs tests
- Builds Docker image with git SHA tag +
latest - Pushes to Docker Hub
- Substitutes image tag in the deployment manifest
- Applies manifests to K3s
- Monitors rollout status (waits up to 120s)
- Verifies the app responds correctly
Step 4: Setting Up the Self-Hosted Runner
# On the K3s server
mkdir -p /home/runner && cd /home/runner
# Download and configure GitHub Actions runner
curl -O https://github.com/actions/runner/releases/download/v2.314.1/actions-runner-linux-x64-2.314.1.tar.gz
tar xzf actions-runner-linux-x64-2.314.1.tar.gz
# Register (get token from GitHub repo Settings → Actions → Runners)
./config.sh --url https://github.com/YOUR_USER/YOUR_REPO --token YOUR_TOKEN
# Install as a service
sudo ./svc.sh install
sudo ./svc.sh startStep 5: Local Testing with Minikube
If you don’t have K3s, test locally:
# Start Minikube
minikube start --cpus 4 --memory 4096
# Use Minikube's Docker daemon
eval $(minikube docker-env)
# Build image locally
docker build -t dodatech/myapp:local .
# Apply manifests (update deployment.yaml to use imagePullPolicy: Never)
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
# Expose the service
minikube service myapp-serviceArchitecture
graph LR
A[Developer pushes to main] --> B[GitHub Actions Triggered]
subgraph "Test Job"
C[Checkout code]
D[Run go test]
C --> D
end
subgraph "Build & Push Job"
E[Build Docker image]
F[Push to Registry]
E --> F
end
subgraph "Deploy Job (Self-Hosted Runner)"
G[Substitute env vars]
H[kubectl apply]
I[Rollout status]
J[Verify endpoint]
G --> H --> I --> J
end
B --> C
D --> E
F --> G
subgraph "K3s Cluster"
K[Deployment
3 replicas]
L[Service
NodePort]
M[Ingress]
K --> L --> M
end
H --> K
style A fill:#24292f,color:white
style K fill:#326ce5,color:white
style M fill:#326ce5,color:white
Common Errors
1. “kubectl: command not found” on self-hosted runner
The self-hosted runner does not have kubectl installed. Install it: curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && chmod +x kubectl && sudo mv kubectl /usr/local/bin/.
2. Rolling update stays at “Waiting for deployment” and times out
The new pods fail their readiness probe. Check kubectl describe pod <new-pod> for events. Common causes: wrong image tag, missing environment variables, or the app crashes on startup. The old pods remain running, so the service stays up — but the new version never deploys.
3. “ImagePullBackOff” or “ErrImagePull” on K3s
The image is not accessible. If using a private registry, create a pull secret: kubectl create secret docker-registry regcred --docker-server=docker.io --docker-username=USER --docker-password=PAT and add imagePullSecrets: [{ name: regcred }] to the deployment.
4. GitHub Action runner disconnected or offline
The runner needs a stable network connection to GitHub. Check systemd logs: sudo journalctl -u actions.runner.* -f. Ensure the runner machine has outbound HTTPS access to github.com and *.actions.githubusercontent.com.
5. Service not accessible after deployment
The NodePort service exposes on a random port (30000-32767). Run kubectl get svc to see the port. For ingress, ensure the Ingress controller is installed (kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.5/deploy/static/provider/cloud/deploy.yaml).
Practice Questions
1. Why do we use a rolling update strategy instead of recreating all pods at once? Rolling updates replace pods one at a time, keeping the service available throughout. Recreate deletes all old pods before creating new ones — causing downtime. Rolling updates allow zero-downtime deployments with automatic rollback if probes fail.
2. What is the difference between liveness and readiness probes? Liveness probes restart the container if it fails (e.g., deadlock). Readiness probes control whether the pod receives traffic (e.g., waiting for cache warmup). A pod failing readiness stops serving requests but stays alive. A pod failing liveness gets killed and recreated.
3. Why use a self-hosted runner instead of GitHub-hosted runners for deployment? Self-hosted runners have direct access to the internal K3s cluster. GitHub-hosted runners are external and cannot reach private clusters. Self-hosted runners also avoid per-minute costs for long-running deployments and can cache Docker layers for faster builds.
4. Challenge: Rollback on failure
Add a step that runs kubectl rollout undo deployment/myapp if the verification step fails. Use continue-on-error: false on the verification step. Test by deploying a broken version — the pipeline should automatically roll back.
5. Challenge: Environment-specific deployments
Add deployment branches: staging deploys to a staging namespace, main deploys to production. Use Kubernetes namespaces to isolate environments. Add environment variables in GitHub Actions for namespace, replica count, and resource limits.
FAQ
Next Steps
- Add Docker image scanning in the pipeline
- Explore GitHub Actions matrix builds for multi-arch images
- Learn Kubernetes monitoring with Prometheus
- Check the CI/CD Pipeline project for deeper CI/CD patterns
- Try the Full-Stack Docker project for multi-service deployments
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro