Ik draai GitLab self-hosted. Niet omdat het trendy is, maar omdat ik mijn CI/CD pipeline wil bezitten. Geen vendor kan prijzen veranderen, features deprecaten, of mijn code benaderen zonder mijn weten.

Dit is soevereiniteit toegepast op CI/CD. En GitLab maakt het praktisch.

Laat me je laten zien hoe je een complete pipeline bouwt: van code commit tot draaiend in Kubernetes.

Waarom Self-Hosted GitLab?

Voordat we in pipelines duiken, het “waarom” is belangrijk:

  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, snellere artifact transfers
  4. Customization: Configureer runners precies zoals je ze nodig hebt
  5. Air-gap capable: Werkt in offline omgevingen

De trade-off is operationele overhead. Je onderhoudt GitLab. Voor mij is dat het waard.

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

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
  • Ook tag latest: Voor cache bron in volgende build

Test Stage: Verifieer Voor Deploy

Tests draaien parallel waar mogelijk:

unit-tests:
  # ...
  coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'

De coverage regex extraheert coverage percentage voor GitLab’s merge request weergave.

Scan Stage: Security Gates

Drie security scans:

  1. Container scanning (Trivy): Vind vulnerabilities in container images
  2. SAST: Statische analyse van source code
  3. Secret detection: Vang per ongeluk gecommitte credentials
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 wordt het interessant. We deployen niet direct naar Kubernetes. We updaten de GitOps repository, en ArgoCD doet de daadwerkelijke deployment.

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 applicatie code repo heeft geen cluster credentials nodig. Het heeft alleen write access nodig tot de GitOps repo.

GitLab Runners voor Kubernetes

Om dit te laten werken heb je GitLab Runners nodig. Ik draai ze in Kubernetes:

# 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

Merge Request Pipelines

Voor merge requests, draai een subset van de pipeline:

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

Dit geeft snelle feedback zonder resources te verspillen bij elke push.

Caching voor Snelheid

Versnel pipelines met caching:

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

Track pipeline metrics:

# In je monitoring stack
- job_name: 'gitlab'
  static_configs:
    - targets: ['gitlab.example.com']
  metrics_path: '/-/metrics'

Belangrijke metrics om te watchen:

  • Pipeline duur
  • Succes/failure rate
  • Queue time (runners busy?)
  • 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

Mijn Aanbevelingen

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

  2. Deploy nooit direct van CI naar Kubernetes. Update GitOps repos in plaats daarvan.

  3. Fail fast op security: Container scans met --exit-code 1 blokkeren kwetsbare images.

  4. Gebruik protected variables: Secrets zouden alleen beschikbaar moeten zijn op protected branches.

  5. Handmatige productie deploys: Vereist menselijke goedkeuring voor productie.


Een goede CI/CD pipeline is onzichtbaar wanneer het werkt en duidelijk wanneer het faalt. Bouw het eenmaal, vertrouw het altijd — maar verifieer met elke commit.