Docker-Push von einem macOS-CI-Runner

Warum der Login auf einem macOS-CI-Runner an der nicht erreichbaren Keychain scheitert – und wie sich das Problem mit einer zustandslosen, jobspezifischen Docker-Konfiguration sauber lösen lässt.

/blog/docker-macos-ci-keychain-fix-authentication.webp

Uwe Rittmeyer


Wer eine CI/CD-Pipeline auf macOS betreibt, die ein Docker-Image baut und in eine selbst gehostete Registry pusht, stößt unter Umständen auf eine ärgerliche Hürde:

unauthorized: authentication required

Der Build läuft durch, das Image wird erstellt, docker login sieht sogar nach Erfolg aus – doch der docker push scheitert an der Authentifizierung. Dieser Beitrag erklärt die Ursache und stellt eine kleine, zustandslose Lösung vor, die sich direkt in eine Pipeline übernehmen lässt.

ci-runner — zsh$ docker push registry/appunauthorized: authenticationrequired$ export DOCKER_CONFIG=$(mktemp -d)$ docker login --password-stdinLogin Succeeded$ docker push registry/applatest: digest: sha256:… $

TL;DR

Auf macOS legt Docker die Registry-Zugangsdaten standardmäßig über den Credential-Helper docker-credential-osxkeychain in der Login-Keychain ab. Ein CI-Runner läuft jedoch meist in einer Umgebung ohne GUI-Session, in der diese Keychain nicht erreichbar ist. Der Login scheint zu funktionieren, der Push schlägt aber mit unauthorized: authentication required fehl. Die sauberste Lösung besteht darin, pro Job eine eigene, temporäre Docker-Konfiguration über DOCKER_CONFIG zu setzen, sich per --password-stdin anzumelden und das Verzeichnis anschließend per trap wieder zu entfernen. Damit wird die Keychain vollständig umgangen, und es bleiben keine Zugangsdaten auf dem Runner zurück.

Ursache

Unter macOS speichert Docker die Registry-Zugangsdaten standardmäßig in der Login-Keychain über einen Credential-Helper namens docker-credential-osxkeychain. Sichtbar wird das in der Datei ~/.docker/config.json:

{
  "credsStore": "osxkeychain"
}

Auf einem interaktiven Desktop funktioniert das einwandfrei. Das Problem: Ein CI-Runner läuft in der Regel nicht in einer normalen GUI-Session, sondern als LaunchDaemon, als per SSH ausgelöster Job oder unter einem Service-Account, dessen Login-Keychain nie entsperrt wird. Daher kann der Runner-Prozess die Keychain nicht erreichen.

Das führt zu einem irreführenden Fehlerbild: docker login wird erfolgreich ausgeführt, aber die Zugangsdaten werden entweder gar nicht geschrieben oder lassen sich zum Zeitpunkt des docker push nicht mehr auslesen. Docker pusht daraufhin anonym, und die Registry lehnt den Vorgang mit unauthorized: authentication required ab.

Die Lösung: eine temporäre Docker-Konfiguration pro Job

Am saubersten lässt sich das Problem lösen, indem die Keychain vollständig umgangen wird. Dazu erhält jeder Pipeline-Job ein eigenes, kurzlebiges Konfigurationsverzeichnis für Docker. Ist in diesem Verzeichnis kein credsStore definiert, schreibt Docker das Auth-Token in eine schlichte config.json innerhalb dieses Verzeichnisses – die Keychain kommt gar nicht erst ins Spiel.

# Eine frische, isolierte Docker-Konfiguration für diesen Job
export DOCKER_CONFIG="$(mktemp -d)"

# Aufräumen sicherstellen, selbst wenn ein späterer Schritt fehlschlägt
trap 'rm -rf "$DOCKER_CONFIG"' EXIT

# Login mit einem Secret aus der CI – ohne Spuren in Prozessliste und Shell-History
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin

# Wie gewohnt bauen, taggen und pushen
docker push registry.example.com/myapp:latest
  • DOCKER_CONFIG="$(mktemp -d)" verweist Docker auf ein brandneues Verzeichnis ohne credsStore-Eintrag, sodass die Zugangsdaten in der zugehörigen config.json statt in der Keychain landen. Parallele Jobs erhalten somit jeweils ihre eigene, isolierte Konfiguration.

  • trap 'rm -rf "$DOCKER_CONFIG"' EXIT stellt sicher, dass die temporären Zugangsdaten beim Beenden des Jobs entfernt werden. Auch dann, wenn der Push-Schritt fehlschlägt oder der Job vorzeitig abgebrochen wird.

    Wichtig: Auf dem Runner bleibt nichts zurück!

  • --password-stdin hält das Passwort aus der Prozessliste (ps) und der Shell-History heraus. Ein Passwort sollte in der CI niemals als einfaches -p-Argument übergeben werden.

Stolperfalle: "User interaction is not allowed."

Dahinter steckt der Security-Framework-Fehler errSecInteractionNotAllowed (-25308). Er bedeutet, dass ein Prozess auf ein Keychain-Element zugreifen wollte, für das macOS einen interaktiven Dialog anzeigen müsste – etwa zum Entsperren der Keychain. In einer Session ohne GUI lässt sich dieser Dialog nicht darstellen, weshalb der Vorgang schlicht abgelehnt wird. Es ist dieselbe Grundursache wie zuvor, nur über einen anderen Weg.

Wer ganz auf Nummer sicher gehen möchte, verzichtet auf docker login und schreibt den Auth-Eintrag direkt in die temporäre Konfiguration. Ohne docker login wird kein Credential-Helper aufgerufen – die Keychain bleibt damit garantiert außen vor:

# Eine frische, isolierte Docker-Konfiguration für diesen Job
export DOCKER_CONFIG="$(mktemp -d)"

# Aufräumen sicherstellen, selbst wenn ein späterer Schritt fehlschlägt
trap 'rm -rf "$DOCKER_CONFIG"' EXIT

# Auth-Token selbst erzeugen und direkt in die config.json schreiben
auth=$(printf '%s:%s' "${REGISTRY_USER}" "${REGISTRY_PASSWORD}" | base64)
cat > "$DOCKER_CONFIG/config.json" <<EOF
{
  "auths": {
    "registry.example.com": { "auth": "$auth" }
  }
}
EOF

# Wie gewohnt bauen, taggen und pushen
docker push registry.example.com/myapp:latest

Dieser Ansatz ist die robusteste Variante, da docker login komplett entfällt, gibt es keinen Pfad mehr, über den ein Credential-Helper (und damit die Keychain) ins Spiel kommen "könnte". Die config.json mit dem base64-kodierten Token existiert nur im temporären Verzeichnis und wird durch den trap wieder entfernt.

Hinweis zur Sicherheit: base64 ist eine reine Kodierung, keine Verschlüsselung! Der Wert lässt sich jederzeit ohne Schlüssel zurückwandeln. Die Zugangsdaten liegen in dieser config.json also faktisch im Klartext. Entscheidend ist daher, dass das temporäre Verzeichnis ausschließlich für den jeweiligen Job zugänglich ist (mktemp -d vergibt bereits restriktive Rechte) und zuverlässig wieder entfernt wird. Die eigentlichen "Geheimnisse" gehören weiterhin in den Secret-Store des CI-Systems und sollten niemals im Repository oder in Logs landen.

Warum dieser Ansatz gegenüber den Alternativen vorzuziehen ist

Es gibt zwar weitere Wege, das Problem zu lösen, jedoch bringen alle Nachteile mit sich, die sich für einen Build-Runner nicht eignen:

  • Das Entfernen von credsStore aus der globalen ~/.docker/config.json funktioniert zwar, verändert aber den gemeinsam genutzten Zustand der Maschine und hinterlässt eine base64-kodierte Zugangsinformation in einer dauerhaft vorhandenen Datei. Im Notfall vertretbar, aber unsauberer.
  • Das Entsperren einer dedizierten Build-Keychain (security create-keychain / unlock-keychain) ist auch möglich, für eine reine Registry-Authentifizierung ist es jedoch ein unnötiger Mehraufwand.

Der Ansatz mit der temporären DOCKER_CONFIG ist zustandslos, hinterlässt keine dauerhaften Zugangsdaten auf dem Runner und umgeht das Keychain-Problem vollständig. Aus diesem Grund ist er die Standardempfehlung für Build-Pipelines.

Konkrete CI-Beispiele

Im Folgenden wird die Lösung für zwei verbreitete CI-Systeme gezeigt. Entscheidend ist in beiden Fällen, dass das Setzen von DOCKER_CONFIG, der Login und der Push im selben Shell-Aufruf stattfinden – nur dann wirken die Umgebungsvariable und der trap auch für den Push-Schritt.

GitHub Actions

Das folgende Beispiel geht von einem selbst gehosteten macOS-Runner aus. Benutzername und Passwort der Registry werden als Repository- oder Organisations-Secrets hinterlegt und über env bereitgestellt:

name: Build and Push

on:
  push:
    branches: [master]

jobs:
  build-and-push:
    runs-on: [self-hosted, macOS]
    steps:
      - uses: actions/checkout@v4

      - name: Build Image
        run: docker build -t registry.example.com/myapp:latest .

      - name: Login and Push
        env:
          REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
          REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
        run: |
          export DOCKER_CONFIG="$(mktemp -d)"
          trap 'rm -rf "$DOCKER_CONFIG"' EXIT
          echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin
          docker push registry.example.com/myapp:latest

Zu beachten ist, dass jeder run-Schritt in einer eigenen Shell ausgeführt wird. Login und Push gehören deshalb in denselben Schritt – andernfalls ginge die in mktemp -d erzeugte Konfiguration zwischen den Schritten verloren.

JetBrains TeamCity

In TeamCity wird ein Build-Schritt vom Typ Command Line mit der Ausführungsart Custom Script verwendet. Die Zugangsdaten werden als Parameter hinterlegt und somit als Umgebungsvariablen bereitgestellt:

  1. Unter Parameters einen Parameter env.REGISTRY_USER (Typ Environment variable) anlegen.
  2. Einen Parameter env.REGISTRY_PASSWORD anlegen und über Edit → Type of value auf Password setzen. Dadurch wird der Wert in den Logs maskiert und nicht im Klartext gespeichert.

Im Build-Schritt genügt dann folgendes Skript:

#!/bin/bash
set -euo pipefail

# Eigene, temporäre Docker-Konfiguration für diesen Build
export DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "$DOCKER_CONFIG"' EXIT

# Login mit den als Umgebungsvariablen bereitgestellten Parametern
echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u "$REGISTRY_USER" --password-stdin

docker push registry.example.com/myapp:latest

Der Zugriff auf die Parameter erfolgt hier bewusst über echte Umgebungsvariablen ($REGISTRY_PASSWORD) statt über die TeamCity-Ersetzung %env.REGISTRY_PASSWORD%. So gelangt der geheime Wert nicht in den Skripttext selbst, was im Sinne der Vertraulichkeit vorzuziehen ist.

Fazit

Der Fehler unauthorized: authentication required auf macOS-Runnern lässt sich fast immer darauf zurückführen, dass der Keychain-Credential-Helper aus einer Session ohne GUI nicht erreichbar ist. Die Lösung: Jeder Job erhält eine eigene, temporäre DOCKER_CONFIG, der Login erfolgt per --password-stdin, und das Aufräumen übernimmt ein trap. Zustandslos, für CI-Zwecke ausreichend sicher und ohne Keychain-Probleme.

Happy pushing ⬆ 🚀️