Deploy on push with CI

A ServiceAccount that can only touch your namespace, its kubeconfig as a CI secret, and a pipeline that builds the image on every push, tags it with the commit, and rolls it out.

intermediate~35 min hands-on
#kubernetes#ci#github-actions#gitlab-ci#kustomize#rbac

Not validated end to end yet — be the first.Report a problem

Draft — not yet run end to end. This page was written but its author has not yet run it on a real machine. Commands may be wrong: read before you run, and tell us what breaks.

The gistFrom git push to running pods
Your forge
Your cluster
pushon: pushdocker pushkubectl apply -krolloutpull :sha
Yougit push origin main
Repository
GitHub Actionsbuild → push → deploy
Registry:<sha>
API server:6443
indicatDeployment ·

A push to main runs the pipeline: it builds the image, pushes it to the registry tagged with the commit SHA, then, with a kubeconfig limited to ${NAMESPACE}, applies the manifests with that tag and waits for the rollout.

The previous page deployed indicat by hand from your laptop, with a kubeconfig that is cluster-admin. This page hands the job to GitHub Actions: a push to main builds the image, tags it with the commit SHA, pushes it to the registry and rolls it out, using an identity that can only touch .

Before you start

A ServiceAccount for the pipeline

deploy/ci-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deploy
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deploy
rules:
- apiGroups: [""]
resources: [pods, pods/log, services]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [apps]
resources: [deployments, replicasets]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [batch]
resources: [jobs]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [networking.k8s.io]
resources: [ingresses]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [autoscaling]
resources: [horizontalpodautoscalers]
verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deploy
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ci-deploy
subjects:
- kind: ServiceAccount
name: ci-deploy
namespace:
$kubectl -n apply -f deploy/ci-rbac.yaml
Check
$kubectl -n kube-system auth can-i get secrets --as=system:serviceaccount::ci-deploy
Expected output
no

A kubeconfig for the pipeline

deploy/ci-token.yaml
apiVersion: v1
kind: Secret
metadata:
name: ci-deploy-token
annotations:
kubernetes.io/service-account.name: ci-deploy
type: kubernetes.io/service-account-token
$kubectl -n apply -f deploy/ci-token.yaml
$TOKEN=$(kubectl -n get secret ci-deploy-token -o jsonpath='{.data.token}' | base64 -d)
$CA=$(kubectl -n get secret ci-deploy-token -o jsonpath='{.data.ca\.crt}')
$cat > ci-kubeconfig.yaml <<EOF
$apiVersion: v1
$kind: Config
$clusters:
$ - name: k3s
$ cluster: { server: "https://:6443", certificate-authority-data: $CA }
$users:
$ - name: ci-deploy
$ user: { token: $TOKEN }
$contexts:
$ - name: ci
$ context: { cluster: k3s, user: ci-deploy, namespace: }
$current-context: ci
$EOF
Check
$kubectl --kubeconfig ci-kubeconfig.yaml auth can-i create deployments
Expected output
yes

The CI secret and the registry

$gh secret set KUBECONFIG_B64 --repo < <(base64 -w0 ci-kubeconfig.yaml)
$rm ci-kubeconfig.yaml

kustomize: one place for the image tag

deploy/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace:
resources: [migrate.yaml, deployment.yaml, service.yaml, ingress.yaml, hpa.yaml]
images:
- name:
newTag: latest
Check
$kubectl kustomize deploy/ | grep -c 'image: :latest'
Expected output
2

The pipeline

.github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: :${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
steps:
- uses: actions/checkout@v4
- name: Kubeconfig
run: |
mkdir -p ~/.kube
echo "$KUBECONFIG_B64" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Deploy
run: |
sed -i "s|newTag: .*|newTag: ${{ github.sha }}|" deploy/kustomization.yaml
kubectl delete job indicat-migrate --ignore-not-found
kubectl apply -k deploy/ -l app=indicat-migrate
kubectl wait --for=condition=complete job/indicat-migrate --timeout=300s
kubectl apply -k deploy/
kubectl rollout status deploy/indicat --timeout=300s
  1. The minimum: read the code, write packages. The default token permissions are broader; stating them here narrows them for this workflow.
  2. One tag per commit. The same SHA in git log, in the registry and in kubectl get deploy -o wide: no guessing what runs.
  3. Layer cache in GitHub's cache service; a build that only changed application code takes seconds.
  4. Ties the job to the production environment: its secrets, its protection rules (next step), and a deployment history in the repository's sidebar.
  5. The Job first, alone, selected by its label; then everything. A migration that fails stops the pipeline before any pod changes.
$git add deploy/ .github/ .gitlab-ci.yml 2>/dev/null; git commit -m "ci: deploy on push"
$git push origin main
Check
$gh run list --repo  --workflow deploy --limit 1 --json conclusion --jq '.[0].conclusion'
Expected output
success
Check
$[ "$(kubectl -n  get deploy indicat -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)" = "$(git rev-parse HEAD)" ] && echo running HEAD
Expected output
running HEAD
If the deploy job fails

The job log has the kubectl error. In order of likelihood:

  • Unable to connect to the server: :6443 is not reachable from the runner (firewall, private address).
  • forbidden: the Role lacks a resource that deploy/ now contains; add it to ci-rbac.yaml.
  • error: no objects passed to apply on the Job step: the label app: indicat-migrate is missing on migrate.yaml.
  • The migration Job fails: kubectl logs job/indicat-migrate from your laptop; the Deployment was not touched.
  • rollout status times out: ImagePullBackOff because the image name in deploy/ differs from (kustomize replaced nothing), or the pods fail their probes (previous page's troubleshooting applies).

Environments and protection

Repository Settings → Environments → production: add Required reviewers, and restrict Deployment branches to main. Settings → Branches → Add rule on main: require a pull request and status checks.

Rollback

Every commit has its image. Rolling back is deploying an older one: re-run the pipeline of the last good commit, or, from your laptop, undo the rollout.

$gh run list --repo --workflow deploy --limit 5
$gh run rerun <run-id> --repo
$kubectl -n rollout undo deploy/indicat

Done

A push to main builds :sha, migrates, rolls out, and stops on the first error, with an identity that cannot see past . Your admin kubeconfig stays on your laptop.

Did everything work?

If you followed this page to the end on a real machine, say so. Your validation is dated and records your stack, so the next reader on the same path knows it still works.

This copy is read-only. To report that it works, or that it does not, open an issue

Only your stack choices are recorded, never your values. The pseudonym stays on this browser.