Skip to content

Deploy to Kubernetes with GitHub Actions

Lesson 5 of 7Advanced5 min readGitHub Actions & CI/CD · Continuous DeliveryVerified: azure/setup-kubectl v5, azure/k8s-deploy v7, helm/kind-action v1, August 2026

Kubernetes deployment from CI has a decision to make before any YAML gets written: does the pipeline push changes into the cluster, or does something inside the cluster pull them? The answer changes what credentials exist, what your blast radius is, and what happens when someone edits a resource by hand.

Push (this page)Pull (GitOps)
Who applies changesThe workflowAn agent in the cluster
Cluster credentialsHeld by GitHubNever leave the cluster
Cluster API exposureMust be reachable from GitHub’s runnersCan be fully private
Manual driftUndetected until the next deployContinuously reconciled
RollbackRe-run a workflowRevert a commit
ComplexityLowerHigher — an operator to install and run

Push is simpler and is the right starting point for one cluster and one team. Pull becomes clearly better once you have several clusters, a private API server, or auditors asking who changed what.

This page covers push, and is honest about where it stops being the right answer.

Authenticating without a stored kubeconfig

Section titled “Authenticating without a stored kubeconfig”

The pattern to avoid is storing a kubeconfig — or a service account token — in repository secrets. It is a long-lived credential to your cluster’s API server, readable by every workflow in the repository.

Use the cloud provider’s federation instead, and fetch credentials per run:

{/* Google Kubernetes Engine */}
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/repo
service_account: deployer@PROJECT_ID.iam.gserviceaccount.com
- uses: google-github-actions/get-gke-credentials@v3
with:
cluster_name: production
location: europe-west1
{/* Amazon EKS */}
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-deploy-eks
aws-region: eu-west-1
- run: aws eks update-kubeconfig --name production --region eu-west-1
{/* Azure Kubernetes Service */}
- uses: azure/login@v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: az aks get-credentials --resource-group example-rg --name production

In all three, the kubeconfig is generated on the runner, lives for the length of the job, and is backed by credentials that expire in minutes.

Manifests need the digest the build produced. The three common approaches:

{/* Kustomize — no placeholder to substitute, and it validates as it goes */}
- run: |
cd k8s/overlays/production
kustomize edit set image "app=ghcr.io/OWNER/REPO@${DIGEST}"
kustomize build . > rendered.yaml
env:
DIGEST: ${{ needs.build.outputs.digest }}
{/* Helm */}
- run: |
helm upgrade --install app ./chart \
--namespace production \
--set "image.digest=${DIGEST}" \
--atomic \
--timeout 5m
env:
DIGEST: ${{ needs.build.outputs.digest }}
{/* kubectl set image — imperative, but the smallest possible change */}
- run: |
kubectl set image deployment/app "app=ghcr.io/OWNER/REPO@${DIGEST}" \
--namespace production
env:
DIGEST: ${{ needs.build.outputs.digest }}

Notice that in all three the digest arrives through env: and is referenced as a shell variable. Substituting ${{ }} directly into a run: block is the script injection pattern; making it a habit even for trusted values is what stops it appearing where the value is not trusted.

sed on a manifest is the fourth approach and the one to avoid. It happily produces syntactically valid YAML with semantically wrong content, and it fails silently when the placeholder has been renamed.

kubectl apply returns as soon as the API server has accepted the object. It says nothing about whether any pod started. A workflow that ends there reports success for a deployment whose new pods are in CrashLoopBackOff:

- name: Apply
run: kubectl apply -f rendered.yaml --namespace production
- name: Wait for the rollout
run: kubectl rollout status deployment/app --namespace production --timeout 5m

rollout status blocks until the new ReplicaSet has the required number of ready replicas, or the timeout expires — at which point it exits non-zero and fails the job. That is the step that makes the green tick mean something.

For this to work, the Deployment needs a readiness probe that tests something real. A probe that returns 200 unconditionally makes the rollout succeed regardless, and the whole safety mechanism — including Kubernetes’ own rolling update, which stops when new pods do not become ready — is inert.

- name: Roll back
if: failure()
run: kubectl rollout undo deployment/app --namespace production

rollout undo reverts to the previous ReplicaSet. It works because Kubernetes keeps old ReplicaSets around — how many is revisionHistoryLimit, default 10. Set it to 0 and you have deleted your rollback.

An automatic if: failure() rollback is attractive and worth thinking about before adopting. It recovers quickly from a bad image; it also destroys the evidence, and it can flap if the underlying problem is external — the new pods fail because a dependency is down, the rollback succeeds, the next deploy fails again. Many teams prefer to fail loudly and let a human decide.

Note what rollout undo does not roll back: ConfigMaps and Secrets that were applied alongside the Deployment, database migrations, and anything a Job did. It reverts the Deployment’s pod template and nothing else.

Two checks catch most manifest errors and need no cluster at all:

- run: kubectl apply --dry-run=server -f rendered.yaml --namespace production

--dry-run=server sends the manifest to the API server for full validation — schema, admission webhooks, defaulting — without persisting it. It is dramatically better than --dry-run=client, which only checks that the YAML parses into a known kind.

For pull requests that cannot reach the real cluster, spin up a disposable one:

- uses: helm/kind-action@v1
- run: kubectl apply -f rendered.yaml
- run: kubectl wait --for=condition=available deployment/app --timeout=120s

kind runs a full Kubernetes cluster in Docker on the runner. It will not reproduce your cloud load balancer or storage classes, but it does prove the manifests apply and the pods start — which is most of what a manifest change gets wrong.

Push deployment stops being the right answer at recognisable points:

  • The cluster’s API server is private, and exposing it to GitHub’s runners means widening the network boundary for the sake of the pipeline.
  • You have more clusters than you can hold in your head, and each needs its own credential path.
  • Someone has edited a resource by hand and nobody noticed until the next deploy overwrote it.
  • Auditors want a single record of what is deployed, and “read the workflow logs” is not acceptable.

At that point an in-cluster operator watching a repository — Argo CD, Flux — inverts the direction. GitHub Actions’ job becomes building the image and committing the new digest to a manifest repository; the cluster pulls it. No cluster credentials exist in GitHub at all, and drift is corrected continuously rather than discovered later.

  1. Create a Deployment with a readiness probe that checks a real endpoint, and apply it to a kind cluster locally.

  2. Write a workflow that uses helm/kind-action@v1, applies your manifests, and gates on kubectl rollout status.

  3. Change the image to a tag that does not exist and push. Confirm the job fails at rollout status rather than passing at apply — this is the difference the gate makes.

  4. Add kubectl rollout undo behind if: failure() and confirm the deployment returns to the working ReplicaSet.

  5. Set revisionHistoryLimit: 0, redeploy, and try rollout undo again. Read the error, then put it back.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.