Eine GitLab-CI/CD-Pipeline, die für die meisten kleinen Projekte reicht
Wenn ich ein kleines Projekt übernehme, das noch keine CI hat, läuft Deployment meistens so: jemand loggt sich per SSH ein, zieht den aktuellsten Commit und startet einen Dienst neu. Das funktioniert, bis es nicht mehr funktioniert, ein vergessenes npm install, ein Deploy vom falschen Branch, eine fehlende Umgebungsvariable. Die Lösung ist keine komplizierte Pipeline mit einem Dutzend Stages. Es ist eine kurze .gitlab-ci.yml, die jedes Mal dasselbe macht: bauen, testen, deployen.
Die Grundstruktur
stages:
- build
- test
- deploy
variables:
GIT_DEPTH: 1
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
build:
stage: build
image: node:20
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
test:
stage: test
image: node:20
script:
- npm ci
- npm run test -- --ci
GIT_DEPTH: 1 hält Checkouts bei Repos mit langer Historie schnell, und das Caching von node_modules pro Branch sorgt dafür, dass die meisten Pipelines eine komplette Neuinstallation überspringen. Die Build-Stage erzeugt ein Artefakt, das die Deploy-Stage wiederverwendet, der Code wird also pro Pipeline-Lauf nur einmal gebaut, nicht einmal pro Stage.
Deployment über SSH
Für kleine Projekte ist ein Kubernetes-Manifest oder eine Container-Registry oft mehr Infrastruktur, als das Projekt braucht. Schlichtes rsync über SSH, ausgelöst aus der CI, deckt überraschend viele reale Deployments ab:
deploy_staging:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client rsync
- eval $(ssh-agent -s)
- echo "$STAGING_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan -H "$STAGING_HOST" >> ~/.ssh/known_hosts
script:
- rsync -az --delete dist/ deploy@$STAGING_HOST:/var/www/staging/
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
Der SSH-Key liegt als maskierte, geschützte CI/CD-Variable, niemals im Repo. ssh-keyscan füllt known_hosts, damit der Job nicht an einem Host-Key-Prompt hängen bleibt, und rsync --delete hält das Remote-Verzeichnis synchron mit dem Build-Output, statt nach und nach veraltete Dateien anzusammeln.
Production bekommt einen manuellen Gate
Staging deployt bei jedem Push auf develop. Production deployt von main, aber nur, wenn jemand den Button klickt:
deploy_production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client rsync
- eval $(ssh-agent -s)
- echo "$PRODUCTION_SSH_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- ssh-keyscan -H "$PRODUCTION_HOST" >> ~/.ssh/known_hosts
script:
- rsync -az --delete dist/ deploy@$PRODUCTION_HOST:/var/www/production/
environment:
name: production
url: https://example.com
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
when: manual macht aus dem Job einen Button in GitLabs Pipeline-Ansicht statt etwas, das automatisch losläuft. Zusammen mit GitLabs Environments bekommt man dadurch auch eine Deploy-Historie mit Rollback per Klick auf ein vorheriges Artefakt, was bei mir mehr als einmal aus einem schiefgelaufenen Deploy eine Sache von dreißig Sekunden gemacht hat, statt Panik.
Warum das meistens reicht
Diese Pipeline führt keinen Linter aus, baut kein Docker-Image, spricht nicht mit Kubernetes. Für viele kleine Projekte ist das in Ordnung, das kann später ergänzt werden, falls das Projekt in eine Größe wächst, die es braucht. Was diese Pipeline tut, ist die drei Stellen zu entschärfen, an denen manuelle Deploys typischerweise schiefgehen: jemand vergisst, Abhängigkeiten zu installieren, jemand deployt ungetesteten Code, oder jemand deployt den falschen Branch. Eine .gitlab-ci.yml dieser Größe fängt alle drei ab, und sie ist kurz genug, dass die nächste Person, die das Projekt anfasst, sie in unter einer Minute komplett lesen kann.