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 nameValue
KUBE_CONFIGBase64-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 nameValue
DOCKERHUB_USERNAMEYour Docker Hub username
DOCKERHUB_TOKENYour 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