

Repo, CI und Container-Registry vom ersten Tag an besitzen: eine Gründer-Checkliste für ausgelagerte Entwicklung
Die meisten ausgelagerten Projekte scheitern nicht am Code. Sie scheitern an dem Tag, an dem der Dienstleister nicht mehr antwortet.
Ich führe eine kleine Software-Agentur, und ein guter Teil unserer Arbeit bestand über die Jahre darin, Produkte aufzusammeln, die ein anderes Team zurückgelassen hat. Das Muster wiederholt sich mit langweiliger Regelmäßigkeit. Der Code liegt in einem Repository, das der Gründer lesen kann, aber das Image wird aus einer Basis gebaut, die in der privaten Registry des alten Dienstleisters liegt. Die CI läuft unter dem persönlichen Konto von irgendjemandem. Die Produktion zieht :latest aus einem Namespace, in dem sich auf Kundenseite niemand anmelden kann. Alles funktioniert, bis es nicht mehr funktioniert — und dann kann niemand mehr etwas neu bauen.
Dieser Artikel ist die Checkliste, die ich Gründern heute vor dem ersten Commit in die Hand gebe. Das ist keine Theorie. Jeder Befehl und jede Ausgabe weiter unten wurde auf einer gewöhnlichen Debian-13-Maschine mit Docker 26.1 ausgeführt (die genaue Umgebung steht am Ende), gegen ein kleines Demo-Repository. Sie können alles davon in etwa zehn Minuten nachvollziehen.
Die Regel: Ein Fremder mit Ihren Zugangsdaten kann ausliefern
Der Test, den ich verwende, ist einfach. Geben Sie einer kompetenten Entwicklerin oder einem kompetenten Entwickler, die oder der den Dienstleister nie getroffen hat:
- Lesezugriff auf das Repository,
- Schreibzugriff auf Ihre Container-Registry,
- sonst nichts.
Können sie den genau in Produktion laufenden Dienst klonen, bauen, starten und veröffentlichen? Lautet die Antwort «dafür müssten sie die Agentur um etwas bitten», dann gehört Ihnen das Produkt noch nicht. Alles Folgende existiert, um aus dieser Antwort ein Ja zu machen — und um es mit einem Skript zu beweisen statt mit einem Versprechen.
1. Das Repository liegt in Ihrer Organisation
Das klingt selbstverständlich. Es ist zugleich der Punkt, den ich am häufigsten verletzt sehe.
Das Repository muss unter einer Organisation liegen, die das Unternehmen kontrolliert, mit mindestens zwei Personen aus dem Unternehmen als Eigentümer. Die Entwickler des Dienstleisters werden als Mitglieder oder externe Mitarbeiter eingeladen. Sie legen das Repository nicht in ihrer eigenen Organisation an, um es «später zu übertragen». Später kommt nie — oder es kommt mitten in einem Streit.
Legen Sie vom ersten Tag an eine CODEOWNERS-Datei an, damit die Eigentümerschaft im Repository steht und nicht in einem Vertragsanhang:
# Client-side engineers own every path. The vendor team is added as reviewers, not owners.* @acme-corp/platform-owners/.github/ @acme-corp/platform-owners/Dockerfile @acme-corp/platform-ownersCODEOWNERS greift nur, wenn der Branch-Schutz die Review durch den Code Owner verlangt — schalten Sie das also auch für main ein. Wenn Ihr Git-Host GitHub ist, können Sie mit der CLI prüfen, wem die Dinge tatsächlich gehören (führen Sie das gegen Ihre eigene Organisation aus; die Ausgabe hängt von Ihrem Konto ab):
gh api repos/acme-corp/handoff-demo --jq '.owner.login, .owner.type'gh api "orgs/acme-corp/members?role=admin" --jq '.[].login'Der erste Befehl soll den Namen Ihrer Organisation und Organization ausgeben, der zweite soll Personen auf Ihrer Gehaltsliste auflisten.
2. Basis-Images per Digest festnageln, nicht per Tag
Tags wandern. python:3.12-slim heute und python:3.12-slim in acht Monaten sind unterschiedliche Bytes. Für ein Wochenendprojekt ist das in Ordnung, für eine Übergabe ist es furchtbar: Das neue Team kann nicht unterscheiden, ob ein Fehler ihm gehört oder dem Basis-Image.
Nageln Sie den Digest fest und hinterlassen Sie einen Kommentar, der erklärt, warum:
# Base image pinned by digest: a rebuild next year uses the same bytes.FROM python:3.12-slim@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6fSo finden Sie den Digest dessen, was Sie bereits geladen haben:
docker images --digests pythonREPOSITORY TAG DIGEST IMAGE ID CREATED SIZEpython 3.12-slim sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f df18428df8a0 17 hours ago 119MBDie Basis zu aktualisieren wird damit zu einem geprüften Einzeiler-Pull-Request statt zu einem unsichtbaren Nebeneffekt des Neubauens. Werkzeuge wie Dependabot oder Renovate können diese PRs für Sie öffnen.
3. Keine Basis-Images aus der privaten Registry des Dienstleisters
Dieser Punkt verursacht den größten Schmerz, deshalb hier, wie es aussieht, wenn es schiefgeht. Ein typisches Dockerfile «aus der Dienstleister-Ära»:
FROM ghcr.io/example-vendor-agency/python-base:latestCOPY app/ ./app/CMD ["python", "app/server.py"]Auf den Laptops des Dienstleisters baut das, weil sie angemeldet sind. Auf einer sauberen Maschine ohne ihre Zugangsdaten ist das die echte Ausgabe (auf die relevanten Zeilen gekürzt):
$ docker build -t acme/handoff-demo:vendor-era .#2 [internal] load metadata for ghcr.io/example-vendor-agency/python-base:latest#2 ERROR: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbidden------Dockerfile:1-------------------- 1 | >>> FROM ghcr.io/example-vendor-agency/python-base:latest 2 | COPY app/ ./app/ 3 | CMD ["python", "app/server.py"]--------------------ERROR: failed to solve: ghcr.io/example-vendor-agency/python-base:latest: failed to resolve source metadata for ghcr.io/example-vendor-agency/python-base:latest: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbiddenexit code: 1(example-vendor-agency ist ein Platzhalter-Namespace, der 403 oben ist also echt — es ist aber derselbe Fehler, den Sie bei einem privaten Namespace ohne Zugriff bekämen.)
Wenn der Dienstleister ein gehärtetes Basis-Image hat, das er mag: gerne. Es wird in Ihre Registry kopiert, sein Dockerfile kommt in Ihr Repository, und Ihre Images bauen darauf auf.
4. Jedes Image sagt, woher es kommt
Wenn um zwei Uhr nachts etwas kaputtgeht, lautet die erste Frage: «Welcher Commit läuft gerade?» OCI-Labels beantworten sie, ohne dass jemand den Chatverlauf durchsuchen muss:
ARG GIT_SHA=unknownARG SOURCE_URL=unknown
LABEL org.opencontainers.image.source=$SOURCE_URL \ org.opencontainers.image.revision=$GIT_SHA \ org.opencontainers.image.title="handoff-demo"Dann kann jede Person mit dem Image Folgendes tun:
docker image inspect localhost:5000/acme/handoff-demo:203d8f1 \ --format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'file:///tmp/client-git/acme/handoff-demo.git @ 203d8f1In einer echten CI ist die Quelle die URL Ihres Repositorys und die Revision der vollständige Commit-SHA. Auch GHCR nutzt org.opencontainers.image.source, um ein Paket mit seinem Repository zu verknüpfen.
Wenn Sie schon im Dockerfile sind: Lassen Sie den Prozess als Nicht-Root-Benutzer laufen. Das kostet zwei Zeilen und ist ein Befund weniger in der ersten Sicherheitsprüfung nach der Übergabe:
RUN useradd --system --uid 10001 --no-create-home appUSER 100015. Die Registry liegt in Ihrem Konto
Die Produktion sollte Images aus einer Registry ziehen, die dem Unternehmen gehört: GHCR unter Ihrer Organisation, ECR in Ihrem AWS-Konto, Artifact Registry in Ihrem GCP-Projekt oder eine selbst gehostete Registry. Die CI des Dienstleisters schreibt dorthin mit einem eng geschnittenen Zugangsdatum, das Sie ausgestellt haben und widerrufen können.
Für die Demo habe ich das offizielle registry:2-Image als Platzhalter für «die Registry des Kunden» verwendet:
$ docker run -d --name client-registry -p 5000:5000 registry:2be33ebd056ea7ac90d49ebd0a348e568c62fbc51739ad42ee1dd159665b591b5Nach einem Push zeigt die Registry-API genau das, was Ihnen gehört:
$ curl -s http://localhost:5000/v2/_catalog{"repositories":["acme/handoff-demo"]}$ curl -s http://localhost:5000/v2/acme/handoff-demo/tags/list{"name":"acme/handoff-demo","tags":["12d12c4","203d8f1"]}Beachten Sie, dass die Tags Commit-SHAs sind und nicht latest. Deployen Sie per Digest oder per SHA-Tag — dann ist ein Rollback eine Änderung von einem Wort.
6. CI ist eine Datei im Repository und läuft unter der Organisation
Die CI-Konfiguration gehört ins Repository, und die Pipeline läuft unter dem Konto der Organisation, nicht unter dem persönlichen Konto einer Entwicklerin des Dienstleisters. Geheimnisse liegen im Secret Store der Organisation. Hier ist der Workflow aus dem Demo-Repository, für GitHub Actions:
name: build-and-push
on: push: branches: [main] pull_request:
permissions: contents: read packages: write
jobs: image: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4
- name: Log in to the client's registry if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build (and push on main) uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name == 'push' }} build-args: | GIT_SHA=${{ github.sha }} SOURCE_URL=${{ github.server_url }}/${{ github.repository }} tags: ghcr.io/${{ github.repository }}:${{ github.sha }}Zwei Details sind für die Eigentümerschaft entscheidend. Der Workflow nutzt das eingebaute GITHUB_TOKEN, es gibt also kein langlebiges persönliches Token zu erben. Und er pusht nach ghcr.io/${{ github.repository }}, das Image landet also bei dem, dem das Repository gehört — bei Ihnen.
Bevor ich einem Workflow traue, lasse ich einen Linter darüber laufen:
$ actionlint .github/workflows/build.yml && echo "actionlint: no issues"actionlint: no issues7. Beweisen Sie es: der Übergabe-Check
Checklisten verrotten. Ein Skript nicht, solange Sie es ausführen. Das ist die Fassung, die ich für kleine Dienste verwende. Sie klont in ein temporäres Verzeichnis, richtet Docker auf eine leere Konfiguration aus, damit keine gespeicherte Anmeldung helfen kann, und baut, startet, prüft, inspiziert und schickt das Image einmal im Kreis durch Ihre Registry.
#!/usr/bin/env bash# handoff-check.sh: can someone who is NOT the vendor rebuild, run and ship this repo?# Usage: ./handoff-check.sh <git-url-or-path> <registry/namespace>set -euo pipefail
REPO="$1"REGISTRY="$2"WORK="$(mktemp -d)"export DOCKER_CONFIG="$WORK/docker-config" # empty config: no saved vendor loginsmkdir -p "$DOCKER_CONFIG"
echo "== 1. Fresh clone"git clone --quiet "$REPO" "$WORK/src"cd "$WORK/src"SHA="$(git rev-parse --short HEAD)"echo "HEAD is $SHA"
echo "== 2. Secrets that should never be in git"if git ls-files | grep -E '(\.env$|\.pem$|\.key$|id_rsa)'; then echo "FAIL: secret-looking files are tracked"; exit 1else echo "OK: no tracked .env/.pem/.key files"fi
echo "== 3. Unpinned base images"if grep -E '^FROM ' Dockerfile | grep -v '@sha256:'; then echo "WARN: base image not pinned by digest"else echo "OK: every FROM is pinned by digest"fi
echo "== 4. Build with no registry credentials"docker build --quiet \ --build-arg GIT_SHA="$SHA" \ --build-arg SOURCE_URL="$REPO" \ -t "$REGISTRY/handoff-demo:$SHA" .
echo "== 5. Run and probe"CID="$(docker run -d -p 18080:8080 "$REGISTRY/handoff-demo:$SHA")"for i in $(seq 1 20); do curl -fsS http://127.0.0.1:18080/healthz 2>/dev/null && break sleep 0.5doneechoecho "runs as uid: $(docker exec "$CID" id -u)"docker rm -f "$CID" >/dev/null
echo "== 6. Provenance labels"docker image inspect "$REGISTRY/handoff-demo:$SHA" \ --format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'
echo "== 7. Push to the client's registry and pull back by digest"docker push --quiet "$REGISTRY/handoff-demo:$SHA"DIGEST="$(docker image inspect "$REGISTRY/handoff-demo:$SHA" --format '{{ index .RepoDigests 0 }}')"docker rmi --force "$REGISTRY/handoff-demo:$SHA" >/dev/nulldocker pull --quiet "$DIGEST"echo "pulled $DIGEST"
rm -rf "$WORK"echo "== handoff-check passed"Ausgeführt gegen das Demo-Repository (ein Bare-Repository als Platzhalter für den Git-Host des Kunden) und die lokale Registry:
$ ./handoff-check.sh file:///tmp/client-git/acme/handoff-demo.git localhost:5000/acme== 1. Fresh cloneHEAD is 203d8f1== 2. Secrets that should never be in gitOK: no tracked .env/.pem/.key files== 3. Unpinned base imagesOK: every FROM is pinned by digest== 4. Build with no registry credentialssha256:60d46686c0cc427edffe8ad32ccc944001ce775a7605a20a90830505c39a6ab1== 5. Run and probe{"status": "ok", "version": "203d8f1"}runs as uid: 10001== 6. Provenance labelsfile:///tmp/client-git/acme/handoff-demo.git @ 203d8f1== 7. Push to the client's registry and pull back by digestlocalhost:5000/acme/handoff-demo:203d8f1localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546pulled localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546== handoff-check passed
Ein paar ehrliche Grenzen. Die Geheimnis-Prüfung erwischt nur offensichtliche Dateinamen; für Inhalte nehmen Sie einen echten Scanner wie gitleaks oder trufflehog. Schritt 4 zieht das öffentliche Basis-Image weiterhin anonym, und genau das ist der Punkt: öffentliche Basen und Ihre eigene Registry, sonst nichts. Und gegen eine echte Registry würden Sie den Push-Schritt mit einem CI-Zugangsdatum Ihrer Organisation ausführen, nicht ganz ohne.
Ich bitte Dienstleister darum, aus diesem Skript (oder ihrer Fassung davon) einen wöchentlichen CI-Job zu machen. Der Tag, an dem es rot wird, ist der Tag, an dem Sie von einer Abhängigkeit vom Laptop einer bestimmten Person erfahren — statt des Tages, an dem diese Person geht.
Die Gründer-Checkliste
Übernehmen Sie das in die Leistungsbeschreibung:
- Repository am ersten Tag in der Organisation des Unternehmens angelegt; mindestens zwei Eigentümer aus dem Unternehmen;
CODEOWNERScommittet und durch Branch-Schutz durchgesetzt. - Jedes
FROMper Digest festgenagelt; Basis-Images nur aus öffentlichen Registries oder aus der Registry des Unternehmens. - Images mit
org.opencontainers.image.sourceundorg.opencontainers.image.revisionbeschriftet; Container laufen ohne Root-Rechte. - Container-Registry in der Cloud oder im Konto des Unternehmens; Images mit dem Commit-SHA getaggt; Produktions-Deployments per SHA oder Digest.
- CI im Repository definiert, unter der Organisation laufend, mit Secrets im Geltungsbereich der Organisation oder mit dem eingebauten Token der Plattform.
- Ein Übergabe-Check, der ohne jedes Zugangsdatum des Dienstleisters klont, baut, startet und pusht — regelmäßig in der CI ausgeführt.
- Eine schriftliche Liste jedes externen Kontos, von dem das Produkt abhängt (DNS, E-Mail, Zahlungen, Cloud), jeweils mit einer verantwortlichen Person im Unternehmen.
Nichts davon bremst einen guten Dienstleister. Ein Dienstleister, der sich dagegen wehrt, sagt Ihnen damit etwas.
Für diesen Artikel verwendete Umgebung
docker client 26.1.5+dfsg1 / server 26.1.5+dfsg1 (storage driver: vfs)git version 2.47.3actionlint 1.7.12Linux 6.12.94+ x86_64PRETTY_NAME="Debian GNU/Linux 13 (trixie)"Umair Abbas ist Gründer von CodeFlamme, einer Software-Agentur in Lahore, Pakistan, die Web-, Mobile- und KI-Produkte baut, bei denen den Kunden Repository, CI und Cloud vom ersten Tag an gehören.
Deutsche Übersetzung des englischen Originalartikels Own the Repo, CI and Container Registry on Day One. Befehle, Ausgaben und Skripte sind unverändert wiedergegeben.
← Back to blog