Wat je gaat leren
Aan het einde van deze tutorial heb je een werkende GitHub Actions-workflow die een Docker image bouwt, tagt met de Git commit SHA, pusht naar GitHub Container Registry en een Kubernetes Deployment bijwerkt naar de nieuwe image. Je begrijpt waarom OIDC beter is dan een opgeslagen kubeconfig, hoe je een ServiceAccount zo scopt dat een gecompromitteerde workflow het cluster niet kan overnemen, en de afweging tussen imperatieve kubectl set image en declaratieve manifest-commits.
Vereisten
- Een Kubernetes-cluster dat bereikbaar is vanaf GitHub-hosted runners (managed GKE, EKS, AKS, DigitalOcean of een self-hosted cluster met een publiek API-endpoint of een self-hosted runner binnen het netwerk)
- Een GitHub-repository met je applicatiesource en een
Dockerfile - Een bestaande Kubernetes
Deploymentvoor de applicatie (heb je die nog niet, dan loopt Docker Compose naar Kubernetes door de opzet) kubectl1.28 of nieuwer lokaal geinstalleerd voor de clustercommando's- Bekendheid met Kubernetes RBAC zodat de scoped ServiceAccount in stap 3 logisch voelt
Inhoudsopgave
- CI bouwt images, CD werkt clusters bij
- GHCR-authenticatie opzetten
- De Docker image bouwen en taggen
- kubectl authenticeren tegen het cluster
- Imperatief versus declaratief deployen
- Wat deze workflow NIET is
- Compleet workflow-bestand
- Wat je geleerd hebt
CI bouwt images, CD werkt clusters bij
Een Kubernetes-deploypipeline heeft twee helften die fundamenteel ander werk doen. Ze samenvoegen in een monolithische stap is de meest voorkomende reden dat zulke workflows brittle worden.
Continuous integration (CI) neemt broncode en produceert een onveranderlijk artefact. Voor een container-applicatie is dat artefact een Docker image. De image krijgt een unieke, content-adresseerbare naam (meestal de Git commit SHA) en belandt in een registry. In deze fase is er nog niks veranderd in je cluster.
Continuous deployment (CD) neemt een bestaand artefact en past het toe op een cluster. De Deployment van het cluster wordt gepatcht naar de nieuwe image-tag. Kubernetes voert een rolling update uit en de nieuwe versie vervangt de oude pod voor pod.
De splitsing is belangrijk omdat CI idempotent is en veilig kan worden opnieuw gedraaid, terwijl CD live productie verandert. Je wilt CI bij elke commit laten draaien, maar CD alleen op de juiste branch, na groene tests, idealiter met een handmatige approval-gate voor productie. GitHub Actions modelleert dit met gescheiden jobs die via needs: aan elkaar hangen.
GHCR-authenticatie opzetten
GitHub Container Registry (GHCR) is de weg van de minste weerstand: je hebt geen apart account nodig, geen opgeslagen credentials, en de ingebouwde GITHUB_TOKEN regelt de authenticatie. GitHub geeft automatisch admin-rechten op packages van de repository die ze publiceert.
De workflow heeft twee permissions-blokken nodig. contents: read om de repository te checken, en packages: write om naar GHCR te pushen:
permissions:
contents: read
packages: write
Inloggen gebeurt met docker/login-action (v4.1.0 op moment van schrijven), met github.actor als gebruikersnaam en de automatisch geinjecteerde GITHUB_TOKEN als wachtwoord:
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: $
password: $
Het token is scoped aan de workflow-run en verloopt zodra de job eindigt. Niks om te roteren, niks dat in CI-logs kan lekken, niks dat je hoeft in te trekken als een contributor vertrekt.
Docker Hub en ECR werken anders. Docker Hub heeft een personal access token nodig als repository secret (DOCKERHUB_TOKEN) en dezelfde docker/login-action met registry: docker.io. Amazon ECR gebruikt aws-actions/configure-aws-credentials om via OIDC kortlevende credentials te krijgen, en daarna aws-actions/amazon-ecr-login voor de Docker login. De buildstap die daarna komt is identiek, welke registry je ook gebruikt.
De Docker image bouwen en taggen
Twee regels sturen de buildstap. Eerst: tag met de commit SHA, nooit met :latest. Daarnaast: laat docker/metadata-action de taglijst genereren, zodat de logica in declaratieve configuratie leeft in plaats van in ad-hoc shellcommando's.
Waarom geen :latest? Omdat :latest een pointer is, geen versie. Als een pod herstart en de image cache op de node is verlopen, kan Kubernetes een andere image pullen dan degene die draaide. Rollbacks worden onmogelijk omdat er geen registratie is van welke :latest op welk moment live was. De oplossing is om elke image-tag content-adresseerbaar te maken. De commit SHA is de voor de hand liggende keuze: uniek, onveranderlijk en te herleiden naar exact de broncode die hem bouwde.
Koppel drie acties achter elkaar. docker/setup-buildx-action zet de Buildx-builder op. docker/metadata-action berekent de taglijst. docker/build-push-action (v7.1.0) voert de build en push uit:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/$
tags: |
type=sha,format=long # ghcr.io/org/repo:sha-abc123def456...
type=ref,event=branch # ghcr.io/org/repo:main
type=semver,pattern= # ghcr.io/org/repo:1.4.2 bij tag push
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: $
labels: $
cache-from: type=gha # haal layer cache uit GitHub Actions cache
cache-to: type=gha,mode=max # bewaar elke layer voor de volgende run
De regels cache-from en cache-to snijden buildtijd flink terug bij incrementele wijzigingen. Zonder cache bouwt elke run elke layer opnieuw vanaf nul.
Checkpoint. Nadat deze stap succesvol heeft gedraaid, laat het tabblad Packages van de repository de image zien met minstens twee tags: main en sha-<40-karakter-hash>. De lange SHA-tag is wat CD straks zal pinnen.
kubectl authenticeren tegen het cluster
Hier gaan de meeste pipelines de mist in. De verleidelijke shortcut is om een kubeconfig-bestand te base64-en, in een GitHub secret te plakken, en in de workflow echo $KUBECONFIG | base64 -d > ~/.kube/config te draaien. Het werkt. Het geeft ook elke gecompromitteerde workflow permanente, ongelimiteerde toegang tot je cluster.
Gebruik OIDC voor managed clusters. Elke grote cloud ondersteunt nu federated identity: GitHub Actions vraagt een kortlevend token rechtstreeks bij de cloudprovider op, die vervolgens tijdelijke clustercredentials uitgeeft gescoped op de workflow. De OIDC-documentatie van GitHub beschrijft het mechanisme: tokens zijn "only valid for a single job, and then automatically expire." Geen langlevende secrets in GitHub dus.
De setup verschilt per provider. Voor EKS:
permissions:
id-token: write # nodig voor OIDC token request
contents: read
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deployer
aws-region: eu-west-1
- name: Update kubeconfig
run: aws eks update-kubeconfig --name production-cluster --region eu-west-1
De trust policy van de IAM-rol beperkt welke repository en branch de rol mogen aannemen, zodat een fork of feature branch geen productietoegang kan stelen. De AWS Configure Credentials README toont het complete trust-policy patroon met repo:<org>/<repo>:ref:refs/heads/main als conditie.
Voor GKE switch je naar google-github-actions/auth@v2 met Workload Identity Federation. Voor AKS gebruik je azure/login@v2 met een federated credential op een Managed Identity. Het principe blijft identiek: geen opgeslagen sleutels.
Gebruik een scoped ServiceAccount-token voor self-hosted clusters. Draait je cluster niet op een cloudprovider die OIDC-federatie ondersteunt, maak dan een namespace-scoped ServiceAccount met de minimale RBAC-permissies die nodig zijn, en genereer een kortlevend token per workflow-run. De Kubernetes RBAC-gids behandelt het patroon uitgebreid. Minimale werkbare role:
# clusterside manifest. Eenmalig toepassen, committen naar je GitOps-repo
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployer
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"] # geen create, geen delete
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"] # alleen-lezen voor rollout-status
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deployer
namespace: production
subjects:
- kind: ServiceAccount
name: ci-deployer
namespace: production
roleRef:
kind: Role
name: deployer
apiGroup: rbac.authorization.k8s.io
Dit account kan bestaande Deployments in een namespace patchen. Workloads aanmaken lukt niet, Secrets lezen kan niet, iets aanraken in andere namespaces kan niet, en escalaties zijn uitgesloten. Een gestolen token betekent een begrensde blast radius, niet een clusterovername.
Genereer per run een vers token (Kubernetes 1.24+):
# Draai dit op je adminwerkstation of als onderdeel van een out-of-band stap
kubectl -n production create token ci-deployer --duration=1h
Zet dat token alleen als repository secret op als je geen OIDC kunt gebruiken. Zelfs dan: roteer regelmatig. Het moment dat je een pad naar OIDC hebt, neem het.
Installeer kubectl in de runner. De azure/setup-kubectl-action (v5.1.0) regelt dit zonder dat je Azure zelf nodig hebt:
- name: Install kubectl
uses: azure/setup-kubectl@v5
with:
version: v1.30.0 # pin op je cluster's minor version
Imperatief versus declaratief deployen
Je hebt een image gebouwd en gepushed. kubectl is geauthenticeerd. Nu moet de Deployment de nieuwe image oppakken. Er zijn twee patronen en ze verschillen in waar de bron van waarheid zit.
Imperatief: kubectl set image. Een regel in de workflow patcht de live Deployment:
- name: Update deployment
run: |
kubectl set image deployment/my-app \
my-app=ghcr.io/$:sha-$ \
-n production
# Wacht tot de rollout klaar is; faal de job als die niet slaagt
kubectl rollout status deployment/my-app -n production --timeout=5m
De deploy is direct. Het cluster is de bron van waarheid, dus wat draait is de werkelijke state. De trade-off: je Git-repository weerspiegelt de werkelijkheid niet meer. Wie je manifests leest, ziet de oude image-tag, en een kubectl apply vanuit Git zou productie stilletjes downgraden. Disaster recovery betekent rebuilden en opnieuw deployen in plaats van manifests replayen.
Gebruik het imperatieve patroon als snelheid telt en je accepteert dat het cluster leidend is.
Declaratief: commit de manifest-wijziging. De workflow wijzigt het manifest-bestand, committet het, en past het toe vanuit Git of laat een GitOps-controller het oppakken:
- name: Update manifest
run: |
yq eval -i \
'.spec.template.spec.containers[0].image = "ghcr.io/$:sha-$"' \
k8s/production/deployment.yaml
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add k8s/production/deployment.yaml
git commit -m "deploy: update my-app to sha-$"
git push
Git blijft de bron van waarheid. Elke deploy is een commit met message, diff en een reviewbare historie. Rollbacks worden git revert. De trade-off: de workflow heeft schrijfrechten op de manifest-repository nodig (een token met contents: write of een dedicated deploy key), plus een tweede loop om te reconciliëren.
Voor de meeste teams wint declaratief, omdat het audits, reviews en rollbacks triviaal eenvoudig maakt. Die tweede loop kun je het beste laten afhandelen door Argo CD in plaats van direct kubectl apply vanuit de workflow. De volgende-stappen-sectie komt hierop terug.
Wat deze workflow NIET is
Drie misvattingen torpederen CI/CD-pipelines regelmatig voordat ze productie halen. Benoem ze gewoon hardop.
Het is niet "zet je kubeconfig in GitHub Secrets." Een kubeconfig is langlevende, cluster-brede credentials. Die opslaan als repository secret staat functioneel gelijk aan je clusterroot-wachtwoord publiceren onder toegangscontroles van een pastebin. Gebruik OIDC-federatie voor managed clusters. Gebruik een namespace-scoped ServiceAccount met kortlevend token voor self-hosted clusters. Op het moment dat je echo "$" | base64 -d typt: stop en kies een andere aanpak.
Het is niet "gebruik :latest als Docker-tag." De :latest tag is een pointer die zonder waarschuwing kan veranderen. Kubernetes gebruikt intern image digests, maar :latest veroorzaakt inconsistente pulls tussen nodes, breekt rollback, en maakt incident postmortems gokwerk. Tag met de commit SHA. Wil je leesbare tags voor productiereleases, voeg dan semantische versietags naast de SHA toe, nooit in plaats daarvan. De GitHub Actions CI/CD best practices guide markeert :latest expliciet als anti-pattern.
Het is niet "je hebt Argo CD of Jenkins nodig om naar Kubernetes te deployen." Een enkele GitHub Actions-workflow met kubectl set image of kubectl apply -f is genoeg voor een klein team met een of twee omgevingen. Argo CD en Flux betalen zich terug bij meerdere clusters, veel applicaties of strikte audit-eisen. Tot dan is de simpelste pipeline die bouwt, pusht en patcht de juiste tool. Stap pas over als de pijn van handmatige orchestratie de operationele kosten van nog een controller overstijgt.
Compleet workflow-bestand
Zet dit in .github/workflows/deploy.yaml. Comments leggen elke niet-voor-de-hand-liggende regel uit:
name: Build and deploy to Kubernetes
on:
push:
branches: [main] # CI draait bij elke push; alleen main triggert CD
concurrency:
group: deploy-production
cancel-in-progress: false # annuleer nooit een lopende deploy
jobs:
build:
name: Build and push image
runs-on: ubuntu-latest
permissions:
contents: read # checkout
packages: write # pushen naar GHCR
outputs:
image-tag: $ # geef SHA-tag door aan deploy-job
steps:
- uses: actions/checkout@v5
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: $
password: $
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Generate image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/$
tags: |
type=sha,format=long
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: $
labels: $
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to production
runs-on: ubuntu-latest
needs: build # blokkeert totdat build slaagt
environment: production # opt in op GitHub environment-protecties
permissions:
id-token: write # vraag OIDC-token aan bij GitHub
contents: read
steps:
- uses: actions/checkout@v5
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deployer
aws-region: eu-west-1
- name: Install kubectl
uses: azure/setup-kubectl@v5
with:
version: v1.30.0
- name: Update kubeconfig
run: aws eks update-kubeconfig --name production-cluster --region eu-west-1
- name: Roll out new image
run: |
IMAGE=ghcr.io/$:sha-$
kubectl set image deployment/my-app my-app=$IMAGE -n production
kubectl rollout status deployment/my-app -n production --timeout=5m
- name: Verify pods are healthy
run: |
# Exit non-zero als een pod niet Running en Ready is
kubectl get pods -n production -l app=my-app \
-o jsonpath='{range .items[*]}{.status.phase}{"\n"}{end}' \
| grep -v Running && exit 1 || echo "All pods Running"
Eindverificatie. Nadat de workflow klaar is op een push naar main:
- Het tabblad Actions laat beide jobs groen zien.
kubectl describe deployment my-app -n productiontoont eenImage:regel die naarghcr.io/<org>/<repo>:sha-<commit>wijst.kubectl rollout history deployment/my-app -n productionbevat een nieuwe revisie.- Requests naar de service krijgen antwoord van de nieuwe versie.
Wat je geleerd hebt
Je hebt een CI/CD-pipeline gebouwd die drie regels volgt.
Bouw eenmalig, deploy overal. De image is een onveranderlijk artefact getagd met de commit SHA. Het artefact dat door CI kwam is wat gedeployd wordt, niet een rebuild die misschien subtiel afwijkt.
Alleen kortlevende credentials. OIDC-federatie voor managed clusters, namespace-scoped ServiceAccount-tokens voor self-hosted. Geen langlevende kubeconfigs, geen statische cloudsleutels in GitHub Secrets.
Twee jobs, twee jobs aan permissies. De build-job heeft packages: write. De deploy-job heeft id-token: write. Geen van beide heeft meer dan nodig. Een gecompromitteerde buildstap kan niet deployen. Een gecompromitteerde deploystap kan geen images pushen.
Waar je daarna naartoe kunt. Zodra je meerdere omgevingen of clusters draait, gaat het imperatieve kubectl set image-patroon piepen. Schakel dan over naar het declaratieve model: de workflow committet de nieuwe image-tag naar een manifest-repository, en Argo CD reconcilieert die naar elk cluster. De buildstap blijft hetzelfde; de deploystap wordt git commit. Voor applicaties die veiligere rollouts nodig hebben dan de standaard Deployment-strategie, combineer deze workflow met zero-downtime rolling update configuratie zodat het cluster pod-vervanging netjes afhandelt terwijl GitHub Actions alles stroomopwaarts verzorgt.