SSD Nodes Learn Hosting plans →
Anleitungen Matt ConnorVon Matt Connor

Docker in Docker vs. docker.sock: Was ist sicherer?

Docker im Container für CI? Der eingebundene docker.sock und docker:dind geben dem Job Root auf dem Host. Warum :ro nicht schützt und welche Alternativen wirklich helfen.

Docker in Docker oder docker.sock: die kurze Antwort

Für Docker in Docker gibt es drei echte Wege. Du bindest den Socket des Host-Daemons in den Container ein. Du startest docker:dind mit --privileged. Oder du nutzt eine verschachtelte Laufzeit wie Sysbox. Die ersten beiden Wege geben dem Container in der Praxis Root-Rechte auf dem Host. Für einen CI-Runner (CI steht für Continuous Integration) auf einem VPS ist die sichere Antwort deshalb meist eine eigene Wegwerf-VM. Für Dienste wie Traefik ist es ein Socket-Proxy statt des rohen Sockets.

Die meisten Anleitungen zeigen nur die eine Zeile, die funktioniert. Dieser Leitfaden zeigt, was jede Variante an Sicherheit kostet. Diese Kosten sind bei jedem Weg andere, und sie stehen selten dabei.

Wie der Docker-Client mit dem Daemon spricht

Der Befehl docker ist nur ein Client. Die eigentliche Arbeit macht der Daemon dockerd. Der Client schickt HTTP-Anfragen an die Docker-API (API steht für Application Programming Interface). Standardmäßig laufen diese Anfragen über den Unix-Socket /var/run/docker.sock. Bei einer normalen Installation läuft der Daemon als root.

Daraus folgt alles Weitere. Wer Anfragen an diesen Socket schicken darf, steuert einen Prozess, der als root läuft. Docker schreibt selbst in der Anleitung für die Schritte nach der Installation, dass die Gruppe docker Rechte auf Root-Niveau gibt. Der Socket ist der technische Grund dafür.

Ein CI-Job braucht meistens zwei Dinge: Er baut ein Image mit docker build und startet Testcontainer mit docker compose up. Dafür braucht der Job einen Daemon. Offen ist nur, welchen.

Weg 1: /var/run/docker.sock in den Container einbinden

Die bekannteste Lösung ist ein Bind-Mount des Sockets:

services:
  runner:
    image: docker:29.8.2-cli
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Der Container hat jetzt keinen eigenen Daemon. Sein docker-Client spricht mit dem Daemon des Hosts. Startet der Job einen Container, entsteht dieser neben dem Runner und nicht in ihm. Man nennt das Geschwister-Container (sibling containers), manchmal auch "Docker outside of Docker".

Das hat praktische Folgen, die mit Sicherheit nichts zu tun haben. Einen Pfad in -v ./src:/src löst der Host-Daemon auf. Er sucht ihn also auf dem Dateisystem des Hosts und nicht im Runner-Container. Ein Verzeichnis, das nur im Runner existiert, fehlt deshalb im neuen Container oder ist dort leer. Alle Jobs teilen sich die Containernamen mit dem Host. Zwei parallele Jobs mit demselben Namen können deshalb kollidieren. Was ein abgebrochener Job gestartet hat, läuft weiter, bis jemand aufräumt.

Warum Zugriff auf den Socket Root auf dem Host bedeutet

Die API unterscheidet nicht zwischen harmlosen und gefährlichen Anfragen. Wer einen Container anlegen darf, darf auch dessen Optionen wählen. Dazu gehören Verzeichnisse des Hosts als Mount, der privilegierte Modus und die Namespaces des Hosts. Der Daemon führt das als root aus. Ein Prozess mit Zugriff auf den Socket erreicht über einen neuen Container also alles, was root auf dem Host erreicht.

Für CI heißt das: Jeder Job hat diese Rechte. Jeder Pull-Request, der einen Job auslöst, hat sie auch. Ein einziges kompromittiertes npm- oder PyPI-Paket im Build reicht, weil sein Installationsskript im Job läuft.

Dieselbe Zeile steht schon in deinen Compose-Dateien

Diese Mount-Zeile ist kein Sonderfall für CI. Sie steht in der Compose-Datei von Traefik, weil Traefik über die API die Labels deiner Container liest. Sie steht bei Portainer, weil Portainer deine Container verwaltet. Sie steht bei Watchtower, weil Watchtower Images zieht und Container neu anlegt. Vielleicht betreibst du Traefik als Reverse Proxy für mehrere Apps mit Docker Compose oder Portainer auf einem VPS. Dann hat dieser Container schon heute Root-Rechte auf deinem Server.

Bei Traefik ist das besonders ernst. Traefik ist direkt aus dem Internet erreichbar und verarbeitet jede eingehende Anfrage. Eine Sicherheitslücke in diesem Prozess trifft deshalb den ganzen Host.

Warum :ro die API nicht schreibgeschützt macht

Oft liest man den Rat, den Socket mit :ro einzubinden:

    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

Das ändert an der API nichts. :ro ist eine Option des Mounts. Sie verbietet Schreibzugriffe auf das Dateisystem, also das Anlegen, Ändern oder Löschen von Dateien. Ein Socket ist aber kein Datenspeicher. Der Client öffnet eine Verbindung zum Socket. Dabei prüft der Kernel die Dateirechte der Socket-Datei, aber nicht den Schreibschutz des Mounts. Über die offene Verbindung laufen dann alle HTTP-Anfragen, auch POST /containers/create. Ob eine Anfrage nur liest oder etwas verändert, sieht nur der Daemon. Ohne ein Autorisierungs-Plugin prüft er das nicht.

Du kannst das auf deinem eigenen Server nachprüfen:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro docker:29.8.2-cli docker ps

Zeigt der Befehl die Container deines Hosts, dann beantwortet die API Anfragen durch einen :ro-Mount. Auch das Anlegen und Starten von Containern läuft über diese Verbindung. Für eine echte Einschränkung brauchst du einen Filter vor der API. Mehr dazu weiter unten beim Socket-Proxy.

Weg 2: docker:dind mit --privileged

Die zweite Variante startet einen eigenen Daemon in einem Container. Das offizielle Image dafür ist docker:dind. Die Dokumentation des Images zeigt den folgenden Aufbau. Hier steht er mit festem Tag. Stand Oktober 2026 ist 29.8.2 die aktuelle Version:

docker network create ci-net
docker run --privileged --name ci-docker -d \
  --network ci-net --network-alias docker \
  -e DOCKER_TLS_CERTDIR=/certs \
  -v ci-docker-certs-ca:/certs/ca \
  -v ci-docker-certs-client:/certs/client \
  docker:29.8.2-dind

Mit DOCKER_TLS_CERTDIR erzeugt das Image beim Start Zertifikate für TLS (Transport Layer Security). Der Daemon nimmt dann nur Clients an, die das Client-Zertifikat aus dem Volume vorzeigen. Setzt du die Variable auf einen leeren Wert, lauscht der Daemon ohne TLS. Die Dokumentation warnt, dass das ein Sicherheitsproblem ist, sobald andere Container oder das Netzwerk den Daemon erreichen können.

Einen Client verbindest du aus einem zweiten Container im selben Netzwerk:

docker run --rm --network ci-net \
  -e DOCKER_TLS_CERTDIR=/certs \
  -v ci-docker-certs-client:/certs/client:ro \
  docker:29.8.2 version

In diesem Client-Container ist kein Socket des Hosts eingebunden. Zeigt docker version einen Abschnitt Server, kann dieser Server deshalb nur der Daemon in ci-docker sein.

Was --privileged kostet

Die Image-Dokumentation sagt es selbst: --privileged ist für Docker in Docker nötig, gibt aber vollen Zugriff auf die Host-Umgebung. Ein privilegierter Container bekommt alle Linux-Capabilities und sieht die Geräte des Hosts. Außerdem läuft er ohne die Standardprofile von seccomp und AppArmor. Die Trennung zwischen Container und Host ist damit größtenteils aufgehoben. Code in einem privilegierten Container hat deshalb kaum weniger Möglichkeiten als root auf dem Host.

Dazu kommen technische Probleme. Die Dokumentation verweist auf einen Artikel von Jérôme Petazzoni, der die erste Docker-in-Docker-Lösung geschrieben hat: Do not use Docker-in-Docker for CI. Er beschreibt unter anderem Konflikte, wenn innerer und äußerer Daemon mit ihren Storage-Treibern übereinander arbeiten. Außerdem geht der Build-Cache mit jedem neuen Container verloren. Die Image-Dokumentation selbst rät grundsätzlich von Docker in Docker ab, auch wenn es legitime Anwendungsfälle gibt.

Und die Rootless-Variante?

Es gibt auch docker:29.8.2-dind-rootless. Darin läuft der innere Daemon als normaler Benutzer statt als root. Das hilft weniger, als der Name vermuten lässt. Laut Dokumentation braucht auch diese Variante --privileged, genau wie das normale dind-Image. Die Dokumentation nennt das selbst ein Sicherheitsproblem, das man entsprechend behandeln muss. Du gewinnst nur eine zweite Schicht im Inneren. Bricht ein Job aus einem inneren Container aus, landet er beim unprivilegierten Benutzer des inneren Daemons. Der äußere Container bleibt privilegiert.

Weg 3: Sysbox als verschachtelte Laufzeit

Sysbox ersetzt für ausgewählte Container die Standard-Laufzeit runc. Ein Sysbox-Container nutzt User-Namespaces. Root im Container ist auf dem Host deshalb ein unprivilegierter Benutzer. Laut README läuft in so einem Container Software wie Docker oder Kubernetes. Dafür braucht es weder einen privilegierten Container noch angepasste Versionen dieser Software.

Zum Stand der Pflege: Nestybox, die Firma hinter Sysbox, gehört seit Mai 2022 zu Docker. Laut README ist Sysbox aber ein Community-Projekt, das Docker nicht offiziell unterstützt. Es wird weiter entwickelt. Version 0.7.0 erschien im Juni 2026, Version 0.7.1 im Juli 2026 (Stand Oktober 2026).

Pakete gibt es laut Installationsanleitung nur für Ubuntu und Debian. Der Installer konfiguriert Docker eventuell neu und startet den Daemon dabei neu. Die Anleitung empfiehlt deshalb, vorher alle Container zu stoppen und zu entfernen. Docker aus einem Snap-Paket wird nicht unterstützt. Das Projekt empfiehlt mindestens 4 CPUs und 4 GB RAM.

sudo apt-get install -y jq
wget https://github.com/nestybox/sysbox/releases/download/v0.7.1/sysbox-ce_0.7.1.linux_amd64.deb
sudo apt-get install ./sysbox-ce_0.7.1.linux_amd64.deb
systemctl status sysbox -n20

Auf einem ARM-VPS heißt die Datei sysbox-ce_0.7.1.linux_arm64.deb. Prüfe in der Ausgabe von systemctl status, ob der Dienst läuft, bevor du weitermachst. Startet er nicht, stehen die Gründe in den Logzeilen darunter.

Einen Container startest du mit --runtime=sysbox-runc. Die Sysbox-Anleitung nutzt als Beispiel ein Image mit vorinstalliertem Docker:

docker run --runtime=sysbox-runc -it --rm nestybox/ubuntu-jammy-systemd-docker

Ein Hinweis zu diesem Image: Es hat nur den Tag latest. Dieser Tag wurde zuletzt im August 2022 aktualisiert (Stand Oktober 2026). Für eine CI-Umgebung, die du lange betreibst, ist das kein guter Startpunkt. Baue lieber ein eigenes Image auf einer Basis mit festem Tag und installiere Docker darin selbst.

Auch Sysbox hat Grenzen. Es ist eine zusätzliche Komponente mit eigenem Code im Startpfad jedes Containers, der sie nutzt. Eine Lücke darin hat ähnliche Folgen wie eine Lücke in runc. Außerdem bist du auf Ubuntu oder Debian als Host festgelegt.

Was ein Self-Hoster konkret tun kann

Ein Socket-Proxy mit Allow-List statt des rohen Sockets

Ein Socket-Proxy sitzt zwischen dem Dienst und der API. Nur dieser eine Container hält den echten Socket. Er leitet nur die Teile der API weiter, die du erlaubst. Verbreitet ist tecnativa/docker-socket-proxy. Laut README ist fast alles standardmäßig gesperrt. Mit POST=0 lässt der Proxy nur GET- und HEAD-Anfragen durch. Die API ist dann tatsächlich nur lesbar. Diesen Schutz liefert :ro nicht.

Traefik braucht nur Lesezugriff auf die Container. Ergänze deine bestehende Compose-Datei so:

services:
  socket-proxy:
    image: tecnativa/docker-socket-proxy:v0.5.0
    restart: unless-stopped
    environment:
      CONTAINERS: "1"
      POST: "0"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - socket-proxy

  traefik:
    # Image, Ports und Labels wie bisher, aber ohne docker.sock-Volume.
    # Diese Zeile kommt in deine bestehende command-Liste:
    command:
      - --providers.docker.endpoint=tcp://socket-proxy:2375
    networks:
      - socket-proxy
      - web

networks:
  socket-proxy:
    internal: true
  web:

Der Proxy spricht unverschlüsseltes HTTP auf Port 2375. Das README warnt ausdrücklich, diesen Port nie in einem öffentlichen Netzwerk freizugeben. Deshalb fehlt im Beispiel ein ports:-Eintrag, und das Netzwerk ist internal. Veröffentlichte Docker-Ports umgehen außerdem ufw, wie der Beitrag zu Docker-Ports und ufw zeigt. Ein versehentliches "2375:2375" unter ports: wäre also trotz Firewall offen.

Das README startet den Proxy in seinem Beispiel mit --privileged. Der Grund: Auf manchen Systemen mit SELinux oder AppArmor wird die Verbindung zum Socket sonst blockiert. Der Proxy hält ohnehin den vollen Socket und damit Root-Rechte. Halte ihn deshalb klein. Verbinde nur die Dienste mit seinem Netzwerk, die ihn wirklich brauchen.

Bei Portainer und Watchtower hilft der Proxy wenig. Eine Verwaltungsoberfläche braucht Schreibzugriff, sonst kann sie nichts verwalten. Watchtower muss Images ziehen und Container neu anlegen. Behandle Portainer deshalb wie einen Root-Login: kein offener Port im Internet, Zugang nur über ein VPN oder einen SSH-Tunnel.

CI-Jobs und Coding-Agents in eine Wegwerf-VM

Für CI gibt es eine einfachere Antwort als jede Konstruktion mit Containern. Gib dem Runner eine eigene VM (virtuelle Maschine) oder einen eigenen kleinen VPS. Dort darf der Job den Socket benutzen oder --privileged starten. Er bekommt dann root auf einer Maschine, auf der sonst nichts liegt, also keine Datenbank und keine Zugangsdaten anderer Dienste. Nach dem Job oder in einem festen Rhythmus setzt du die Maschine neu auf.

Wie das mit GitHub Actions aussieht, beschreibt der Leitfaden zum selbst gehosteten GitHub-Actions-Runner. GitHub selbst empfiehlt, selbst gehostete Runner nur mit privaten Repositories zu nutzen. Der Grund: Forks eines öffentlichen Repositorys können per Pull-Request Code auf deinem Runner ausführen.

Dasselbe gilt für KI-Coding-Agents, die selbst Befehle ausführen. Ein Agent mit Zugriff auf den Socket hat dieselben Root-Rechte wie ein CI-Job. Wie du so einen Agenten in einer Maschine abschottest, die du jederzeit neu aufsetzen kannst, zeigt der Beitrag über Coding-Agents in einer Wegwerf-VM.

Podman mit rootless Socket

Podman bietet eine Docker-kompatible API, auf Wunsch ohne root. Der Socket gehört dann deinem Benutzer:

sudo apt install -y podman
systemctl --user enable --now podman.socket
sudo loginctl enable-linger $USER
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock

loginctl enable-linger sorgt dafür, dass der Socket nach einem Neustart auch ohne Login startet. Über DOCKER_HOST sprechen Docker-kompatible Werkzeuge mit Podman statt mit dockerd.

Bindest du diesen Socket in einen Container ein, bekommt der Container die Rechte deines Benutzers und nicht die von root. Laut Podman-Dokumentation schützen die normalen Dateirechte den Socket. So kann nur der Benutzer darauf zugreifen, der den Dienst betreibt. Ein Job kann also weiterhin alles, was dieser Benutzer kann, zum Beispiel seine Dateien lesen und seine Container stoppen. Mit einem eigenen Benutzer, der nur für CI existiert, ist die Angriffsfläche trotzdem deutlich kleiner als mit root. Die Unterschiede im Alltag erklärt der Vergleich von Podman und Docker auf einem VPS.

Auch Docker lässt sich rootless betreiben. Der Socket liegt dann standardmäßig unter $XDG_RUNTIME_DIR/docker.sock und gehört deinem Benutzer. Welche Verwaltungsoberflächen damit arbeiten können, steht in der Übersicht zu Docker-Oberflächen mit Rootless-Support.

Welcher Weg passt zu welchem Fall?

  • Traefik und andere Dienste, die nur lesen: Socket-Proxy mit CONTAINERS=1 und POST=0.
  • Portainer und Watchtower: Der Socket bleibt nötig. Schotte den Zugang ab und behandle den Dienst wie root.
  • CI auf einem Server mit anderen Diensten: weder Socket noch --privileged. Nimm eine eigene VM für den Runner oder Podman rootless unter einem eigenen Benutzer.
  • CI, die einen echten inneren Daemon ohne privilegierten Container braucht: Sysbox auf einem Ubuntu- oder Debian-Host.
  • CI auf einer Wegwerf-VM: docker:dind oder der Socket sind dort vertretbar, weil root auf dieser Maschine nichts Wertvolles erreicht.

FAQ

Ist docker.sock mit :ro sicher eingebunden?

Nein. :ro macht nur das Dateisystem des Mounts schreibgeschützt. Die Verbindung zum Socket ist davon nicht betroffen. Über diese Verbindung laufen alle API-Anfragen, auch das Anlegen neuer Container. Ein Container mit docker.sock:ro hat damit dieselben Root-Rechte auf dem Host wie ohne :ro. Echten Lesezugriff bekommst du nur mit einem Filter wie tecnativa/docker-socket-proxy und POST=0.

Braucht docker:dind wirklich --privileged?

Ja. Die offizielle Dokumentation des Images sagt, dass --privileged für Docker in Docker nötig ist und vollen Zugriff auf die Host-Umgebung gibt. Laut Dokumentation gilt das auch für die Variante dind-rootless. Ohne privilegierten Container läuft ein innerer Daemon nur mit einer anderen Laufzeit wie Sysbox.

Was ist der Unterschied zwischen Docker in Docker und dem Einbinden von docker.sock?

Bei Docker in Docker läuft ein eigener Daemon im Container. Neue Container entstehen dann innerhalb dieses Containers. Beim Einbinden von docker.sock gibt es keinen zweiten Daemon. Der Container steuert den Daemon des Hosts, und neue Container entstehen als Geschwister neben ihm. Deshalb beziehen sich Bind-Mount-Pfade dann auf den Host. Beide Wege geben dem Container in der Praxis Root-Rechte auf dem Host.

Wird Sysbox noch gepflegt?

Ja, Stand Oktober 2026. Version 0.7.0 erschien im Juni 2026 und Version 0.7.1 im Juli 2026. Nestybox gehört seit 2022 zu Docker. Laut README ist Sysbox aber ein Community-Projekt ohne offiziellen Support von Docker. Pakete gibt es nur für Ubuntu und Debian.

Wie gebe ich Traefik Zugriff auf Docker ohne den rohen Socket?

Starte tecnativa/docker-socket-proxy mit CONTAINERS=1 und POST=0 in einem internen Netzwerk. Setze in Traefik --providers.docker.endpoint=tcp://socket-proxy:2375. Traefik kann dann Container und Labels lesen, aber nichts anlegen oder starten. Gib Port 2375 nie nach außen frei, weil der Proxy unverschlüsseltes HTTP spricht und keine eigene Authentifizierung hat.

#docker#docker-in-docker#docker-socket#container-security#ci