Skip to content

Deploy from GitHub Actions

The common way to deploy to Kubernetes from CI is to paste an admin kubeconfig into a repository secret and hope nobody leaks it. That credential is cluster-admin, lives for years, gets copied into forks and artifacts, and cannot be revoked without re-keying the cluster.

A better pattern works on any cluster: a deploy identity that can change one namespace and nothing else, with a token that expires. A ServiceAccount gets a Role in your app’s namespace, you mint a token for it with an expiry, and the workflow writes its kubeconfig at the start of each run from three values: the API server’s address, its CA certificate and that token. To revoke it, you delete the ServiceAccount, and every token issued for it stops working at once.

On PaaSbox Clusters, the kubeconfigs from the portal are for a person: each one is sealed to a key the browser makes and expires after 10 minutes to 24 hours. A program can ask for one through the REST API with an API key that has the scope k3s:access (in progress); the recipe A test cluster per pull request does that for throwaway clusters. For a cluster that stays, such as production, this guide’s pattern works as it is: you create the ServiceAccount once, with an admin kubeconfig from the portal.

  • A cluster, its admin kubeconfig on your machine, and kubectl.
  • A namespace for your app (myapp below) and manifests to apply, for example a kustomize overlay.
  • An API server the runner can reach. If your firewall allows the Kubernetes API only from your own addresses, run the job on a self-hosted runner inside your network instead of on GitHub’s. On a PaaSbox cluster, port 6443 is open only to the address ranges in the cluster’s settings: Change who can reach the API.

Save as deployer.yaml. The Role lists the kinds a typical web app’s manifests contain; trim it to yours, and add Secrets only if your pipeline must create them:

apiVersion: v1
kind: ServiceAccount
metadata: { name: deployer, namespace: myapp }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: deployer, namespace: myapp }
rules:
- apiGroups: [apps]
resources: [deployments]
verbs: [get, list, watch, create, update, patch]
- apiGroups: [apps]
resources: [replicasets]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [services, configmaps]
verbs: [get, list, watch, create, update, patch]
- apiGroups: [""]
resources: [pods, pods/log]
verbs: [get, list, watch]
- apiGroups: [batch]
resources: [jobs]
verbs: [get, list, watch, create, delete]
- apiGroups: [networking.k8s.io]
resources: [ingresses]
verbs: [get, list, watch, create, update, patch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: deployer, namespace: myapp }
subjects:
- { kind: ServiceAccount, name: deployer, namespace: myapp }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: deployer }

Apply it with your admin kubeconfig:

Terminal window
kubectl apply -f deployer.yaml
Terminal window
kubectl -n myapp create token deployer --duration=720h

This asks the API server for a token of the ServiceAccount that expires after 30 days. A cluster can cap how long such a token lives; if yours does, the token expires sooner. Store it as a repository secret, KUBE_TOKEN (GitHub → Settings → Secrets and variables → Actions → Secrets).

The address and the CA certificate are not secret. Read them from your admin kubeconfig and store them as repository variables, KUBE_SERVER and KUBE_CA:

Terminal window
kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}'
kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}'

A complete deploy workflow: write the kubeconfig, apply the manifests, verify the rollout. GitHub’s Ubuntu runners come with kubectl.

name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Write a kubeconfig for this run
env:
KUBE_SERVER: ${{ vars.KUBE_SERVER }}
KUBE_CA: ${{ vars.KUBE_CA }}
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
run: |
export KUBECONFIG="$RUNNER_TEMP/kubeconfig"
echo "$KUBE_CA" | base64 -d > "$RUNNER_TEMP/ca.crt"
kubectl config set-cluster target --server="$KUBE_SERVER" \
--certificate-authority="$RUNNER_TEMP/ca.crt" --embed-certs
kubectl config set-credentials deployer --token="$KUBE_TOKEN"
kubectl config set-context deploy --cluster=target --user=deployer --namespace=myapp
kubectl config use-context deploy
chmod 600 "$KUBECONFIG"
echo "KUBECONFIG=$KUBECONFIG" >> "$GITHUB_ENV"
- name: Deploy
run: kubectl apply -k deploy/overlays/production
- name: Wait for the rollout
run: kubectl rollout status deploy/web --timeout=180s

If your release includes an immutable migration Job, like the one in the Django SaaS guide, the same delete-then-apply step goes between deploy and rollout. The Role above allows it:

- name: Run migrations
run: |
kubectl delete job myapp-migrate --ignore-not-found
kubectl apply -f deploy/overlays/production/migrate-job.yaml
kubectl wait --for=condition=complete job/myapp-migrate --timeout=300s

What this pattern gives you, stated plainly:

  • A leak is contained. The token can change one namespace and read nothing in any other: not another app’s Secrets, not the cluster’s nodes.
  • A leak expires. The token stops working on its expiry date. Pick weeks, not years, and mint a new one before it runs out.
  • Revocation is one command, and it does not touch the cluster’s keys. kubectl -n myapp delete serviceaccount deployer stops every token issued for it at once; apply deployer.yaml again and mint a new token.
  • Changes are attributable. Where the API server’s audit log is turned on, every change the pipeline makes carries the name system:serviceaccount:myapp:deployer, so “what deployed at 02:00” has an answer.

GitHub can hand each job a short-lived OIDC token of its own. A Kubernetes API server configured to trust GitHub’s issuer, through JWT authentication in its authentication configuration, accepts that token directly, and the repository stores no cluster credential at all. It needs access to the API server’s flags, which a managed control plane may not give you.

  • Never upload the kubeconfig as an artifact and never cat it in a step. It holds the token.
  • One ServiceAccount per pipeline and namespace (staging and production separately), so you can revoke one without affecting the others.
  • Grant only what your manifests contain. Leave Secrets out unless the pipeline must create them, and never bind the deploy identity to a ClusterRole.
  • Pin image tags (sha-…, not latest) so what CI deploys is exactly what CI built.

The same pattern works in any CI system, including GitLab CI, Forgejo Actions and Jenkins, since it is only kubectl: a scoped identity, a token that expires, and a kubeconfig written per run.