Du willst einer Docker-Applikation ein eigenes CA-Zertifikat beibringen — zum Beispiel weil du eine interne CA betreibst und der Container intern gehostete Dienste per TLS erreichen soll.

Der Standard-Ansatz: Zertifikat ins Container mounten, update-ca-certificates beim Start ausführen. Klingt gut. Funktioniert — solange der Container als root läuft.

Tut er aber nicht immer.

Das Problem

Du configurierst ein Docker Compose Override mit Entrypoint-Wrapper:

services:
  app:
    volumes:
      - ./certs/internal-ca.crt:/usr/local/share/ca-certificates/internal-ca.crt:ro
    entrypoint: ["/bin/bash", "-c", "update-ca-certificates && exec /docker-entrypoint.sh"]

Container startet. Crasht sofort. Restart-Loop. Logs:

Updating certificates in /etc/ssl/certs...
/usr/sbin/update-ca-certificates: 101: cannot create /etc/ssl/certs/ca-certificates.crt.new: Permission denied

update-ca-certificates muss in /etc/ssl/certs/ schreiben — das geht nur als root. Der Container läuft als normaler User.

Bei Anwendungen wie Nextcloud (läuft als www-data, Entrypoint startet aber initial als root und wechselt dann) funktioniert der Wrapper. Bei Anwendungen die von Anfang an als Non-Root-User starten — zum Beispiel Zammad (zammad-User) — schlägt es fehl.

Die Diagnose

# Welchen User nutzt der Container?
docker exec srv-r2d2 id
# uid=1000(zammad) gid=1000(zammad)

# Warum läuft update-ca-certificates nicht?
docker exec srv-r2d2 ls -la /etc/ssl/certs/ca-certificates.crt
# -r--r--r-- 1 root root ... (nur lesbar, nicht schreibbar für andere)

Der Container-User kann die Cert-Datei lesen, aber nicht ersetzen. update-ca-certificates muss eine neue Datei schreiben.

Die Lösung: Pre-Built Combined CA Bundle

Statt update-ca-certificates im Container auszuführen, erstellen wir das fertige Bundle außerhalb und mounten es direkt.

Schritt 1: Bundle aus dem laufenden Container extrahieren

# Standard-CA-Bundle aus dem Container holen (während er noch läuft)
docker exec srv-r2d2 cat /etc/ssl/certs/ca-certificates.crt \
  > /opt/srv-r2d2/certs/combined-ca-bundle.crt

# Eigene CA anhängen
cat /opt/srv-r2d2/certs/internal-ca.crt >> /opt/srv-r2d2/certs/combined-ca-bundle.crt

# Verifizieren: Letzte Zeilen prüfen
tail -3 /opt/srv-r2d2/certs/combined-ca-bundle.crt
# Erwartung: -----END CERTIFICATE----- (deine CA)

Schritt 2: docker-compose.override.yml

services:
  app:
    volumes:
      - ./certs/combined-ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro

Das war’s. Kein entrypoint-Override. Kein Build-Step. Kein root.

Der Container-User kann das Bundle lesen (world-readable), nicht schreiben. Genau das wollen wir.

Schritt 3: Verifizieren

# Container neu starten
docker compose -f docker-compose.yml -f docker-compose.override.yml \
  up -d app

# SSL-Verbindung zum internen Dienst testen
docker exec srv-r2d2 \
  sh -c 'openssl s_client -connect mail-chewbacca.darkside.local:993 -quiet < /dev/null 2>&1 | head -4'

Erwartete Ausgabe:

depth=1 CN = Internal Root CA
verify return:1
depth=0 CN = mail-chewbacca.darkside.local
verify return:1

verify return:1 = CA bekannt, Zertifikat gültig. ✅

Warum funktioniert das Mounten?

Das Mount überschreibt /etc/ssl/certs/ca-certificates.crt im Container mit deiner Datei. Der Inhalt ist schreibgeschützt (:ro-Flag), aber der Prozess liest ihn nur — das geht als beliebiger User.

OpenSSL und die meisten TLS-Bibliotheken lesen das System-CA-Bundle beim Verbindungsaufbau. Das Bundle ist jetzt dein kombiniertes Bundle.

Falle: Bundle wird outdated

Das Bundle enthält die CAs zum Zeitpunkt der Erstellung. Wenn der Container-Image ein Update kriegt und neue Root-CAs eingebaut werden, hast du die im Bundle nicht.

Fix: Bundle regelmäßig neu erstellen oder nach Image-Updates refreshen:

# Image updaten
docker compose pull app

# Neues Bundle erstellen (Container kurz ohne Override starten)
docker run --rm <IMAGE> cat /etc/ssl/certs/ca-certificates.crt \
  > /opt/srv-r2d2/certs/combined-ca-bundle.crt
cat /opt/srv-r2d2/certs/internal-ca.crt >> /opt/srv-r2d2/certs/combined-ca-bundle.crt

# Dann wieder mit Override starten
docker compose -f docker-compose.yml -f docker-compose.override.yml up -d app

Wann welchen Ansatz?

SituationAnsatz
Container startet initial als rootEntrypoint-Override mit update-ca-certificates
Container startet als Non-RootCombined CA Bundle mounten (dieser Artikel)
Eigenes Dockerfile erlaubtRUN update-ca-certificates im Build-Step
Keine Kontrolle über ImageCombined CA Bundle ist die einzige Option

Bonus: Zweite Falle mit docker compose exec

Wenn du docker compose exec app rails runner '...' ausführst und die App eine DATABASE_URL braucht — diese Variable wird im Entrypoint via export gesetzt, gilt aber nicht für exec-Subprozesse. export vererbt sich nur an Kindprozesse des Entrypoints, nicht an nachträglich gestartete exec-Prozesse.

Fix: Explizit übergeben:

docker compose exec -T \
  -e DATABASE_URL='postgres://user:pass@db:5432/myapp' \
  app rails runner 'puts "Hello"'

Fazit

Non-Root-Container sind gut für die Sicherheit. Aber sie brechen manche Annahmen. update-ca-certificates ist eine davon.

Das Combined CA Bundle ist kein Hack — es ist konzeptuell sauberer als ein Entrypoint-Wrapper: Du gibst dem Container genau das Bundle, das er braucht, unveränderlich und nachvollziehbar.

Und das Beste: Es funktioniert mit jedem Image, unabhängig davon wie der Entrypoint aussieht.