DevOps & observability / Tutorial

Deploy to Managed Kubernetes from GitHub Actions: A Complete CI/CD Walkthrough

Build a GitHub Actions pipeline that tests a Node.js app, publishes its Docker image to GHCR, and deploys it to DigitalOcean Kubernetes.

Your application is containerized, but releasing it still involves building an image locally, pushing it to a registry, connecting to Kubernetes, changing the workload, and watching the rollout. That process is manageable once. Repeated across developers and releases, it becomes slow and error-prone.

A CI/CD pipeline turns those manual commands into a consistent, traceable deployment process. In this tutorial, GitHub Actions will test a Node.js application, build its Docker image, publish the image to GitHub Container Registry (GHCR), obtain short-lived credentials for a DigitalOcean Kubernetes cluster, deploy the new image, and verify the rollout.

The resulting path is:

Push to main
    → Run tests
    → Build Docker image
    → Push image to GHCR
    → Update Kubernetes Deployment
    → Verify rollout

Although this walkthrough uses DigitalOcean Kubernetes, the same structure works with other managed Kubernetes providers by replacing the authentication steps.

Prerequisites and project setup

You will need:

  • A GitHub repository containing a containerized application
  • A working Dockerfile
  • A package-lock.json file so npm ci can install deterministic dependencies
  • A DigitalOcean Kubernetes cluster
  • A DigitalOcean API token that can retrieve cluster credentials
  • kubectl and doctl installed locally for initial setup
  • Permission to configure GitHub Actions, secrets, variables, and packages

For a custom-scoped DigitalOcean token, include kubernetes:access_cluster and its required scopes. This permits the workflow to retrieve cluster credentials without granting permission to create or delete clusters. DigitalOcean documents the required scopes here.

The example project has this structure:

my-app/
├── src/
├── tests/
├── Dockerfile
├── package.json
├── package-lock.json
├── k8s/
│   ├── deployment.yaml
│   └── service.yaml
└── .github/
    └── workflows/
        └── deploy.yaml

The application is assumed to listen on port 3000 and expose a successful GET /health endpoint. Change those values in the manifests if your application behaves differently. The workflow can also be adapted to Python, Go, Java, or another language by replacing the Node.js setup and test steps.

How the deployment pipeline fits together

Each component has a separate responsibility.

The deployment pipeline. A push or merge to main starts a GitHub Actions job on an ubuntu-latest runner in the production environment, limited by the production-deployment concurrency group. The job runs six steps in order: test with npm ci and npm test, build and push the image with GITHUB_TOKEN, fetch cluster credentials with doctl using a 600-second expiry, write the SHA-tagged image into deployment.yaml in place of IMAGE_PLACEHOLDER, kubectl apply the k8s directory, and verify with kubectl rollout status and a 180-second timeout. The image lands in GHCR as ghcr.io/owner/repo tagged with the Git SHA, in a public package the cluster pulls anonymously. DigitalOcean Kubernetes runs the my-app Deployment behind a LoadBalancer Service on port 80, and Pods receive traffic only after GET /health passes. The commit, workflow run, image tag, and Deployment revision all share the same SHA.

GitHub stores the source code and triggers the workflow when a commit reaches main. GitHub Actions provides a temporary Ubuntu runner that installs dependencies, executes tests, and runs the deployment commands.

GHCR stores the built container image so that it is accessible outside the runner. Every image is tagged with the full Git commit SHA, such as:

ghcr.io/example/my-app:72a39d5...

A commit-specific tag creates an explicit connection among the source commit, workflow run, container image, and deployed workload. Unlike relying exclusively on latest, it also lets you identify or redeploy an earlier version without guessing which image it represents.

Finally, Kubernetes pulls the image and performs a rolling update. The workflow watches that rollout and fails if the new Pods do not become ready. Updating a Deployment’s container image changes its Pod template and starts a rollout, as described in the Kubernetes Deployment documentation.

Prepare the Kubernetes cluster

Connect to the cluster

Authenticate doctl, then save the cluster configuration locally:

doctl auth init
doctl kubernetes cluster kubeconfig save my-cluster
kubectl get nodes

The kubeconfig records the cluster API address, authentication configuration, and Kubernetes context. kubectl reads it to determine which API server to contact and which credentials to use. DigitalOcean’s command merges the new context into your existing kubeconfig. For more details, see DigitalOcean’s cluster connection guide.

kubectl get nodes should return the cluster’s worker nodes in a Ready state.

Create the Deployment

Add k8s/deployment.yaml:

k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 2
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: my-app
          image: IMAGE_PLACEHOLDER
          ports:
            - name: http
              containerPort: 3000
          readinessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5

replicas requests two application instances. The selector must match the labels on the Pod template so the Deployment can manage its Pods. The container name, my-app, identifies the container whose image each release changes.

IMAGE_PLACEHOLDER is not a real image. Before applying the manifest, the workflow replaces it with the commit-tagged image, so the Deployment only ever references images that exist in GHCR. Because the placeholder is replaced in CI, deploy this file through the workflow rather than with a local kubectl apply.

The readiness probe prevents Kubernetes from sending traffic to a Pod until /health responds successfully. If the probe is wrong or the application cannot start, the rollout remains incomplete, which is preferable to directing users to a broken instance.

Expose the application

Create k8s/service.yaml:

k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-app
spec:
  type: LoadBalancer
  selector:
    app: my-app
  ports:
    - name: http
      port: 80
      targetPort: http

A LoadBalancer Service asks the cloud provider to provision an external load balancer and route port 80 to the Pods’ named http port. Provisioning can take several minutes, and the load balancer may add to your cloud bill.

Configure GitHub secrets and permissions

Open the repository and go to Settings → Secrets and variables → Actions.

Under Secrets, create:

SecretPurpose
DIGITALOCEAN_ACCESS_TOKENAllows doctl to retrieve cluster credentials

Under Variables, create:

VariableExample
CLUSTER_NAMEmy-cluster

The cluster name is not sensitive, so it does not need to be stored as a secret. Never commit the API token or a downloaded kubeconfig file.

You do not need to create a separate registry password. GitHub automatically creates GITHUB_TOKEN for each workflow run, and the workflow grants it packages: write permission. GitHub recommends using this token to publish packages associated with the workflow repository. Its GHCR publishing guide documents the corresponding login settings.

This tutorial lets Kubernetes pull the image anonymously by making its GHCR package public. New GHCR packages are private by default, while public container packages can be pulled without authentication.

Review GitHub’s package visibility documentation before changing that setting.

Build the GitHub Actions workflow

Create .github/workflows/deploy.yaml with the complete pipeline:

.github/workflows/deploy.yaml
name: Test, publish, and deploy
 
on:
  push:
    branches:
      - main
 
concurrency:
  group: production-deployment
  cancel-in-progress: false
 
permissions:
  contents: read
  packages: write
 
env:
  REGISTRY: ghcr.io
  CLUSTER_NAME: ${{ vars.CLUSTER_NAME }}
  NAMESPACE: default
 
jobs:
  deploy:
    name: Test, publish, and deploy
    runs-on: ubuntu-latest
    timeout-minutes: 20
    environment: production
 
    steps:
      - name: Check out repository
        uses: actions/checkout@v6
 
      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm
 
      - name: Install dependencies
        run: npm ci
 
      - name: Run tests
        run: npm test
 
      - name: Set image reference
        shell: bash
        run: |
          echo "IMAGE=${REGISTRY}/${GITHUB_REPOSITORY,,}:${GITHUB_SHA}" \
            >> "$GITHUB_ENV"
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build and publish image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}
 
      - name: Install doctl
        uses: digitalocean/action-doctl@v2
        with:
          token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
 
      - name: Save short-lived cluster credentials
        run: |
          doctl kubernetes cluster kubeconfig save \
            --expiry-seconds 600 \
            "$CLUSTER_NAME"
 
      - name: Set the image in the manifest
        run: |
          sed -i "s|IMAGE_PLACEHOLDER|${IMAGE}|" k8s/deployment.yaml
          grep -qF "image: ${IMAGE}" k8s/deployment.yaml
 
      - name: Apply Kubernetes manifests
        run: kubectl apply -f k8s/ --namespace "$NAMESPACE"
 
      - name: Verify rollout
        run: |
          kubectl rollout status deployment/my-app \
            --namespace "$NAMESPACE" \
            --timeout=180s

The trigger covers direct pushes and pull-request merges that create a commit on main. The concurrency group prevents two production deployments from running simultaneously. With cancel-in-progress: false, an active deployment is allowed to finish before a pending one begins. GitHub documents concurrency as a way to limit an environment to one deployment at a time in its deployment guidance.

npm ci and npm test execute before registry or cluster authentication. If either command returns a nonzero exit code, GitHub stops the job and marks the failed step in the Actions interface.

The image-reference step lowercases the repository path because container image names must be lowercase. It combines that path with github.sha, producing a unique image for the exact commit being deployed.

Building creates the container layers on the runner; pushing uploads those layers and their manifest to GHCR. The cluster cannot deploy the image until it is available from a registry it can reach.

The DigitalOcean action installs and authenticates doctl. The next step requests credentials that expire after 600 seconds. DigitalOcean supports --expiry-seconds specifically for controlling credential lifetime, as shown in its GitHub Actions deployment guide.

The manifest step writes the commit-tagged image into the runner’s copy of deployment.yaml. The grep check fails the job if the substitution did not happen, for example because the placeholder was renamed. kubectl apply then sends both manifests in a single update. Changing the image changes the Pod template, so each deployment creates exactly one new Deployment revision that points at a real image. That keeps kubectl rollout undo meaningful: the previous revision is the previous release.

Finally, kubectl rollout status waits for the new Pods to become available. It exits with a nonzero status if the rollout fails or exceeds the timeout, causing the Actions job to fail instead of reporting a false success.

How a two-replica rolling update progresses while kubectl rollout status waits. Before: the old ReplicaSet's pods a and b are ready and serve traffic. Surge: applying the new image changes the Pod template and a new pod c starts but is not yet ready, so the Service still routes to a and b. Replace: c passes its /health readiness probe and joins the Service, old pod a terminates, and new pod d starts. Complete: pods c and d are both ready, the old ReplicaSet is scaled to zero and kept for rollback, and the command exits 0. If the new pod never becomes ready because of ImagePullBackOff, CrashLoopBackOff, or a failing probe, the old pods keep serving and the step fails after the 180-second timeout.

Test the pipeline end to end

Change something visible in the application, such as its response body or displayed version, then commit and push:

git add .
git commit -m "Update application response"
git push origin main

Open the repository’s Actions tab and select the new run. You should see the tests pass, a SHA-tagged image appear in GHCR, cluster credentials get created, and the rollout complete.

Check the cluster locally:

kubectl get pods
kubectl get deployments
kubectl get services

The Pods should show READY values such as 1/1. The Deployment should report that its desired replicas are ready and available. The Service will initially show <pending> under EXTERNAL-IP while DigitalOcean provisions the load balancer.

Once an address appears, test the public endpoint:

curl http://EXTERNAL_IP/

The response should contain your latest change. Pod readiness demonstrates that the health check succeeds; Deployment availability confirms that the required replicas can serve traffic; rollout completion confirms that Kubernetes replaced the previous version successfully.

Troubleshoot common deployment failures

IssueLikely causeSuggested fix
ImagePullBackOffWrong tag or unavailable private imageCheck the image reference and GHCR visibility or pull secret
UnauthorizedMissing or insufficient credentialsVerify the DigitalOcean token and scopes
CrashLoopBackOffApplication exits after startingInspect container logs and configuration
Rollout timeoutPods never become readyCheck probes, events, resources, and image pulls
Tests failTest or dependency errorOpen the failed Actions step and inspect its output

Start Kubernetes debugging with:

kubectl describe pod POD_NAME
kubectl logs POD_NAME
kubectl get events --sort-by=.metadata.creationTimestamp

describe exposes scheduling, probe, and image-pull failures. logs shows application output, while events reveal cluster-level problems such as insufficient resources or unsuccessful load-balancer provisioning.

Security and production improvements

The example explicitly limits GITHUB_TOKEN to reading repository contents and writing packages. Continue that least-privilege approach for every workflow and use short-lived cluster credentials rather than committing a long-lived kubeconfig.

For production, pin third-party actions to reviewed, full-length commit SHAs instead of movable version tags. GitHub recommends this because it ensures that a workflow runs the exact action code you approved. See its Actions security guidance.

Protect the production GitHub environment with deployment-branch restrictions or required reviewers when your repository and plan support them. Environments can delay access to their secrets until protection rules pass.

Readiness probes and rolling updates help preserve availability by keeping unready Pods out of service while Kubernetes gradually replaces the old ReplicaSet. If an application release is defective, you can roll back:

kubectl rollout undo deployment/my-app

Rollback depends on usable Deployment revision history. It does not reverse database migrations, messages already published, or changes to external systems. Add image vulnerability scanning, dependency checks, admission controls, monitoring, and post-deployment health tests before treating the pipeline as production-ready.

From manual deployment to a repeatable release

You now have a complete path from a commit on main to a verified Kubernetes rollout. GitHub Actions tests the application, builds a traceable Docker image, publishes it to GHCR, retrieves short-lived DOKS credentials, updates the Deployment, and fails visibly if the new Pods do not become ready.

That removes repetitive release commands while giving every deployment a corresponding commit, image tag, and workflow log.

Natural next steps are to introduce separate staging and production environments, run integration tests against the deployed service, require production approvals, use private registry authentication, and connect rollout results to monitoring and alerting.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading