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.
What you’ll need
Section titled “What you’ll need”- A cluster, its admin kubeconfig on your machine, and
kubectl. - A namespace for your app (
myappbelow) 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.
1. Create the deploy identity
Section titled “1. Create the deploy identity”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: v1kind: ServiceAccountmetadata: { name: deployer, namespace: myapp }---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: { 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/v1kind: RoleBindingmetadata: { 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:
kubectl apply -f deployer.yaml2. Mint a token and store it
Section titled “2. Mint a token and store it”kubectl -n myapp create token deployer --duration=720hThis 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:
kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}'kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}'3. The workflow
Section titled “3. The workflow”A complete deploy workflow: write the kubeconfig, apply the manifests, verify the rollout.
GitHub’s Ubuntu runners come with kubectl.
name: deployon: 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=180sIf 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=300sWhy this beats an admin kubeconfig
Section titled “Why this beats an admin kubeconfig”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 deployerstops every token issued for it at once; applydeployer.yamlagain 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.
Going further: no stored token at all
Section titled “Going further: no stored token at all”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.
Hardening checklist
Section titled “Hardening checklist”- Never upload the kubeconfig as an artifact and never
catit 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-…, notlatest) 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.
Next steps
Section titled “Next steps”- A test cluster per pull request: a throwaway cluster for each pull request, created and deleted by CI or an agent (in progress).
- Deploy a Django SaaS: the manifests and the migration Job this workflow applies.