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:
- 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, snellere artifact transfers
- Customization: Configureer runners precies zoals je ze nodig hebt
- 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-fromversnelt 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:
- Container scanning (Trivy): Vind vulnerabilities in container images
- SAST: Statische analyse van source code
- 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:
- Cloned de GitOps repository
- Update de image tag met Kustomize
- Commit en pusht
- 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
| 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
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
Self-host GitLab als je om soevereiniteit geeft. De operationele overhead is het waard.
Deploy nooit direct van CI naar Kubernetes. Update GitOps repos in plaats daarvan.
Fail fast op security: Container scans met
--exit-code 1blokkeren kwetsbare images.Gebruik protected variables: Secrets zouden alleen beschikbaar moeten zijn op protected branches.
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.
