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:

  1. Data soevereiniteit: je code, je builds, je artifacts blijven op jouw infrastructuur
  2. Geen usage limits: onbeperkte CI minuten, onbeperkte opslag, onbeperkte gebruikers
  3. Netwerk lokaliteit: builds draaien dicht bij je clusters, dus artifact transfers zijn snel
  4. Customization: configureer runners precies zoals je ze nodig hebt
  5. 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-from versnelt 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:

  1. Container scanning (Trivy): vind vulnerabilities in container images
  2. SAST: statische analyse van source code
  3. 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:

  1. Cloned de GitOps repository
  2. Update de image tag met Kustomize
  3. Commit en pusht
  4. 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

VariableProtectedMaskedBeschrijving
GITOPS_SSH_KEYSSH key voor GitOps repo
KUBECONFIGKubernetes config (bij directe deploy)
SONAR_TOKENSonarQube 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

  1. Self-host GitLab als je om soevereiniteit geeft. De operationele overhead is echt, en hij is het waard.

  2. Deploy nooit direct van CI naar Kubernetes. Update GitOps repos en laat een reconciler het toepassen doen.

  3. Fail fast op security: container scans met --exit-code 1 blokkeren kwetsbare images voordat ze shippen.

  4. Gebruik protected variables: secrets zouden alleen bereikbaar moeten zijn vanaf protected branches.

  5. 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.