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
apiVersion: v1kind: ServiceAccountmetadata: name: ci-deploy---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: ci-deployrules: - 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/v1kind: RoleBindingmetadata: name: ci-deployroleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: ci-deploysubjects: - kind: ServiceAccount name: ci-deploy namespace: $kubectl -n apply -f deploy/ci-rbac.yaml$kubectl -n kube-system auth can-i get secrets --as=system:serviceaccount::ci-deployno
A kubeconfig for the pipeline
apiVersion: v1kind: Secretmetadata: name: ci-deploy-token annotations: kubernetes.io/service-account.name: ci-deploytype: 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$kubectl --kubeconfig ci-kubeconfig.yaml auth can-i create deploymentsyes
The CI secret and the registry
$gh secret set KUBECONFIG_B64 --repo < <(base64 -w0 ci-kubeconfig.yaml)$rm ci-kubeconfig.yamlkustomize: one place for the image tag
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: resources: [migrate.yaml, deployment.yaml, service.yaml, ingress.yaml, hpa.yaml]images: - name: newTag: latest$kubectl kustomize deploy/ | grep -c 'image: :latest'2
The pipeline
name: deployon: push: branches: [main]permissions: contents: read packages: writejobs: 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- The minimum: read the code, write packages. The default token permissions are broader; stating them here narrows them for this workflow.
- One tag per commit. The same SHA in
git log, in the registry and inkubectl get deploy -o wide: no guessing what runs. - Layer cache in GitHub's cache service; a build that only changed application code takes seconds.
- Ties the job to the
productionenvironment: its secrets, its protection rules (next step), and a deployment history in the repository's sidebar. - 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$gh run list --repo --workflow deploy --limit 1 --json conclusion --jq '.[0].conclusion'success
$[ "$(kubectl -n get deploy indicat -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)" = "$(git rev-parse HEAD)" ] && echo running HEADrunning 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 thatdeploy/now contains; add it toci-rbac.yaml.error: no objects passed to applyon the Job step: the labelapp: indicat-migrateis missing onmigrate.yaml.- The migration Job fails:
kubectl logs job/indicat-migratefrom your laptop; the Deployment was not touched. rollout statustimes out:ImagePullBackOffbecause the image name indeploy/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/indicatDone
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.