Eine eigene Docker Registry betreiben
Wie ich in Schulungen eine private Docker Registry mit registry:2 aufsetze: Images taggen, pushen und pullen, und danach mit htpasswd und TLS absichern.
In fast jeder Docker-Schulung kommt derselbe Moment. Wir haben ein Image gebaut, es läuft lokal, alle sind zufrieden – und dann fragt jemand aus der zweiten Reihe: "Wie kommt das Image jetzt auf den anderen Rechner? Und muss ich das wirklich über Docker Hub schieben, wo unser Code drinsteckt?" Genau an dieser Stelle lohnt es sich, das Thema Registry einmal in Ruhe aufzumachen. Denn die Antwort ist erfreulich einfach: Man betreibt seine eigene Registry, im eigenen Netz, unter eigener Kontrolle. Und das Beste daran – die Registry selbst ist nur ein weiterer Container.
In diesem Artikel gehe ich den Weg durch, den ich auch im Kurs nehme. Zuerst klären wir, warum eine private Registry überhaupt sinnvoll ist. Dann starten wir registry:2 mit einem einzigen Kommando, taggen ein Image, pushen es und ziehen es auf einem anderen Host wieder herunter. Und weil eine offene Registry ein echtes Problem ist, sichern wir sie zum Schluss mit Basic-Auth und TLS ab. Wer Docker Compose schon kennt – etwa aus meinem Artikel zu Node.js und Redis mit Docker Compose – wird sich hier schnell zurechtfinden.
Warum überhaupt eine eigene Registry?
Docker Hub ist bequem. Man pusht ein Image, es liegt in der Cloud, jeder kann es ziehen. Genau das ist aber in vielen Firmenkontexten das Problem: Der eigene Anwendungscode steckt im Image, und der soll nicht öffentlich einsehbar sein. Klar, es gibt private Repositories bei Docker Hub, aber dann hängt die gesamte Deployment-Kette an einem externen Dienst und an einer Internetverbindung, die durchaus mal langsam oder gestört sein kann.
Eine private Registry im eigenen Netz löst diese Punkte auf einen Schlag. Die Images bleiben intern, das Ziehen läuft über das lokale Netzwerk und ist damit spürbar schneller, und man behält die volle Kontrolle darüber, wo die Daten liegen und wie lange sie aufbewahrt werden. Gerade in einer CI/CD-Pipeline, die viele Builds am Tag durch die Gegend schiebt, macht sich das schnell bemerkbar.
Damit die Unterschiede greifbar werden, stelle ich beides im Kurs gern direkt gegenüber:
- Die private Registry hoste ich selbst, im eigenen Netz, mit voller Kontrolle über Speicherort und Lifecycle – dafür betreibe und sichere ich sie auch selbst.
- Docker Hub ist ein gehosteter Dienst mit Accounts, Organisationen, Web-Oberfläche und offiziellen Images – dafür internetabhängig und außerhalb der eigenen Infrastruktur.
Beides hat seine Berechtigung. Offizielle Basis-Images wie node, redis oder eben registry selbst ziehe ich weiterhin von Docker Hub. Die eigenen Anwendungs-Images landen dagegen in der privaten Registry. Das eine schließt das andere nicht aus.
Die Registry ist selbst nur ein Container
Kommen wir zum eigentlichen Werkzeug. Die Open-Source-Registry, die hinter Docker Hub dieselbe Technik verwendet, heißt "Docker Distribution" und wird als fertiges Image registry:2 bereitgestellt. Kein separater Server, keine aufwendige Installation – ein docker run, und die Registry läuft. Standardmäßig hört sie auf Port 5000.
docker run -d \
-p 5000:5000 \
--restart=always \
--name registry \
-v /mnt/registry:/var/lib/registry \
registry:2
Ein paar Dinge sind hier bewusst gesetzt. Das -d startet den Container im Hintergrund. --restart=always sorgt dafür, dass die Registry nach einem Neustart des Docker-Daemons oder des Hosts von allein wieder hochkommt – für einen Dienst, an dem die ganze Pipeline hängt, will man das. Und ganz wichtig: das Volume mit -v. Die Registry legt ihre Images per Default über den Filesystem-Storagetreiber unter /var/lib/registry ab. Ohne Volume liegen diese Daten nur im Container. Löscht man den Container, sind die Images weg. Mit dem gemounteten Verzeichnis /mnt/registry bleiben sie erhalten.
Damit läuft die Registry. Sie tut jetzt aber noch nichts Sichtbares – sie wartet auf ihr erstes Image.
Taggen, pushen, pullen
Der entscheidende Punkt beim Umgang mit einer eigenen Registry ist der Image-Name. Docker steuert nämlich nicht über einen separaten Konfigurationsschalter, in welche Registry ein Image wandert, sondern über den Namen selbst. Ein voll qualifizierter Image-Name hat die Form host:port/name:tag. Fehlt der Host-Teil, geht Docker automatisch von Docker Hub (docker.io) aus. Setze ich dagegen meinen eigenen Registry-Host vor den Namen, weiß Docker beim push und pull genau, wohin.
Nehmen wir an, ich habe lokal ein Image app:1.0 gebaut. Dann tagge ich es für meine Registry um:
docker tag app:1.0 registry.local:5000/app:1.0
docker tag kopiert dabei nichts und baut nichts neu – es hängt lediglich einen zweiten Namen an dasselbe Image. Anschließend schiebe ich es in die Registry:
docker push registry.local:5000/app:1.0
Docker liest den Host-Teil registry.local:5000 aus dem Namen und lädt die einzelnen Layer dorthin hoch. Auf einem anderen Rechner im selben Netz – oder auf dem CI-Runner, der später das Deployment übernimmt – hole ich das Image genauso wieder herunter:
docker pull registry.local:5000/app:1.0
Das ist der komplette Kreislauf. Bauen, taggen, pushen, an anderer Stelle pullen. Genau diesen Bogen bilde ich im Kurs auch als Diagramm ab, weil er das Zusammenspiel auf einen Blick zeigt:
flowchart LR
A[docker build<br/>app:1.0] --> B[docker tag<br/>registry.local:5000/app:1.0]
B --> C[docker push]
C --> D[(Private Registry<br/>:5000)]
D --> E[docker pull<br/>anderer Host / CI]
Der Registry-Host ist also kein Beiwerk, sondern der Dreh- und Angelpunkt. Wer das einmal verinnerlicht hat, versteht auch sofort, warum das Umtaggen vor dem Push nötig ist: Ein Image, das nur app:1.0 heißt, würde Docker in Richtung Docker Hub schicken.
Der wunde Punkt: Ab Werk steht alles offen
Jetzt kommt der Teil, den ich in Schulungen mit besonderem Nachdruck betone. Die Registry, die wir gerade gestartet haben, läuft ohne jede Absicherung. Kein Passwort, keine Verschlüsselung. Jeder, der registry.local:5000 im Netz erreicht, kann pushen und pullen. Für einen kurzen lokalen Test auf dem eigenen Rechner ist das in Ordnung. Sobald die Registry aber für mehr als einen Rechner erreichbar ist, darf sie so nicht bleiben.
Absicherung besteht dabei aus zwei voneinander unabhängigen Bausteinen, die man nicht verwechseln sollte:
- TLS verschlüsselt den Transport, damit niemand mitlesen kann, was über die Leitung geht.
- Basic-Auth per htpasswd schützt den Zugriff, damit nur berechtigte Nutzer pushen und pullen.
Das eine ersetzt das andere nicht. TLS ohne Auth verschlüsselt zwar, lässt aber jeden herein. Auth ohne TLS wiederum wird von der Registry gar nicht akzeptiert – die Passwörter dürften ja im Klartext übers Netz gehen, und das lässt registry:2 bewusst nicht zu. Beides gehört also zusammen.
Schauen wir uns zuerst die Auth an. Für Basic-Auth braucht die Registry eine htpasswd-Datei, und zwar zwingend mit bcrypt-Hashes. Die erzeuge ich mir am einfachsten mit dem httpd-Image, das das Werkzeug htpasswd mitbringt:
docker run --rm --entrypoint htpasswd httpd:2 \
-Bbn deploy s3cret > auth/htpasswd
Die Flags sind schnell erklärt: -B erzwingt bcrypt (das ist Pflicht für die Registry), -b übergibt das Passwort direkt in der Kommandozeile, und -n gibt das Ergebnis auf stdout aus, statt es in eine Datei zu schreiben – deshalb leite ich die Ausgabe selbst nach auth/htpasswd um. Herausgekommen ist ein Nutzer deploy mit dem Passwort s3cret.
Für TLS brauche ich ein Zertifikat und den passenden Schlüssel. In einer echten Umgebung kommen die von einer internen CA oder von Let's Encrypt; im Testnetz tut es ein selbstsigniertes Paar. Die beiden Dateien lege ich als domain.crt und domain.key in ein Verzeichnis certs.
Jetzt starte ich die Registry neu – diesmal mit beiden Schutzschichten. Die Konfiguration übergebe ich über Umgebungsvariablen, die die Registry beim Start ausliest:
docker run -d \
-p 5000:5000 \
--restart=always \
--name registry \
-v "$(pwd)"/auth:/auth \
-v "$(pwd)"/certs:/certs \
-v /mnt/registry:/var/lib/registry \
-e REGISTRY_AUTH=htpasswd \
-e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
registry:2
Die REGISTRY_AUTH_*-Variablen schalten die htpasswd-Authentifizierung ein und zeigen auf die eben erzeugte Datei im gemounteten /auth-Verzeichnis. Die beiden REGISTRY_HTTP_TLS_*-Variablen aktivieren TLS mit Zertifikat und Schlüssel aus /certs. Von jetzt an muss man sich anmelden, bevor man etwas pushen darf:
docker login registry.local:5000
Nach der Anmeldung mit Nutzername und Passwort funktionieren push und pull wieder wie gewohnt – nur eben abgesichert. Docker merkt sich das Login lokal, sodass die Pipeline nach einmaligem docker login durchläuft.
Der Vollständigkeit halber: Statt alles über Umgebungsvariablen zu setzen, kann man der Registry auch eine eigene config.yml mitgeben. Dort stehen dieselben Angaben strukturiert unter auth.htpasswd, http.tls und storage.filesystem. Man baut sich dann ein kleines Image FROM registry:2, kopiert die Konfiguration hinein und startet sie über den passenden Entrypoint. Beide Wege sind gängig; für den Einstieg finde ich die Variante mit Umgebungsvariablen übersichtlicher, weil man alles in einem Kommando sieht.
Wie die beiden Schutzschichten zusammenspielen und wo der unsichere Notausgang liegt, zeigt dieses Bild:
flowchart LR
C[Client] -->|TLS| R[Registry-Container :5000]
R -->|htpasswd-Auth| S[(Storage-Volume<br/>/var/lib/registry)]
C2[Client ohne TLS] -.HTTP.-> I[insecure-registries<br/>nur Testnetz]
I -.-> R
Wenn kein TLS zur Hand ist
In Übungen kommt es vor, dass gerade kein Zertifikat greifbar ist, man aber trotzdem schnell einen Push testen will. Dann läuft man in eine typische Fehlermeldung: http: server gave HTTP response to HTTPS client. Der Docker-Client erwartet nämlich standardmäßig HTTPS und verweigert die Kommunikation mit einer reinen HTTP-Registry.
Für genau diesen Fall gibt es eine Ausnahme – die man aber wirklich nur im abgeschotteten Testnetz einsetzen sollte. Man trägt die Registry auf jedem Client als "insecure" in die Datei /etc/docker/daemon.json ein:
{
"insecure-registries": ["registry.local:5000"]
}
Danach muss der Docker-Daemon neu gestartet werden, damit die Änderung greift. Wichtig ist das "auf jedem Client": Der Eintrag gilt lokal pro Rechner, nicht zentral in der Registry. Wer die Registry auf fünf Hosts nutzt, pflegt den Eintrag fünfmal. Für eine dauerhaft betriebene Registry ist das kein Zustand – da führt an TLS kein Weg vorbei. Kommt ein selbstsigniertes Zertifikat zum Einsatz, muss man es übrigens als CA auf den Clients hinterlegen, sonst misstraut ihm der Docker-Client genauso.
Was man beim Betrieb im Blick behalten sollte
Zwei Dinge gehen im ersten Enthusiasmus gern unter. Das erste ist die schon erwähnte Persistenz: Ohne gemountetes Volume verliert man beim nächsten Container-Neustart alle Images. Das Volume ist also nicht optional, sondern Grundausstattung.
Das zweite ist der Speicherplatz. Das Filesystem-Backend wächst mit jedem Push, und alte, längst nicht mehr benötigte Layer verschwinden nicht von allein. Mit der Zeit läuft die Platte voll. Die Registry bringt dafür eine Garbage Collection mit, die man regelmäßig laufen lassen sollte, um nicht mehr referenzierte Blobs aufzuräumen. Das ist im Betrieb schnell vergessen und rächt sich dann Monate später mit einer vollen Festplatte. Ein Backup des Storage-Verzeichnisses gehört ebenfalls dazu – bei einer selbst betriebenen Registry kümmert sich niemand sonst darum.
Wer nicht selbst hosten möchte, aber trotzdem eine private Lösung im Haus braucht, findet mit Produkten wie Portus, Sonatype Nexus oder JFrog Artifactory Alternativen, die zusätzlich eine Web-Oberfläche und Rechteverwaltung mitbringen. Für den Einstieg und für viele interne Setups reicht registry:2 aber vollkommen aus.
Fazit
Eine eigene Docker Registry klingt nach Infrastrukturprojekt, ist in Wahrheit aber eine Sache von wenigen Minuten – solange man die zwei entscheidenden Punkte verinnerlicht. Erstens: Die Registry ist selbst nur ein Container, registry:2, und der Registry-Host wird über den Image-Namen host:port/name:tag angesteuert. Wer taggt, pusht und pullt, hat den kompletten Kreislauf verstanden. Zweitens: Ab Werk steht alles offen, und Absicherung braucht immer beide Bausteine – TLS für den verschlüsselten Transport und htpasswd für den Zugriffsschutz. Der insecure-registries-Eintrag ist eine Testnetz-Krücke, kein Betriebszustand.
Damit hat man die eigenen Images im eigenen Netz, schneller verfügbar und unter voller Kontrolle. Genau das, wonach die Frage aus der zweiten Reihe gesucht hat.
Kommentare