Eine eigene Docker Registry betreiben
Wie ich in Projekten eine private Docker Registry mit registry:2 aufsetze: Images taggen, pushen und pullen, und danach mit htpasswd und TLS absichern.
Eine Docker Registry ist kein Dateiserver, auf dem man Images ablegt. Sie ist der Namensraum, über den Docker Images überhaupt adressiert: Ein voll qualifizierter Image-Name hat die Form host:port/name:tag, und der Host-Teil darin entscheidet, wohin ein push geht und woher ein pull kommt. Fehlt er, ist Docker Hub gemeint – auch dann, wenn im Image der eigene Anwendungscode steckt.
Redaktionelle Einordnung (Juli 2026): Das Tutorial beschreibt den Stand von 2018. Docker Content Trust basiert auf Notary v1 und wird von Docker abgekündigt; für neue Signatur- und Verifikationsabläufe nennt Docker Sigstore oder Notation. Auch Portus ist keine heutige Produktempfehlung mehr: Das Projekt wurde 2023 archiviert. Der grundlegende Registry-Betrieb mit registry:2, TLS, Authentifizierung und unveränderlichen Digests bleibt davon unberührt.
Wer diesen Host-Teil selbst setzt, entscheidet damit auch, wo die eigenen Images liegen, woher sie stammen und wer dafür geradesteht, dass in Produktion genau das läuft, was gebaut und geprüft wurde. Der Weg dahin ist kurz. 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. Weil eine offene Registry ein echtes Problem ist, sichern wir sie mit Basic-Auth und TLS ab. Zum Schluss geht es über das Werkzeug hinaus: um Herkunft, Signatur und Betriebsverantwortung der eigenen Images. 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 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 halte ich gern als Diagramm fest, 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]
Am Registry-Host hängt damit die ganze Mechanik. Das erklärt auch, 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
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
Beim schnellen Ausprobieren kommt es vor, dass gerade kein Zertifikat greifbar ist, man aber trotzdem 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.
Die Registry als Kontrollpunkt in der Lieferkette
Bis hierhin war die Registry vor allem ein Transportmittel: bauen, taggen, pushen, pullen. Der eigentliche Hebel liegt aber eine Ebene höher. Sobald alle eigenen Images durch eine Registry laufen, die ich selbst kontrolliere, wird sie zum zentralen Punkt, an dem sich die Herkunft dessen entscheidet, was in Produktion läuft. In vielen Firmenkontexten zählt genau das mehr als die paar Sekunden, die ein Pull im lokalen Netz spart.
Der erste Punkt ist die Herkunft. Ein Tag wie registry.local:5000/app:1.0 ist bequem, aber veränderlich. Nichts hindert jemanden daran, denselben Tag später mit einem anderen Inhalt zu überschreiben – der Name bleibt gleich, das Image dahinter ist ein anderes. Wer verbindlich wissen will, welches Image läuft, referenziert es über seinen Digest statt über den Tag:
docker pull registry.local:5000/app@sha256:9b2a4c...
Der Digest ist der Hash über den Image-Inhalt und damit unveränderlich. Zieht ein CI-Runner oder ein Produktionshost ein Image per Digest, bekommt er garantiert genau die Bits, die einmal gebaut und geprüft wurden – nicht das, was zufällig gerade unter dem Tag liegt. In der Pipeline heißt das: Der Build gibt den Digest aus, das Deployment pinnt genau diesen Digest, und der Tag bleibt nur ein lesbarer Name für Menschen, kein Vertrauensanker.
Der zweite Punkt ist die Signatur. Die Herkunft über den Digest sagt „es ist dasselbe Image“, aber noch nicht „es kommt von dem, von dem es kommen soll“. Dafür gibt es Docker Content Trust. Setze ich DOCKER_CONTENT_TRUST=1, signiert Docker die Images beim Push und verlangt beim Pull eine gültige Signatur:
export DOCKER_CONTENT_TRUST=1
docker push registry.local:5000/app:1.0
Im Hintergrund verwaltet ein Notary-Dienst die Signaturen und die zugehörigen Schlüssel. Der Gewinn ist, dass ein Client ein untergeschobenes oder manipuliertes Image erkennt und den Pull verweigert, statt es blind auszuführen. Das kostet Aufwand – Schlüssel wollen erzeugt, sicher verwahrt und rotiert werden – und ist für einen reinen Testaufbau überzogen. Sobald Images aber bis in die Produktion laufen, ist die Frage „wurde das von uns gebaut und seitdem nicht angefasst?“ keine akademische mehr.
Der dritte Punkt ist die Betriebsverantwortung, die Kehrseite der ganzen Kontrolle. Wer die Registry selbst betreibt, entscheidet nicht nur, wer pushen und pullen darf. Er ist auch dafür verantwortlich, dass der Dienst läuft, gepatcht ist, seine Daten behält und im Zweifel wiederherstellbar bleibt. Diese Verantwortung verschwindet nicht, sie wechselt nur den Besitzer. Bei Docker Hub trägt sie der Anbieter, bei der eigenen Registry trage ich sie.
Daraus ergab sich 2018 das eigentliche Entscheidungskriterium. registry:2 reichte, solange Herkunft und Zugriff mit Digests, einer Handvoll htpasswd-Nutzern und Content Trust abgedeckt waren. Sobald aber viele Teams, feingranulare Rechte, nachvollziehbare Audit-Logs und eine belastbare Trennung zwischen „gebaut“ und „produktionsfreigegeben“ gefordert waren, kamen Produkte wie Portus, Sonatype Nexus oder JFrog Artifactory ins Spiel. Portus ist inzwischen archiviert; für eine heutige Auswahl muss der Wartungs- und Supportstatus neu geprüft werden.
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. Das sind die konkreten Handgriffe hinter dem Wort Betriebsverantwortung. Dass registry:2 mit einem Kommando startet, nimmt sie einem nicht ab – sie liegen von da an bei mir.
Fazit
Eine eigene Docker Registry klingt nach Infrastrukturprojekt, ist technisch aber eine Sache von wenigen Minuten – solange man die entscheidenden Punkte verinnerlicht. Die Registry ist selbst nur ein Container, registry:2, und der Registry-Host wird über den Image-Namen host:port/name:tag angesteuert. 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.
Der eigentliche Gewinn zeigt sich erst im Betrieb. Die eigenen Images liegen im eigenen Netz, sind schneller verfügbar und verkürzen die Wege in der Pipeline. Über Digests und den damals üblichen Docker Content Trust wurde aus schnellem Ausliefern ein nachvollziehbares Ausliefern – man wusste, was lief und woher es kam. Genauso klar sind die Grenzen: Wo Governance über viele Teams, feingranulare Rechte und lückenlose Audits verlangt ist, endet registry:2 und ein spezialisiertes Produkt beginnt. Für alles davor reichen ein Container, ein sauber gesetzter Host-Teil im Image-Namen und zwei Schutzschichten.
Weiterführende Quellen
- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-docker.html
- Deploy a registry server – docs.docker.com
- Run an externally-accessible registry (Basic-Auth + TLS)
- Registry configuration reference (config.yml)
- Test an insecure registry (daemon.json)
- docker tag / docker push / docker pull / docker login – CLI-Referenz
- Docker Content Trust und Notary v1
- Docker: abgekündigte Produkte und empfohlene Alternativen
- Portus – archiviertes Repository
- Node.js und Redis mit Docker Compose
Wie fandest du diesen Beitrag?
Kommentare