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 or pull
Section titled “Push or pull”| Push (this page) | Pull (GitOps) | |
|---|---|---|
| Who applies changes | The workflow | An agent in the cluster |
| Cluster credentials | Held by GitHub | Never leave the cluster |
| Cluster API exposure | Must be reachable from GitHub’s runners | Can be fully private |
| Manual drift | Undetected until the next deploy | Continuously reconciled |
| Rollback | Re-run a workflow | Revert a commit |
| Complexity | Lower | Higher — 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 productionIn 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.
Templating the image reference
Section titled “Templating the image reference”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.
Gate on rollout status
Section titled “Gate on rollout status”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 5mrollout 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.
Rollback
Section titled “Rollback”- name: Roll back if: failure() run: kubectl rollout undo deployment/app --namespace productionrollout 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.
Testing manifests without a cluster
Section titled “Testing manifests without a cluster”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=120skind 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.
When to switch to GitOps
Section titled “When to switch to GitOps”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.
Exercise
Section titled “Exercise”-
Create a Deployment with a readiness probe that checks a real endpoint, and apply it to a
kindcluster locally. -
Write a workflow that uses
helm/kind-action@v1, applies your manifests, and gates onkubectl rollout status. -
Change the image to a tag that does not exist and push. Confirm the job fails at
rollout statusrather than passing atapply— this is the difference the gate makes. -
Add
kubectl rollout undobehindif: failure()and confirm the deployment returns to the working ReplicaSet. -
Set
revisionHistoryLimit: 0, redeploy, and tryrollout undoagain. Read the error, then put it back.