CI/CD
.yml
GitLab · SSH · Deployment-Sicherheit · Magento
SSH-Bastion, Jump Hosts und getrennte
Deployment-User in GitLab-Prozessen

Wer Produktionsserver direkt aus GitLab-Runnern erreichbar macht, baut Angriffsfläche auf. SSH-Bastion-Server, Jump Hosts und dedizierte Deployment-User trennen Zugriffspfade sauber und machen Deployment-Verbindungen auditierbar, widerrufbar und ohne Personal-SSH-Keys umsetzbar.

12 Min. Lesezeit SSH ProxyJump · Bastion · Deploy-User · Known Hosts GitLab CI/CD · Magento 2 · Zero Downtime

1. Warum ein Bastion-Host statt direktem SSH

In vielen Magento-Projekten ist der Produktionsserver direkt über eine öffentliche IP per SSH erreichbar. Der GitLab-Runner verbindet sich mit einem hinterlegten SSH-Key direkt auf Port 22 und führt Deploy-Skripte aus. Das funktioniert – bis ein Runner kompromittiert wird, ein Schlüssel verloren geht oder ein Team-Mitglied das Projekt verlässt. Ohne Zwischenschicht gibt es keine Möglichkeit, den Zugriff gezielt zu unterbrechen, ohne den gesamten SSH-Key-Satz des Servers zu rotieren.

Ein Bastion-Host ist ein dedizierter Einstiegspunkt ins interne Netz. Der Produktionsserver ist nicht mehr direkt aus dem Internet erreichbar – nur der Bastion-Host ist exponiert, und ausschließlich über ihn führt der Weg weiter. GitLab-Runner verbinden sich zuerst mit dem Bastion und springen von dort per ProxyJump auf den Zielserver. Das reduziert die Angriffsfläche, ermöglicht zentrales Logging aller SSH-Verbindungen und macht Zugriffspfade auditierbar.

Getrennte Deployment-User – also eigene Linux-Accounts ohne sudo-Rechte und mit eng definierten authorized_keys-Einschränkungen – sorgen dafür, dass ein kompromittierter Deployment-Key keinen Root-Zugriff erlaubt. Diese drei Komponenten zusammen bilden eine vertretbare Sicherheitsarchitektur für automatisierte Magento-Deployments über GitLab.

2. Architektur: Bastion, Jump Host und Zielsystem

Die typische Netzwerktopologie für gesicherte GitLab-Deployments besteht aus drei Schichten. Der GitLab-Runner läuft entweder als Shared Runner auf gitlab.com oder als self-hosted Runner in einem separaten Netz. Er hat ausschließlich Zugriff auf den Bastion-Host, der in einer DMZ sitzt und nur Port 22 nach außen öffnet. Der eigentliche Zielserver – der Webserver mit dem Magento-Deployment-Pfad – ist nur aus dem internen Netz erreichbar und hat keine direkte Verbindung ins Internet.

Der Jump Host ist in vielen Setups identisch mit dem Bastion-Host. Der Unterschied ist terminologisch: Ein Bastion ist der erste exponierte Punkt, ein Jump Host ist die Weiterleitungsinstanz. In der Praxis übernimmt ein Server beide Rollen. Der GitLab-Runner verwendet SSH ProxyJump, um in einem einzigen SSH-Kommando durch den Bastion auf den Zielserver zu verbinden – ohne dass der Bastion ein separates Login erfordert. Das reduziert die Komplexität bei gleichbleibender Sicherheit.

3. Getrennte Deployment-User anlegen und absichern

Ein Deployment-User ist ein dedizierter Linux-Account, der ausschließlich für automatisierte Deploy-Prozesse existiert. Er hat kein Passwort, keine sudo-Rechte und keinen interaktiven Shell-Zugang, der über das Notwendige hinausgeht. Auf dem Zielserver legt man ihn mit adduser --disabled-password deploy an. In der ~/.ssh/authorized_keys dieses Users werden ausschließlich GitLab-spezifische Deployment-Keys hinterlegt – keine persönlichen Entwickler-Keys.

Für zusätzliche Sicherheit kann jeder authorized_keys-Eintrag mit einer command=-Einschränkung versehen werden: Der SSH-Key darf dann nur ein bestimmtes Skript ausführen, nicht beliebige Befehle. Das ist für einfache Deployments manchmal zu restriktiv, für hochsichere Umgebungen aber der richtige Ansatz. Auf dem Bastion-Host legt man ebenfalls einen eigenen User an, durch den der Runner springt – der Bastion-User selbst braucht keine Schreibrechte auf dem Zielserver.

stages:
  - build
  - deploy
  - verify

variables:
  GIT_STRATEGY: fetch
  # SSH agent is initialized in before_script
  SSH_OPTS: "-o StrictHostKeyChecking=yes -o BatchMode=yes"

.ssh_setup: &ssh_setup
  before_script:
    # Load private key from GitLab CI variable (file type)
    - eval $(ssh-agent -s)
    - chmod 600 "$SSH_PRIVATE_KEY"
    - ssh-add "$SSH_PRIVATE_KEY"
    # Write known_hosts for bastion and target
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS_BASTION" >> ~/.ssh/known_hosts
    - echo "$SSH_KNOWN_HOSTS_TARGET" >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    # Configure ProxyJump transparently for all SSH calls
    - |
      cat >> ~/.ssh/config <<EOF
      Host $DEPLOY_HOST
        ProxyJump $BASTION_USER@$BASTION_HOST
        User $DEPLOY_USER
        IdentityFile $SSH_PRIVATE_KEY
        StrictHostKeyChecking yes
      EOF
    - chmod 600 ~/.ssh/config

deploy:production:
  stage: deploy
  <<: *ssh_setup
  script:
    - ssh $SSH_OPTS $DEPLOY_USER@$DEPLOY_HOST "bash -s" < scripts/deploy.sh
  environment:
    name: production
  only:
    - tags

4. ProxyJump in GitLab CI/CD konfigurieren

Die ProxyJump-Direktive in ~/.ssh/config ersetzt den älteren ProxyCommand-Ansatz mit ssh -W. Beide lösen dasselbe Problem – der Unterschied liegt in Lesbarkeit und Fehlertoleranz. ProxyJump ist seit OpenSSH 7.3 verfügbar und auf allen modernen Linux-Systemen vorhanden. Die Direktive teilt dem SSH-Client mit: Verbinde zuerst mit dem Bastion-Host, und nutze diese Verbindung als Tunnel für den eigentlichen Zielserver. Aus Sicht der Anwendungsschicht sieht es aus wie ein direktes SSH-Kommando.

In GitLab-Pipelines schreibt man die ~/.ssh/config im before_script dynamisch, weil Runner-Umgebungen nach jedem Job zurückgesetzt werden. Die relevanten Werte – Bastion-Hostname, Bastion-User, Deploy-Hostname und Deploy-User – kommen aus GitLab CI/CD-Variablen. So enthält die .gitlab-ci.yml keine hardkodierten Hostnamen und kann für Staging und Production dieselbe Konfiguration verwenden, getrennt durch Environment-Scopes.

5. Known Hosts für Bastion und Zielserver verwalten

Das StrictHostKeyChecking-Flag auf yes zu setzen ist Pflicht in automatisierten Deployments. Wer es auf no setzt, um Verbindungsprobleme zu umgehen, öffnet die Tür für Man-in-the-Middle-Angriffe. Stattdessen hinterlegt man die SSH-Fingerprints beider Server – Bastion und Zielserver – als GitLab CI/CD-Variablen und schreibt sie im before_script in die ~/.ssh/known_hosts des Runners.

Die Fingerprints erhält man auf dem Server mit ssh-keyscan -H hostname. Der Output enthält eine oder mehrere Zeilen im known_hosts-Format und wird direkt als Variable SSH_KNOWN_HOSTS_BASTION bzw. SSH_KNOWN_HOSTS_TARGET in GitLab hinterlegt. Bei Server-Migrationen oder Key-Rotationen müssen diese Variablen aktiv aktualisiert werden – sonst schlägt die Pipeline mit einem Host-Verification-Fehler fehl, was das gewünschte Verhalten ist.

6. GitLab-Variablen und SSH-Agent im Runner

Für SSH-Bastion-Setups braucht die Pipeline folgende Variablen: SSH_PRIVATE_KEY als File-Variable (nicht masked string), SSH_KNOWN_HOSTS_BASTION, SSH_KNOWN_HOSTS_TARGET, BASTION_HOST, BASTION_USER, DEPLOY_HOST, DEPLOY_USER und DEPLOY_PATH. Der SSH-Private-Key muss als File-Variable hinterlegt werden, weil der ssh-add-Befehl einen Dateipfad erwartet, keine Stringvariable. GitLab schreibt File-Variablen in eine temporäre Datei und übergibt den Pfad als Umgebungsvariable.

Der SSH-Agent im Runner muss für jeden Job neu gestartet werden – er überlebt den Job-Wechsel nicht. Das eval $(ssh-agent -s) im before_script startet einen neuen Agent, und ssh-add lädt den Key. Wichtig: Der Agent-Prozess stirbt mit dem Job-Container, sodass der Key nicht in andere Jobs oder Runner überläuft. In Docker-Executors ist der Aufräumprozess automatisch; bei Shell-Executors empfiehlt sich trap "ssh-agent -k" EXIT als Sicherheitsnetz.

# Required CI/CD variables (set in GitLab project settings):
# SSH_PRIVATE_KEY     — Type: File, deploy key without passphrase
# SSH_KNOWN_HOSTS_BASTION — bastion host fingerprint (ssh-keyscan output)
# SSH_KNOWN_HOSTS_TARGET  — target server fingerprint
# BASTION_HOST        — hostname or IP of the bastion server
# BASTION_USER        — SSH user on the bastion (e.g. jump)
# DEPLOY_HOST         — internal hostname of the target server
# DEPLOY_USER         — deployment-only user on the target server
# DEPLOY_PATH         — absolute base path, e.g. /var/www/magento

deploy:staging:
  stage: deploy
  before_script:
    - eval $(ssh-agent -s)
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - printf '%s\n' "$SSH_KNOWN_HOSTS_BASTION" "$SSH_KNOWN_HOSTS_TARGET" \
        >> ~/.ssh/known_hosts
    - |
      cat > ~/.ssh/config <<EOF
      Host $DEPLOY_HOST
        ProxyJump ${BASTION_USER}@${BASTION_HOST}
        User $DEPLOY_USER
        StrictHostKeyChecking yes
        BatchMode yes
      EOF
    - chmod 600 ~/.ssh/config ~/.ssh/known_hosts
  script:
    - ssh "$DEPLOY_USER@$DEPLOY_HOST" "echo 'Connection via bastion: OK'"
    - scp scripts/deploy.sh "$DEPLOY_USER@$DEPLOY_HOST:/tmp/deploy_$CI_JOB_ID.sh"
    - ssh "$DEPLOY_USER@$DEPLOY_HOST" "bash /tmp/deploy_$CI_JOB_ID.sh && rm -f /tmp/deploy_$CI_JOB_ID.sh"
  environment:
    name: staging
    url: https://staging.mironsoft.de
  only:
    - main

7. Direkter SSH vs. Bastion-Routing im Vergleich

Der Vergleich zwischen direktem SSH-Zugriff und Bastion-Routing zeigt, wo die wesentlichen Unterschiede in Sicherheit und Wartbarkeit liegen. Beide Ansätze ermöglichen automatisierte Deployments, aber mit grundlegend unterschiedlichen Risikoprofilen.

Kriterium Direkter SSH Bastion + Jump Host Bewertung
Angriffsfläche Produktionsserver direkt exponiert Nur Bastion exponiert Bastion klar besser
Key-Widerruf Alle Server einzeln aktualisieren Bastion-Eintrag löschen genügt Bastion effizienter
Audit-Logging Nur auf Zielserver Zentrales Log auf Bastion Bastion auditierbar
Komplexität Einfaches Setup Höherer initialer Aufwand Direkter SSH einfacher initial
User-Trennung Gleicher User für alle Zwecke Dedizierter Deploy-User pro Schicht Bastion sauberer

Der initiale Mehraufwand für ein Bastion-Setup amortisiert sich schnell: Wenn ein Runner-Key rotiert werden muss, genügt eine einzige Änderung auf dem Bastion statt auf jedem Zielserver. Bei wachsenden Infrastrukturen mit mehreren Staging- und Produktionsservern ist das ein erheblicher operativer Vorteil.

8. Typische Fehlerbilder und Diagnose

Das häufigste Problem bei Bastion-Setups ist die known_hosts-Verwaltung. Wenn der Bastion-Host oder der Zielserver eine neue Host-Key-Version hat – nach einer Migration, einem OS-Upgrade oder einem absichtlichen Key-Wechsel – schlägt StrictHostKeyChecking=yes fehl. Die Fehlermeldung ist eindeutig: Host key verification failed. Die Lösung ist das Aktualisieren der GitLab-Variablen SSH_KNOWN_HOSTS_BASTION oder SSH_KNOWN_HOSTS_TARGET. Wer die Variable nicht findet, hat meist vergessen, sie mit Environment-Scope anzulegen.

Ein zweites häufiges Problem: Der SSH-Agent ist im Runner nicht aktiv, weil eval $(ssh-agent -s) fehlt oder der Key mit ssh-add nicht geladen wurde. Das äußert sich als Permission denied (publickey), obwohl die Variable korrekt gesetzt ist. Die Diagnose: Im Pipeline-Log prüfen, ob ssh-add -l vor dem Deploy-Schritt einen Key listet. Ein dritter Fehlerfall betrifft ProxyJump-Konfigurationsfehler in der ~/.ssh/config: Tippfehler beim Hostnamen oder fehlende Leerzeichen nach den Direktiven führen zu kryptischen Verbindungsfehlern.

9. Rollback über Bastion-Verbindung

Rollback-Jobs in einer GitLab-Pipeline nutzen dieselbe SSH-Konfiguration wie Deploy-Jobs. Der einzige Unterschied: statt des Deploy-Skripts wird ein Rollback-Skript aufgerufen, das den current-Symlink auf das vorherige Release-Verzeichnis setzt. Da die Verbindungskette über den Bastion führt, muss der Rollback-Job denselben before_script-Block mit SSH-Agent und ProxyJump-Konfiguration enthalten.

Ein bewährtes Muster ist ein separater rollback-Stage mit when: manual in GitLab. Der Job erscheint in der Pipeline-Ansicht und kann mit einem Klick ausgelöst werden. Als Parameter übergibt man das Rollback-Ziel-Release entweder als hartkodierten Wert oder als Variable. Wer Rollback regelmäßig in der Staging-Umgebung testet, weiß im Produktionsfall, dass der Weg funktioniert – das ist die wichtigste Voraussetzung für echtes Zero-Downtime-Deployment.

rollback:production:
  stage: rollback
  when: manual
  allow_failure: false
  before_script:
    # Same SSH setup as deploy job — bastion jump must work for rollback too
    - eval $(ssh-agent -s)
    - ssh-add "$SSH_PRIVATE_KEY"
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - printf '%s\n' "$SSH_KNOWN_HOSTS_BASTION" "$SSH_KNOWN_HOSTS_TARGET" \
        >> ~/.ssh/known_hosts
    - |
      cat > ~/.ssh/config <<EOF
      Host $DEPLOY_HOST
        ProxyJump ${BASTION_USER}@${BASTION_HOST}
        User $DEPLOY_USER
        StrictHostKeyChecking yes
        BatchMode yes
      EOF
    - chmod 600 ~/.ssh/config ~/.ssh/known_hosts
  script:
    # Determine previous release and switch symlink atomically
    - |
      ssh "$DEPLOY_USER@$DEPLOY_HOST" bash -s <<'REMOTE'
      set -euo pipefail
      cd "$DEPLOY_PATH"
      # List releases sorted newest-first, pick second (previous) entry
      PREV=$(ls -1dt releases/*/ | sed -n '2p' | tr -d '/')
      if [[ -z "$PREV" ]]; then
        echo "[ERROR] No previous release found for rollback" >&2
        exit 1
      fi
      ln -sfn "$DEPLOY_PATH/$PREV" "$DEPLOY_PATH/current"
      cd "$DEPLOY_PATH/current"
      bin/magento cache:flush
      echo "[OK] Rolled back to $PREV"
      REMOTE
  environment:
    name: production
  only:
    - tags

10. Zusammenfassung

SSH-Bastion-Server und Jump Hosts sind kein Luxus für große Infrastrukturen, sondern eine vertretbare Sicherheitsmaßnahme ab dem Moment, in dem automatisierte Deployments auf Produktionssysteme zugreifen. Die Kombination aus Bastion als einzigem exponierten Einstiegspunkt, ProxyJump für transparentes SSH-Routing und dedizierten Deployment-Usern ohne sudo-Rechte schafft eine klare, auditierbare Verbindungsarchitektur. GitLab CI/CD lässt sich mit wenigen before_script-Zeilen für dieses Modell konfigurieren, ohne die Pipeline-Struktur zu verkomplizieren.

Der operativ wichtigste Schritt ist das regelmäßige Testen des gesamten Pfades – nicht nur von Staging nach Staging, sondern den kompletten Bastion-Jump-Deploy-Verify-Rollback-Zyklus auf einer produktionsähnlichen Umgebung. Wer das tut, kennt die Verbindungskette und kann im Störfall sicher handeln, statt unter Druck improvisionieren zu müssen.

SSH-Bastion und Jump Hosts in GitLab — Das Wichtigste auf einen Blick

Architektur

Bastion als einziger exponierter Punkt, ProxyJump für transparentes Routing, Produktionsserver nicht direkt erreichbar.

Deployment-User

Dedizierter Linux-Account ohne sudo, authorized_keys nur mit Deploy-Keys, keine persönlichen Entwickler-Schlüssel.

GitLab-Konfiguration

SSH_PRIVATE_KEY als File-Variable, Known Hosts beider Server als Variable, ~/.ssh/config dynamisch im before_script schreiben.

Rollback-Fähigkeit

Rollback-Job nutzt denselben SSH-Setup-Block – über denselben Bastion-Pfad, mit manuellem Trigger in GitLab-UI.

11. FAQ: SSH-Bastion und Jump Hosts in GitLab

1Bastion-Host vs. Jump Host – was ist der Unterschied?
Terminologischer Unterschied: Bastion betont die Sicherheitsrolle als einziger exponierter Einstiegspunkt, Jump Host betont die SSH-ProxyJump-Weiterleitungsfunktion. In der Praxis übernimmt ein Server beide Rollen.
2ProxyJump oder ProxyCommand – welches nutzen?
ProxyJump ist der moderne Nachfolger, seit OpenSSH 7.3 verfügbar, lesbarer und fehlertoleranter. ProxyCommand mit ssh -W ist der ältere Ansatz. Für aktuelle Systeme immer ProxyJump bevorzugen.
3Warum SSH_PRIVATE_KEY als File-Variable?
ssh-add erwartet einen Dateipfad. GitLab schreibt File-Variablen als temporäre Datei und übergibt den Pfad – genau was ssh-add braucht. Als normale Variable würde der Inhalt als Pfad interpretiert und fehlschlagen.
4Was passiert bei Bastion-Ausfall?
Deployments schlagen fehl – das ist korrekt. Der Produktionsserver bleibt unerreichbar statt exponiert. Bastion sollte hochverfügbar gebaut werden, z. B. als Autoscaling-Gruppe mit Failover-IP.
5Deploy-Key ohne Downtime rotieren?
Neuen Key generieren, als zweiten Eintrag in authorized_keys eintragen, GitLab-Variable aktualisieren, Pipeline testen, alten Key entfernen. Beide Keys sind kurzzeitig aktiv.
6Braucht der Bastion-User Rechte auf dem Zielserver?
Nein. Der Bastion-User ist nur für Routing zuständig. Eine minimale Shell oder /bin/false mit authorize_keys-restrict reicht, wenn ProxyJump korrekt konfiguriert ist.
7Wie Bastion-Verbindungen für Audits loggen?
SSH schreibt in /var/log/auth.log oder journald. Logs mit Loki oder Elasticsearch aggregieren. CI_JOB_ID als Umgebungsvariable mitschicken, um Pipeline-Läufe im Log korrelieren zu können.
8Mehrere Zielserver über denselben Bastion?
Ja. Für jeden Zielserver einen eigenen Host-Block mit ProxyJump auf denselben Bastion. Der Bastion muss AllowTcpForwarding oder PermitOpen für die entsprechenden Ziele erlauben.
9StrictHostKeyChecking=no ist trotzdem fehlerhaft – warum?
StrictHostKeyChecking=no deaktiviert die Prüfung und sollte nie eingesetzt werden. Fehler liegen woanders: Agent nicht gestartet, Key nicht geladen, Firewall blockiert oder falscher Hostname. Diagnose mit ssh -v statt Option deaktivieren.
10Bastion-Verbindung ohne vollständigen Pipeline-Lauf testen?
Dedizierten Test-Job anlegen: stage: verify, script: ssh $DEPLOY_USER@$DEPLOY_HOST 'echo connection OK'. Schlägt fehl, wenn die SSH-Konfiguration defekt ist – ohne Deploy-Skripte auszuführen.