Skip to content
Build a Kubernetes Deployment Pipeline with GitHub Actions (Step by Step)

Build a Kubernetes Deployment Pipeline with GitHub Actions (Step by Step)

DodaTech Updated Jun 20, 2026 8 min read

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: 80

Expected 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:

  1. Runs tests
  2. Builds Docker image with git SHA tag + latest
  3. Pushes to Docker Hub
  4. Substitutes image tag in the deployment manifest
  5. Applies manifests to K3s
  6. Monitors rollout status (waits up to 120s)
  7. 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 start

Step 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-service

Architecture


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

Can I use this with Docker Hub or GitHub Container Registry?
Both work. The example uses Docker Hub. For GHCR, change REGISTRY to ghcr.io and login with ${{ secrets.GITHUB_TOKEN }}. Ensure the repository has write permissions for the packages scope.
How do I handle database migrations during deployment?
Run migrations as a Kubernetes Job before the deployment update. Add a GitHub Actions step: kubectl apply -f k8s/migration-job.yaml and kubectl wait --for=condition=complete job/migration --timeout=60s. Then proceed with the deployment.
What if the cluster is not accessible from GitHub Actions?
Use a VPN, wireguard tunnel, or deploy a self-hosted runner inside the cluster. All three approaches give the runner network access to the Kubernetes API server.

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