GitLab CI/CD: Pipelines, Runner und Automatisierung
Im vorherigen Post über GitLab-Workflows haben wir uns mit Git-Grundlagen, Branching-Strategien und Merge Requests beschäftigt. Damit hast du eine solide Basis für die Zusammenarbeit im Team. Aber Code schreiben und mergen ist nur die halbe Miete – der eigentliche Mehrwert entsteht, wenn nach jedem Push automatisch getestet, gebaut und deployed wird.
Genau dafür gibt es CI/CD (Continuous Integration / Continuous Delivery). Und GitLab bringt alles dafür von Haus aus mit – keine Drittanbieter-Plugins, keine externen Services. Eine einzige YAML-Datei im Repository reicht, um komplette Build- und Deployment-Pipelines zu definieren.
Gerade im Homelab ist das extrem praktisch: Du pushst eine Änderung an deiner Jekyll-Doku, und Sekunden später ist die Seite live. Du baust ein neues Docker-Image, und es landet automatisch in deiner Container Registry. Du schreibst Tests für dein Python-Projekt, und bei jedem Merge Request siehst du sofort, ob alles grün ist.
Dieser Post bezieht sich auf eine self-hosted GitLab CE Instanz. Die meisten Konzepte gelten identisch für gitlab.com – bei der SaaS-Variante sparst du dir lediglich das Runner-Setup.
In diesem Post schauen wir uns an:
- Was CI/CD genau bedeutet und wie GitLab es umsetzt
- Wie du einen eigenen GitLab Runner aufsetzt
- Wie
.gitlab-ci.ymlaufgebaut ist und was du damit alles machen kannst - Praxisbeispiele aus dem Homelab – von Auto-Deploy bis Backup-Jobs
GitLab CI/CD Grundlagen
CI vs. CD vs. CD
Die drei Begriffe werden oft synonym verwendet, meinen aber unterschiedliche Dinge:
| Abkürzung | Bedeutung | Was passiert? |
|---|---|---|
| CI | Continuous Integration | Code wird bei jedem Push automatisch gebaut und getestet |
| CD | Continuous Delivery | Code ist nach erfolgreichem Test bereit für Deployment (manueller Klick) |
| CD | Continuous Deployment | Code wird nach erfolgreichem Test automatisch deployed |
In der Praxis beginnt fast jedes Team mit CI (automatische Tests) und arbeitet sich dann zu CD vor. Im Homelab kannst du direkt mit Continuous Deployment starten – das Risiko ist überschaubar.
Pipeline-Architektur
Eine GitLab Pipeline besteht aus Stages (Phasen), die nacheinander ablaufen. Innerhalb einer Stage laufen Jobs parallel:
graph LR
A[Push / MR / Schedule] --> B[Pipeline]
B --> C[Stage: Build]
B --> D[Stage: Test]
B --> E[Stage: Deploy]
C --> C1[Job: compile]
C --> C2[Job: lint]
D --> D1[Job: unit-tests]
D --> D2[Job: integration-tests]
E --> E1[Job: deploy-staging]
E --> E2[Job: deploy-prod]
Wann läuft eine Pipeline?
GitLab startet Pipelines automatisch bei bestimmten Events:
| Event | Beschreibung | Typischer Einsatz |
|---|---|---|
| Push | Jeder Push auf einen Branch | Standard-CI |
| Merge Request | Erstellen/Aktualisieren eines MR | Code Review + Tests |
| Tag | Neuer Git-Tag erstellt | Release-Builds |
| Schedule | Zeitgesteuert (Cron) | Nightly Builds, Backups |
| API / Trigger | Externer Aufruf | Cross-Project Pipelines |
| Web | Manueller Start in der UI | Debug, Ad-hoc Builds |
Du kannst mit
rules:in deiner.gitlab-ci.ymlgenau steuern, welche Jobs bei welchem Event laufen. Dazu später mehr.
Der Pipeline-Lebenszyklus
stateDiagram-v2
[*] --> created : Trigger Event
created --> waiting_for_resource : Runner verfügbar?
waiting_for_resource --> pending : Ressource zugewiesen
pending --> running : Runner startet Job
running --> success : Alle Jobs erfolgreich
running --> failed : Mindestens ein Job fehlgeschlagen
success --> [*]
failed --> [*]
GitLab Runner einrichten
Ein Runner ist der Agent, der deine Pipeline-Jobs tatsächlich ausführt. GitLab selbst koordiniert nur – die Arbeit macht der Runner.
Runner-Typen
| Typ | Scope | Registrierung | Einsatz |
|---|---|---|---|
| Shared Runner | Alle Projekte der Instanz | Admin-Bereich | Standard für kleine Teams |
| Group Runner | Alle Projekte einer Gruppe | Gruppeneinstellungen | Team-spezifische Builds |
| Project Runner | Einzelnes Projekt | Projekteinstellungen | Spezielle Anforderungen |
Für ein Homelab reicht ein einzelner Shared Runner in der Regel aus. Registriere ihn als Admin, und alle Projekte können ihn nutzen.
Executor-Typen
Der Executor bestimmt, wie der Runner Jobs ausführt:
| Executor | Isolation | Speed | Einsatz |
|---|---|---|---|
| Shell | Keine | Schnell | Einfache Scripts, lokale Tools |
| Docker | Container | Mittel | Standard – jeder Job in frischem Container |
| Docker Machine | VM + Container | Langsam | Auto-Scaling (Cloud) |
| Kubernetes | Pod | Variabel | K8s-Cluster |
| SSH | Remote | Langsam | Legacy-Systeme |
Für das Homelab empfehle ich den Docker-Executor. Er bietet gute Isolation, ist einfach einzurichten und du kannst für jeden Job ein passendes Image wählen.
Docker Runner im Homelab aufsetzen
Wir deployen den Runner selbst als Docker-Container. Erstelle auf einer VM (z.B. 192.168.60.xxx) folgende Dateien:
1
2
3
4
5
6
7
8
9
10
services:
gitlab-runner:
image: gitlab/gitlab-runner:latest
container_name: gitlab-runner
restart: unless-stopped
volumes:
- ./config:/etc/gitlab-runner
- /var/run/docker.sock:/var/run/docker.sock
environment:
- TZ=Europe/Berlin
Das Mounten von
/var/run/docker.sockerlaubt dem Runner, Docker-Container für Jobs zu starten. Das ist der Docker-in-Docker-Ansatz via Socket – einfach, aber der Runner hat damit Root-Zugriff auf den Docker-Host.
Starte den Runner:
1
docker compose up -d
Runner registrieren
Hole dir den Registration Token aus der GitLab Admin-Oberfläche unter Admin Area > CI/CD > Runners und registriere den Runner:
1
2
3
4
5
6
7
8
docker exec -it gitlab-runner gitlab-runner register \
--non-interactive \
--url "https://gitlab.example.com" \
--token "glrt-xxxxxxxxxxxxxxxxxxxx" \
--executor "docker" \
--docker-image "alpine:latest" \
--description "homelab-docker-runner" \
--tag-list "docker,homelab"
Die Konfiguration landet in config/config.toml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
concurrent = 4
check_interval = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "homelab-docker-runner"
url = "https://gitlab.example.com"
token = "glrt-xxxxxxxxxxxxxxxxxxxx"
executor = "docker"
[runners.docker]
image = "alpine:latest"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache"]
shm_size = 0
network_mtu = 0
Wichtige Anpassungen in config.toml:
1
2
3
4
5
concurrent = 4 # Anzahl paralleler Jobs
[runners.docker]
privileged = true # Nötig für Docker-in-Docker Builds
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
pull_policy = ["if-not-present"] # Images lokal cachen
privileged = trueist nötig, wenn du Docker-Images innerhalb von CI-Jobs bauen willst (z.B. mitdocker build). Für reine Test-Jobs reichtfalse.
Runner testen
Prüfe, ob der Runner in GitLab angezeigt wird:
1
docker exec gitlab-runner gitlab-runner verify
In der GitLab-UI sollte der Runner unter Admin Area > CI/CD > Runners mit grünem Status erscheinen.
.gitlab-ci.yml Deep Dive
Die gesamte Pipeline-Konfiguration lebt in einer einzigen Datei im Repository-Root: .gitlab-ci.yml. Sobald GitLab diese Datei findet, werden Pipelines bei jedem Push automatisch gestartet.
Stages und Jobs
Die grundlegende Struktur:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
stages:
- build
- test
- deploy
build-app:
stage: build
image: node:20-alpine
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
run-tests:
stage: test
image: node:20-alpine
script:
- npm ci
- npm test
deploy-production:
stage: deploy
image: alpine:latest
script:
- echo "Deploying to production..."
when: manual
graph TD
subgraph "Stage: build"
A[build-app]
end
subgraph "Stage: test"
B[run-tests]
end
subgraph "Stage: deploy"
C[deploy-production<br>🔒 manual]
end
A --> B --> C
Job-Konfiguration im Detail
Jeder Job kann eine Vielzahl von Parametern haben:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
mein-job:
stage: test # Welche Stage
image: python:3.12-slim # Docker-Image für den Job
tags:
- docker # Nur Runner mit diesem Tag
- homelab
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
before_script:
- pip install -r requirements.txt
script:
- pytest tests/ -v
- coverage report
after_script:
- echo "Aufräumen..." # Läuft auch bei Fehler
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
expire_in: 30 days
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cache/pip/
retry:
max: 2
when:
- runner_system_failure
timeout: 10 minutes
allow_failure: false
Vordefinierte Variablen
GitLab stellt in jedem Job automatisch Variablen zur Verfügung:
| Variable | Beschreibung | Beispielwert |
|---|---|---|
CI_COMMIT_SHA |
Vollständiger Commit-Hash | a1b2c3d4e5f6... |
CI_COMMIT_SHORT_SHA |
Kurzer Hash (8 Zeichen) | a1b2c3d4 |
CI_COMMIT_BRANCH |
Branch-Name | feature/login |
CI_COMMIT_TAG |
Tag-Name (falls vorhanden) | v1.2.3 |
CI_PIPELINE_SOURCE |
Auslöser der Pipeline | push, merge_request_event |
CI_PROJECT_DIR |
Projektverzeichnis | /builds/group/project |
CI_REGISTRY |
Container Registry URL | registry.gitlab.example.com |
CI_REGISTRY_IMAGE |
Image-Pfad des Projekts | registry.gitlab.example.com/group/project |
Die vollständige Liste findest du unter Settings > CI/CD > Variables oder in der GitLab-Dokumentation.
Eigene Variablen
Du kannst Variablen auf verschiedenen Ebenen definieren:
1
2
3
4
5
6
7
8
9
# In .gitlab-ci.yml (global)
variables:
APP_VERSION: "1.0.0"
DEPLOY_TARGET: "production"
# Pro Job überschreiben
deploy-job:
variables:
DEPLOY_TARGET: "staging"
Zusätzlich kannst du Variablen in der GitLab-UI setzen:
- Projekt-Variablen: Settings > CI/CD > Variables
- Gruppen-Variablen: Gruppe > Settings > CI/CD > Variables
- Instanz-Variablen: Admin > CI/CD > Variables
Artifacts vs. Cache
Das ist eine der häufigsten Verwirrungsquellen. Hier der Unterschied:
| Eigenschaft | Artifacts | Cache |
|---|---|---|
| Zweck | Ergebnisse zwischen Jobs/Stages weitergeben | Build-Abhängigkeiten zwischen Pipelines beschleunigen |
| Lebensdauer | An die Pipeline gebunden | Persistent über Pipelines hinweg |
| Verfügbarkeit | Downloadbar in der UI | Nur innerhalb von Jobs |
| Upload | Immer zum GitLab-Server | Runner-lokal oder S3 |
| Beispiel | Kompiliertes Binary, Testergebnisse | node_modules/, pip-cache |
| Keyword | artifacts: |
cache: |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Artifact: Build-Ergebnis an Deploy-Stage weitergeben
build:
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
# Cache: npm-Downloads zwischen Pipelines wiederverwenden
test:
stage: test
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
script:
- npm ci
- npm test
Faustregel: Artifacts = “Ich habe etwas gebaut, das der nächste Job braucht.” Cache = “Ich will nicht jedes Mal 500 MB Dependencies runterladen.”
Services (Sidecar-Container)
Manchmal braucht dein Job zusätzliche Dienste – z.B. eine Datenbank für Integration Tests:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
integration-tests:
stage: test
image: python:3.12-slim
services:
- name: postgres:16-alpine
alias: db
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
- name: redis:7-alpine
alias: cache
variables:
DATABASE_URL: "postgresql://testuser:testpass@db:5432/testdb"
REDIS_URL: "redis://cache:6379"
script:
- pip install -r requirements.txt
- pytest tests/integration/ -v
Die Services laufen als Sidecar-Container im gleichen Netzwerk – du erreichst sie über den alias als Hostnamen.
Include und Templates
Für DRY-Prinzip (Don’t Repeat Yourself) bietet GitLab include und extends:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Templates in einer separaten Datei
include:
- local: '/.gitlab/ci/templates.yml'
- project: 'devops/ci-templates'
ref: main
file: '/docker-build.yml'
- remote: 'https://example.com/ci-template.yml'
# Eigenes Template mit extends
.test-template:
stage: test
before_script:
- pip install -r requirements.txt
cache:
key: pip-cache
paths:
- .cache/pip/
unit-tests:
extends: .test-template
script:
- pytest tests/unit/
integration-tests:
extends: .test-template
services:
- postgres:16-alpine
script:
- pytest tests/integration/
Jobs, deren Name mit einem Punkt beginnt (
.test-template), sind Hidden Jobs – sie werden nicht ausgeführt, sondern dienen nur als Template.
Pipeline-Patterns
Multi-Stage Pipeline
Das klassische Pattern: Jobs laufen Stage für Stage nacheinander, innerhalb einer Stage parallel.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
stages:
- build
- test
- security
- deploy
build-frontend:
stage: build
script: npm run build
build-backend:
stage: build
script: go build ./...
test-frontend:
stage: test
script: npm test
test-backend:
stage: test
script: go test ./...
sast-scan:
stage: security
script: semgrep scan
deploy:
stage: deploy
script: ./deploy.sh
DAG-Pipeline mit needs:
Mit needs: kannst du die starre Stage-Reihenfolge aufbrechen. Jobs starten, sobald ihre Abhängigkeiten erfüllt sind:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
stages:
- build
- test
- deploy
build-frontend:
stage: build
script: npm run build
artifacts:
paths: [dist/]
build-backend:
stage: build
script: go build -o app
artifacts:
paths: [app]
test-frontend:
stage: test
needs: [build-frontend] # Startet sofort nach build-frontend
script: npm test
test-backend:
stage: test
needs: [build-backend] # Startet sofort nach build-backend
script: go test ./...
deploy:
stage: deploy
needs: [test-frontend, test-backend]
script: ./deploy.sh
graph LR
BF[build-frontend] --> TF[test-frontend]
BB[build-backend] --> TB[test-backend]
TF --> D[deploy]
TB --> D
DAG-Pipelines können deutlich schneller sein, weil Jobs nicht auf die gesamte vorherige Stage warten müssen.
Parent-Child Pipelines
Für komplexe Monorepos kannst du Pipelines aufteilen:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Haupt-Pipeline (.gitlab-ci.yml)
stages:
- trigger
frontend:
stage: trigger
trigger:
include: frontend/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- frontend/**/*
backend:
stage: trigger
trigger:
include: backend/.gitlab-ci.yml
strategy: depend
rules:
- changes:
- backend/**/*
Merge Request Pipelines
Speziell für Merge Requests kannst du eine schlankere Pipeline konfigurieren:
1
2
3
4
5
6
7
8
9
10
11
12
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_TAG
lint:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- npm run lint
Scheduled Pipelines
Zeitgesteuerte Pipelines konfigurierst du in der GitLab-UI unter Build > Pipeline Schedules. In der CI-Datei reagierst du darauf mit:
1
2
3
4
5
6
nightly-backup:
stage: deploy
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- ./scripts/backup.sh
Scheduled Pipelines nutzen den Cron-Syntax. Beispiel:
0 2 * * *= jeden Tag um 2:00 Uhr.
Environments und Deployment
Das Environment-Konzept
GitLab Environments geben deinen Deployments einen Namen und eine URL. Damit siehst du in der UI jederzeit, welche Version wo läuft:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
deploy-staging:
stage: deploy
environment:
name: staging
url: https://staging.example.com
script:
- ./deploy.sh staging
rules:
- if: $CI_COMMIT_BRANCH == "develop"
deploy-production:
stage: deploy
environment:
name: production
url: https://example.com
script:
- ./deploy.sh production
when: manual
rules:
- if: $CI_COMMIT_BRANCH == "main"
Mit
when: manualwird der Production-Deploy erst nach einem Klick in der UI ausgelöst – ein einfacher Schutz vor versehentlichen Deployments.
Deployment-Methoden
Im Homelab gibt es verschiedene Wege, Code auf eine VM zu bringen:
SSH-Deploy
1
2
3
4
5
6
7
8
9
10
11
12
deploy-via-ssh:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
script:
- scp -r dist/ [email protected]:/var/www/app/
- ssh [email protected] "cd /var/www/app && docker compose up -d --build"
Docker Registry Deploy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
build-and-push:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
deploy-from-registry:
stage: deploy
script:
- ssh user@target-vm "docker pull $CI_REGISTRY_IMAGE:latest && docker compose up -d"
needs: [build-and-push]
Ansible Deploy
1
2
3
4
5
6
deploy-with-ansible:
stage: deploy
image: cytopia/ansible:latest
script:
- ansible-playbook -i inventory/production deploy.yml
--extra-vars "version=$CI_COMMIT_SHORT_SHA"
Secrets Management
CI/CD Variables
Sensible Daten wie Passwörter, API-Tokens oder SSH-Keys gehören niemals in die .gitlab-ci.yml oder ins Repository. Stattdessen nutzt du CI/CD Variables:
- Gehe zu Settings > CI/CD > Variables
- Klicke auf Add Variable
- Setze Key und Value
Wichtige Optionen:
| Option | Bedeutung |
|---|---|
| Protected | Variable nur in geschützten Branches/Tags verfügbar |
| Masked | Wert wird in Log-Output mit [MASKED] ersetzt |
| Expand variable | Erlaubt Referenzierung anderer Variablen |
| Type: Variable | Normaler Umgebungsvariablen-String |
| Type: File | Wert wird in temporäre Datei geschrieben, Variable enthält Dateipfad |
1
2
3
4
5
6
deploy:
script:
# $SSH_PRIVATE_KEY ist als CI/CD Variable gesetzt
- echo "$SSH_PRIVATE_KEY" | ssh-add -
# $KUBECONFIG ist als File-Variable gesetzt
- kubectl --kubeconfig=$KUBECONFIG apply -f deployment.yml
Best Practices
Niemals Secrets in
.gitlab-ci.ymlhartcodieren – auch nicht “nur zum Testen”. Git vergisst nichts.
- Protected + Masked für alle produktiven Secrets
- File-Variablen für Zertifikate, Kubeconfigs, Service-Account-Keys
- Gruppen-Variablen für Secrets, die in mehreren Projekten gebraucht werden
- Variablen-Scoping: Definiere Variablen so spezifisch wie möglich (Projekt > Gruppe > Instanz)
- Rotation: Ändere Tokens und Passwörter regelmäßig – CI/CD Variables machen das einfach
Prüfe mit
git log --all -p -- .gitlab-ci.yml, ob jemals Secrets versehentlich committed wurden. Falls ja: Repository-History bereinigen mitgit filter-branchoder BFG Repo-Cleaner.
Homelab-Praxisbeispiele
Jekyll Docs Auto-Deploy
Dieses Blog hier wird automatisch deployed. Bei jedem Push auf main triggert GitLab einen Webhook auf der Jekyll-VM (192.168.60.89), die den Build anstößt. Alternativ kannst du das komplett in der Pipeline lösen:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
stages:
- build
- deploy
variables:
JEKYLL_ENV: production
build-site:
stage: build
image: jekyll/jekyll:latest
script:
- bundle install
- bundle exec jekyll build -d public
artifacts:
paths:
- public/
expire_in: 1 hour
deploy-docs:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client rsync
- eval $(ssh-agent -s)
- echo "$DEPLOY_SSH_KEY" | ssh-add -
- mkdir -p ~/.ssh
- echo "$DEPLOY_KNOWN_HOSTS" >> ~/.ssh/known_hosts
script:
- rsync -avz --delete public/ [email protected]:/var/www/jekyll/
environment:
name: production
url: https://docs-jekyll.newsxc.net
rules:
- if: $CI_COMMIT_BRANCH == "main"
Die Webhook-Variante (aktueller Stand) ist simpler, aber die Pipeline-Variante hat den Vorteil, dass der Build-Status in GitLab sichtbar ist und du Fehler sofort siehst.
Docker Image Build und Push
Ein typisches Pattern für Container-basierte Apps:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
stages:
- lint
- build
- test
- push
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
hadolint:
stage: lint
image: hadolint/hadolint:latest-debian
script:
- hadolint Dockerfile
allow_failure: true
build-image:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t $IMAGE_TAG -t $IMAGE_LATEST .
- docker save $IMAGE_TAG > image.tar
artifacts:
paths:
- image.tar
expire_in: 1 hour
test-image:
stage: test
image: docker:24
services:
- docker:24-dind
script:
- docker load < image.tar
- docker run --rm $IMAGE_TAG /app/healthcheck.sh
needs: [build-image]
push-image:
stage: push
image: docker:24
services:
- docker:24-dind
script:
- docker load < image.tar
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $IMAGE_TAG
- docker push $IMAGE_LATEST
needs: [test-image]
rules:
- if: $CI_COMMIT_BRANCH == "main"
Python-App mit Tests
Ein Python-Projekt mit pytest, Coverage-Report und Linting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
stages:
- lint
- test
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
.python-base:
image: python:3.12-slim
cache:
key: pip-${CI_COMMIT_REF_SLUG}
paths:
- .cache/pip/
- .venv/
before_script:
- python -m venv .venv
- source .venv/bin/activate
- pip install -r requirements.txt
ruff-lint:
extends: .python-base
stage: lint
script:
- pip install ruff
- ruff check .
- ruff format --check .
pytest:
extends: .python-base
stage: test
script:
- pip install pytest pytest-cov
- pytest tests/ -v --junitxml=report.xml --cov=src/ --cov-report=xml:coverage.xml --cov-report=term
artifacts:
reports:
junit: report.xml
coverage_report:
coverage_format: cobertura
path: coverage.xml
expire_in: 30 days
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
deploy-app:
stage: deploy
extends: .python-base
script:
- pip install ansible
- ansible-playbook deploy/playbook.yml
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
Das
coverage:Keyword mit Regex extrahiert die Coverage-Prozentzahl aus dem Terminal-Output. GitLab zeigt sie dann direkt im Merge Request an.
Scheduled Backup Job
Ein Backup-Job, der jede Nacht läuft und Datenbanken sichert:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
stages:
- backup
backup-databases:
stage: backup
image: alpine:latest
tags:
- homelab
before_script:
- apk add --no-cache openssh-client postgresql16-client mariadb-client
- eval $(ssh-agent -s)
- echo "$BACKUP_SSH_KEY" | ssh-add -
- mkdir -p ~/.ssh backups
- echo "$BACKUP_KNOWN_HOSTS" >> ~/.ssh/known_hosts
script:
- |
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# PostgreSQL Backup
PGPASSWORD=$PG_PASSWORD pg_dump -h $PG_HOST -U $PG_USER $PG_DATABASE \
> backups/postgres_${TIMESTAMP}.sql
# MariaDB Backup
mariadb-dump -h $MARIA_HOST -u $MARIA_USER -p"$MARIA_PASSWORD" \
--all-databases > backups/mariadb_${TIMESTAMP}.sql
# Auf NAS kopieren
scp backups/*.sql [email protected]:/share/backups/databases/
# Alte Backups aufräumen (älter als 30 Tage)
ssh [email protected] \
"find /share/backups/databases/ -name '*.sql' -mtime +30 -delete"
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
artifacts:
paths:
- backups/
expire_in: 7 days
Dazu in der GitLab-UI unter Build > Pipeline Schedules einen Schedule erstellen: 0 3 * * * (täglich um 3:00 Uhr).
Troubleshooting
Runner offline
1
2
3
4
5
6
7
8
# Status prüfen
docker exec gitlab-runner gitlab-runner verify
# Logs anschauen
docker logs -f gitlab-runner
# Häufige Ursache: Token abgelaufen -> neu registrieren
docker exec -it gitlab-runner gitlab-runner register
Wenn der Runner nach einem GitLab-Update offline geht, prüfe, ob die Runner-Version zum GitLab-Server passt. Große Versionsunterschiede können Probleme verursachen.
Permission-Fehler in Docker-Jobs
1
2
3
4
5
6
# Symptom: "permission denied" beim Zugriff auf Dateien
# Lösung: Im Job den richtigen User setzen
my-job:
image: node:20
before_script:
- chown -R node:node .
Oder in config.toml:
1
2
3
4
[[runners]]
[runners.docker]
# Jobs als Root ausführen (Vorsicht!)
user = ""
Cache-Probleme
1
2
3
4
5
6
7
8
9
# Cache für einen Job gezielt löschen
clear-cache:
script:
- echo "Cache cleared"
cache:
key: ${CI_COMMIT_REF_SLUG}
policy: push # Nur pushen, nicht pullen -> überschreibt
paths:
- .cache/
Oder in der UI: Build > Pipelines > Clear Runner Caches
Performance optimieren
needs:statt starrer Stages nutzen – Jobs starten früherrules:stattonly/except– präzisere Kontrolle, weniger unnötige Jobs- Cache intelligent nutzen –
pull-pushPolicy für den Haupt-Branch,pullfür Feature-Branches - Kleine Images –
alpine-Varianten statt Full-Size Images interruptible: true– alte Pipelines abbrechen, wenn ein neuer Push kommt
1
2
3
4
5
6
workflow:
auto_cancel:
on_new_commit: interruptible
default:
interruptible: true
Mit
interruptible: truewerden laufende Pipelines automatisch abgebrochen, wenn ein neuer Commit auf dem gleichen Branch gepusht wird. Das spart Runner-Ressourcen.
Vergleichstabelle
| Feature | GitLab CI/CD | GitHub Actions | Jenkins | Drone CI |
|---|---|---|---|---|
| Config-Format | YAML (.gitlab-ci.yml) |
YAML (.github/workflows/) |
Groovy (Jenkinsfile) |
YAML (.drone.yml) |
| Runner/Agent | GitLab Runner | GitHub-hosted / Self-hosted | Jenkins Agent | Drone Runner |
| Self-Hosted | Ja (GitLab CE/EE) | Nur Runner | Ja | Ja |
| Container Registry | Integriert | Integriert (ghcr.io) | Plugin | Plugin |
| Environments | Integriert | Integriert | Plugin | Limitiert |
| Secrets | CI/CD Variables | Secrets | Credentials Plugin | Secrets |
| Auto DevOps | Ja | Nein | Nein | Nein |
| DAG-Support | needs: Keyword |
Job Dependencies | Pipeline-Plugin | depends_on: |
| Komplexität | Mittel | Niedrig | Hoch | Niedrig |
| Community | Groß | Sehr groß | Sehr groß | Klein |
| Ideal für | Self-Hosted, All-in-One | Open Source, Cloud | Enterprise, Legacy | Container-native |
Wenn du bereits GitLab als Git-Plattform nutzt, ist GitLab CI/CD die naheliegende Wahl. Alles ist integriert – kein Plugin-Chaos wie bei Jenkins, keine separate Plattform wie bei GitHub Actions.
Fazit
GitLab CI/CD ist ein mächtiges Werkzeug, das weit über einfache Build-Skripte hinausgeht. Mit einem einzigen Runner und einer .gitlab-ci.yml kannst du im Homelab komplette Workflows automatisieren – vom Code-Linting über Tests bis zum Deployment auf deine VMs.
Die wichtigsten Takeaways:
- Ein Docker-Runner reicht für die meisten Homelab-Setups
.gitlab-ci.ymlist deine Single Source of Truth für den gesamten Build-Prozess- Artifacts für Job-zu-Job-Daten, Cache für Build-Beschleunigung
rules:stattonly/exceptfür präzise Pipeline-Steuerung- CI/CD Variables für alle Secrets – niemals hartcodiert
needs:für DAG-Pipelines, die schneller fertig sind
Starte einfach: Ein Lint-Job und ein Deploy-Job reichen für den Anfang. Komplexere Pipelines kannst du iterativ aufbauen, wenn du sie brauchst.
Im nächsten Post schauen wir uns an, wie du Git direkt in deiner IDE nutzen kannst – mit VS Code, LazyVim und JetBrains. Denn der beste CI/CD-Workflow bringt nichts, wenn die tägliche Arbeit mit Git umständlich ist.