Lange tijd draaide mijn CI op de servers van iemand anders. Push code, wacht op een hosted runner, kijk hoe de minuten aftikken tegen een maandelijks quotum, hoop dat de provider de regels niet verandert volgend kwartaal. Het werkte, tot ik me begon af te vragen waar mijn broncode eigenlijk stond terwijl hij gebouwd werd, en wie er nog meer bij kon.
Dus haalde ik GitLab in huis. Ik draai hem nu self-hosted, op eigen hardware, naast de clusters waarnaar hij deployt. Dat is soevereiniteit toegepast op CI/CD: mijn code, mijn builds, mijn artifacts, geen vendor die prijzen kan veranderen, een feature kan deprecaten waar ik van afhankelijk ben, of mijn repositories kan lezen zonder dat ik het weet.
De redenen stapelen zich op zodra je gaat tellen:
- Data soevereiniteit: je code, je builds, je artifacts blijven op jouw infrastructuur
- Geen usage limits: onbeperkte CI minuten, onbeperkte opslag, onbeperkte gebruikers
- Netwerk lokaliteit: builds draaien dicht bij je clusters, dus artifact transfers zijn snel
- Customization: configureer runners precies zoals je ze nodig hebt
- Air-gap capable: werkt in offline omgevingen
Niets hiervan is gratis. Je onderhoudt GitLab nu zelf. Je patcht hem, maakt back-ups, en je hebt de pager om 2 uur ’s nachts als een runner pod door de OOM-killer gesloopt wordt. Voor mij koopt die overhead iets wat ik op geen andere manier krijg, dus betaal ik hem met plezier.
Daarmee is bepaald waar de pipeline draait. De moeilijkere vraag is wat hij precies moet doen, en de stap die de meeste mensen verkeerd doen is de laatste.
De complicatie: hoe komt code van een commit in een draaiende pod, veilig?
Een image bouwen is makkelijk. Iedereen kan een docker build schrijven. De rommel begint nadat de image bestaat. Hoe voorkom je dat een kwetsbare image productie haalt? Waar leven de cluster credentials, en hoeveel plekken mogen ze vasthouden? Als een deploy fout gaat, kun je dan zien wat er veranderde en het terugdraaien zonder door job logs te spitten?
Het naïeve antwoord is de CI pipeline een kubeconfig geven en hem aan het eind kubectl apply laten draaien. Op dag één werkt het. Tegen dag negentig heb je cluster admin credentials in een dozijn project pipelines staan, geen audit trail van wat er werkelijk draait, en een deploy proces dat alleen bestaat als imperatieve shell commando’s in een YAML bestand.
Ik trek liever een harde grens tussen waar CI goed in is en waar het niks te zoeken heeft.
De pipeline architectuur
Een Kubernetes deployment pipeline heeft duidelijke fases:
flowchart LR
subgraph pipeline["GitLab CI Pipeline"]
Build["Build<br/>Compile, Lint,<br/>Build image"] --> Test["Test<br/>Unit, Integr.,<br/>E2E"]
Test --> Scan["Scan<br/>Trivy, SAST,<br/>Secrets"]
Scan --> Publish["Publish<br/>Push naar Registry,<br/>Sign"]
Publish --> Deploy["Deploy<br/>Update GitOps<br/>Repo"]
end
Elke fase heeft een duidelijk doel, en de laatste stopt bij de GitOps repo in plaats van bij het cluster. Waarom dat belangrijk is komt verderop. Laten we het bouwen.
De complete .gitlab-ci.yml
Hier is een productie-ready pipeline:
stages:
- build
- test
- scan
- publish
- deploy
variables:
# Container settings
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
# Image naming
IMAGE_NAME: $CI_REGISTRY_IMAGE
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
# Kubernetes
KUBE_NAMESPACE: $CI_PROJECT_NAME
# Herbruikbare configuraties
.docker-base:
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
# ============================================
# BUILD STAGE
# ============================================
build:
extends: .docker-base
stage: build
script:
- docker build
--cache-from $IMAGE_NAME:latest
--tag $IMAGE_NAME:$IMAGE_TAG
--tag $IMAGE_NAME:latest
--build-arg BUILDKIT_INLINE_CACHE=1
.
- docker push $IMAGE_NAME:$IMAGE_TAG
- docker push $IMAGE_NAME:latest
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_MERGE_REQUEST_ID
lint:
stage: build
image: golangci/golangci-lint:latest # Pas aan voor je taal
script:
- golangci-lint run ./...
rules:
- if: $CI_MERGE_REQUEST_ID
# ============================================
# TEST STAGE
# ============================================
unit-tests:
stage: test
image: golang:1.22 # Pas aan voor je taal
script:
- go test -v -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_MERGE_REQUEST_ID
integration-tests:
stage: test
extends: .docker-base
script:
- docker compose -f docker-compose.test.yml up -d
- docker compose -f docker-compose.test.yml run tests
- docker compose -f docker-compose.test.yml down
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ============================================
# SCAN STAGE
# ============================================
container-scan:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image
--exit-code 1
--severity HIGH,CRITICAL
--ignore-unfixed
$IMAGE_NAME:$IMAGE_TAG
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_MERGE_REQUEST_ID
allow_failure: false
sast:
stage: scan
image: returntocorp/semgrep
script:
- semgrep --config auto --error .
rules:
- if: $CI_MERGE_REQUEST_ID
secret-detection:
stage: scan
image: trufflesecurity/trufflehog:latest
script:
- trufflehog git file://. --only-verified --fail
rules:
- if: $CI_MERGE_REQUEST_ID
# ============================================
# PUBLISH STAGE
# ============================================
publish:
extends: .docker-base
stage: publish
script:
# Tag met semantic version als getagd
- |
if [ -n "$CI_COMMIT_TAG" ]; then
docker pull $IMAGE_NAME:$IMAGE_TAG
docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:$CI_COMMIT_TAG
docker push $IMAGE_NAME:$CI_COMMIT_TAG
fi
rules:
- if: $CI_COMMIT_TAG
# ============================================
# DEPLOY STAGE
# ============================================
deploy-staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache git openssh-client
- eval $(ssh-agent -s)
- echo "$GITOPS_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan gitlab.com >> ~/.ssh/known_hosts
script:
- git clone git@gitlab.com:$GITOPS_REPO.git gitops
- cd gitops
- |
# Update image tag in Kustomize
cd apps/$CI_PROJECT_NAME/overlays/staging
kustomize edit set image $IMAGE_NAME:$IMAGE_TAG
- git config user.email "ci@gitlab.local"
- git config user.name "GitLab CI"
- git add -A
- git commit -m "Deploy $CI_PROJECT_NAME:$IMAGE_TAG to staging" || exit 0
- git push
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache git openssh-client
- eval $(ssh-agent -s)
- echo "$GITOPS_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan gitlab.com >> ~/.ssh/known_hosts
script:
- git clone git@gitlab.com:$GITOPS_REPO.git gitops
- cd gitops
- |
cd apps/$CI_PROJECT_NAME/overlays/production
kustomize edit set image $IMAGE_NAME:$IMAGE_TAG
- git config user.email "ci@gitlab.local"
- git config user.name "GitLab CI"
- git add -A
- git commit -m "Deploy $CI_PROJECT_NAME:$IMAGE_TAG to production" || exit 0
- git push
environment:
name: production
url: https://example.com
rules:
- if: $CI_COMMIT_TAG
when: manual # Vereist handmatige goedkeuring voor productie
Dat is een hoop YAML in één keer. Laat me de fases doorlopen die hun plek verdienen.
De pipeline ontleden
Build stage: container maken
build:
extends: .docker-base
stage: build
script:
- docker build
--cache-from $IMAGE_NAME:latest
--tag $IMAGE_NAME:$IMAGE_TAG
--tag $IMAGE_NAME:latest
.
Belangrijke punten:
- Cache van vorige builds:
--cache-fromversnelt builds dramatisch - Commit SHA als tag: immutable, traceerbare image versies. De tag vertelt je precies welke commit de image heeft geproduceerd, wat je wil weten zodra een deploy zich misdraagt
- Ook tag
latest: voor de cache bron in de volgende build
Test stage: verifieer voor deploy
Tests draaien parallel waar mogelijk:
unit-tests:
# ...
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
De coverage regex haalt het coverage percentage eruit zodat GitLab het op de merge request kan tonen. Klein dingetje, maar het zet het getal voor je neus op het moment van reviewen, in plaats van begraven in een log dat niemand opent.
Scan stage: security gates
Hier begint de pipeline namens jou nee te zeggen. Drie scans, elk voor een andere soort fout:
- Container scanning (Trivy): vind vulnerabilities in container images
- SAST: statische analyse van source code
- Secret detection: vang credentials die iemand per ongeluk heeft gecommit
container-scan:
script:
- trivy image
--exit-code 1 # Fail pipeline op bevindingen
--severity HIGH,CRITICAL # Alleen blokkeren op serieuze issues
--ignore-unfixed # Skip vulnerabilities zonder fixes
Deploy stage: GitOps integratie
Hier is het antwoord op de vraag die ik bovenaan stelde. De pipeline raakt het cluster nooit aan. Hij updatet een GitOps repository, en ArgoCD doet de daadwerkelijke deployment door het cluster te laten kloppen met wat er nu in Git staat.
deploy-staging:
script:
- git clone git@gitlab.com:$GITOPS_REPO.git gitops
- cd gitops/apps/$CI_PROJECT_NAME/overlays/staging
- kustomize edit set image $IMAGE_NAME:$IMAGE_TAG
- git commit -m "Deploy $CI_PROJECT_NAME:$IMAGE_TAG to staging"
- git push
Dit pattern:
- Cloned de GitOps repository
- Update de image tag met Kustomize
- Commit en pusht
- ArgoCD detecteert de wijziging en deployt
De winst zit in wat er ontbreekt. De applicatie repo houdt nooit cluster credentials vast. Hij heeft alleen write access nodig tot één GitOps repo. Compromitteer een project pipeline en de blast radius is een Git commit die iemand bij review opmerkt, geen kubeconfig met de sleutels tot het cluster. Elke deploy is nu een commit met een diff, een auteur en een tijdstempel, dus de vraag “wat draait er nu en wie heeft het erin gezet” heeft een antwoord van één regel. En als productie in de fik staat, is rollback git revert, spiergeheugen in plaats van een verse ronde imperatieve kubectl onder druk.
Die scheiding is het hele punt. CI bouwt en bewijst het artifact; Git legt de intentie vast; ArgoCD maakt de werkelijkheid passend. Elk onderdeel doet waar het echt goed in is.
GitLab Runners voor Kubernetes
Niets hiervan draait zonder GitLab Runners om de jobs uit te voeren. Ik draai ze ook in Kubernetes, zodat hetzelfde cluster dat mijn workloads host ze ook bouwt:
# values.yaml voor gitlab-runner Helm chart
gitlabUrl: https://gitlab.example.com
runnerRegistrationToken: "your-token"
runners:
config: |
[[runners]]
[runners.kubernetes]
namespace = "gitlab-runners"
image = "alpine:latest"
privileged = true # Vereist voor Docker-in-Docker
[[runners.kubernetes.volumes.empty_dir]]
name = "docker-certs"
mount_path = "/certs/client"
medium = "Memory"
rbac:
create: true
resources:
limits:
memory: 256Mi
cpu: 250m
Installeer met Helm:
helm repo add gitlab https://charts.gitlab.io
helm install gitlab-runner gitlab/gitlab-runner -f values.yaml -n gitlab-runners
Environment variables en secrets
Sla gevoelige waarden op in GitLab CI/CD variables:
Project Settings → CI/CD → Variables
| Variable | Protected | Masked | Beschrijving |
|---|---|---|---|
GITOPS_SSH_KEY | ✓ | ✓ | SSH key voor GitOps repo |
KUBECONFIG | ✓ | ✓ | Kubernetes config (bij directe deploy) |
SONAR_TOKEN | ✓ | ✓ | SonarQube token |
Protected: alleen beschikbaar op protected branches Masked: verborgen in job logs
Markeer de GitOps SSH key zowel protected als masked. Een feature branch mag nooit een deploy kunnen pushen, en de key mag nooit opduiken in een log dat een collega kan terugscrollen.
Merge request pipelines
Je hebt niet de hele pipeline nodig bij elke push. Merge requests draaien een subset zodat feedback snel terugkomt:
lint:
rules:
- if: $CI_MERGE_REQUEST_ID # Alleen op MRs
unit-tests:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_MERGE_REQUEST_ID # Ook op MRs
Snelle feedback op de MR, de zwaardere jobs gereserveerd voor de default branch. Een pipeline waar je bij elke push tien minuten op wacht is een pipeline die mensen gaan overslaan.
Caching voor snelheid
Het andere dat een pipeline uit de vergeethoek houdt is snelheid, en caching doet het meeste werk:
variables:
GOPATH: $CI_PROJECT_DIR/.go
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .go/pkg/mod/
- node_modules/
- .npm/
Voor Docker layer caching, gebruik BuildKit:
build:
variables:
DOCKER_BUILDKIT: 1
script:
- docker build --build-arg BUILDKIT_INLINE_CACHE=1 ...
Pipeline health monitoren
Een pipeline is infrastructuur, en infrastructuur die je niet bekijkt is infrastructuur die je niet begrijpt. Scrape GitLab’s eigen metrics:
# In je monitoring stack
- job_name: 'gitlab'
static_configs:
- targets: ['gitlab.example.com']
metrics_path: '/-/metrics'
Belangrijke metrics om te watchen:
- Pipeline duur (kruipt omhoog als caching ergens faalt)
- Succes/failure rate
- Queue time (stijgende queue time betekent dat runners verzadigd zijn)
- Cache hit rate
De complete flow
flowchart TD
Dev["Developer<br/>pusht code"] --> GitLab["GitLab<br/>triggert CI"]
GitLab --> Pipeline["Pipeline<br/>Build → Test → Scan → Publish → Update GitOps"]
Pipeline --> GitOpsRepo["GitOps Repo<br/>(updated)"]
GitOpsRepo --> ArgoCD["ArgoCD<br/>detecteert & deployt"]
ArgoCD --> K8s["Kubernetes<br/>(draait)"]
Schone scheiding van concerns:
- GitLab CI: build, test, scan, publish
- GitOps repo: gewenste staat
- ArgoCD: reconciliation
- Kubernetes: runtime
Elk blok weet niets van de interne werking van de andere. CI weet niet hoe het cluster manifests toepast. Het cluster weet niet hoe de image gebouwd is. Ze ontmoeten elkaar bij een Git commit, de saaiste en best inspecteerbare interface die ik kan bedenken.
Mijn aanbevelingen
Self-host GitLab als je om soevereiniteit geeft. De operationele overhead is echt, en hij is het waard.
Deploy nooit direct van CI naar Kubernetes. Update GitOps repos en laat een reconciler het toepassen doen.
Fail fast op security: container scans met
--exit-code 1blokkeren kwetsbare images voordat ze shippen.Gebruik protected variables: secrets zouden alleen bereikbaar moeten zijn vanaf protected branches.
Handmatige productie deploys: houd een mens in de loop voor productie.
De pipeline die ik het meest vertrouw is die ik regel voor regel aan een vreemde zou kunnen uitleggen. Build, bewijs, leg vast, reconcile. Niets magisch, niets verborgen, en een Git history die je precies vertelt wat er gebeurde toen er iets brak.
