Eintrag

GitLab CI/CD: Pipelines, Runner und Automatisierung

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.yml aufgebaut 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.yml genau 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.sock erlaubt 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 = true ist nötig, wenn du Docker-Images innerhalb von CI-Jobs bauen willst (z.B. mit docker build). Für reine Test-Jobs reicht false.

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: manual wird 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:

  1. Gehe zu Settings > CI/CD > Variables
  2. Klicke auf Add Variable
  3. 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.yml hartcodieren – 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 mit git filter-branch oder 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üher
  • rules: statt only/except – präzisere Kontrolle, weniger unnötige Jobs
  • Cache intelligent nutzenpull-push Policy für den Haupt-Branch, pull für Feature-Branches
  • Kleine Imagesalpine-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: true werden 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.yml ist deine Single Source of Truth für den gesamten Build-Prozess
  • Artifacts für Job-zu-Job-Daten, Cache für Build-Beschleunigung
  • rules: statt only/except fü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.

Dieser Eintrag ist vom Autor unter CC BY 4.0 lizensiert.