Wer ein Kubernetes-Homelab betreibt, kennt das Problem: Selbstsignierte Zertifikate erzeugen Browser-Warnungen, Let's Encrypt funktioniert nur mit öffentlich erreichbaren Domains, und curl -k fühlt sich einfach falsch an. Die Lösung ist eine eigene Certificate Authority – und mit step-certificates von Smallstep lässt sich das erstaunlich elegant in Kubernetes integrieren.
Was wir bauen
Am Ende dieser Anleitung läuft in deinem Cluster:
- Eine step-certificates CA als Kubernetes-StatefulSet
- Ein StepClusterIssuer für cert-manager, der automatisch TLS-Zertifikate ausstellt
- Wildcard-Zertifikate für eine lokale Domain (in meinem Fall
*.r.k) - Pi-hole als DNS-Resolver für die lokale Domain
Voraussetzungen
- Ein laufender Kubernetes-Cluster
cert-managerinstalliert (inklusive CRDs)cert-manager-step-issuerinstalliert- Helm 3
- Pi-hole (oder ein anderer DNS-Resolver, der eigene Records unterstützt)
Schritt 1: CA-Passwort vorbereiten
Das CA-Passwort wird base64-kodiert an Helm übergeben:
echo -n "secret_ca_password" | base64 > password.txt
Hinweis: In einer Produktionsumgebung würde man dieses Secret natürlich über SOPS/Age oder einen externen Secret-Manager verwalten, nicht direkt in einer Datei.
Schritt 2: step-certificates per Helm installieren
Die Installation erfolgt über das offizielle Smallstep Helm Chart. Wichtig sind vor allem die DNS-SANs für den CA-Server selbst:
helm install \
--set ca.dns="step-certificates.step-certificates.svc.cluster.local\,127.0.0.1\,ca.kuepper.nrw" \
--set service.targetPort=9000 \
--set inject.secrets.provisioner_password=$(cat password.txt) \
--set inject.secrets.ca_password=$(cat password.txt) \
--set issuer.dnsNames="*.home.kuepper.nrw" \
step-certificates smallstep/step-certificates \
--create-namespace \
--namespace step-certificates
Die wichtigsten Parameter erklärt:
| Parameter | Bedeutung |
|---|---|
ca.dns |
SANs (Subject Alternative Names) für das CA-Zertifikat selbst |
service.targetPort |
Port, auf dem step-ca lauscht |
inject.secrets.ca_password |
Passwort zum Entschlüsseln des CA-Schlüssels |
inject.secrets.provisioner_password |
Passwort für den ACME/JWK-Provisioner |
issuer.dnsNames |
Für welche Domains der Provisioner Zertifikate ausstellen darf |
Schritt 3: Generierte Passwörter auslesen
step-certificates legt beim ersten Start automatisch Kubernetes Secrets an. Die tatsächlich verwendeten Passwörter lassen sich so auslesen:
# CA-Passwort
kubectl get -n step-certificates \
-o jsonpath='{.data.password}' \
secret/step-certificates-ca-password | base64 --decode
# Provisioner-Passwort
kubectl get -n step-certificates \
-o jsonpath='{.data.password}' \
secret/step-certificates-provisioner-password | base64 --decode
Diese Passwörter brauchen wir später für die cert-manager Konfiguration.
Schritt 4: CA-Fingerprint und Root-Zertifikat sichern
Der CA-Fingerprint ist notwendig, damit Clients der CA vertrauen können. Er wird im Initialisierungs-Job ausgegeben:
kubectl -n step-certificates logs job.batch/step-certificates
# CA URL: https://step-certificates.step-certificates.svc.cluster.local
# CA Fingerprint: fe4459d7b7d2e0e8c3d2e49ace4fbd60b3cc0f8a310a31f5ce97751143338fed
Nachdem wir Fingerprint und Logs gesichert haben, kann der Job gelöscht werden:
kubectl -n step-certificates delete job.batch/step-certificates
Das Root-Zertifikat exportieren wir direkt aus dem laufenden Pod und kodieren es base64 für die spätere Verwendung in Kubernetes Manifests:
kubectl -n step-certificates exec -it step-certificates-0 -- step ca root | base64 -w0
Die Ausgabe (ein langer base64-String, der mit LS0tLS1CRUdJTi... beginnt) wird später als caBundle im StepClusterIssuer benötigt.
Schritt 5: Provisioner-Konfiguration auslesen
Für den cert-manager StepClusterIssuer brauchen wir die kid (Key ID) des Provisioners:
# CA-URL prüfen
kubectl -n step-certificates get configmap step-certificates-config \
-o jsonpath="{.data['defaults\.json']}" | jq -r '."ca-url"'
# https://step-certificates.step-certificates.svc.cluster.local
# kid des ersten Provisioners
kubectl -n step-certificates get \
-o jsonpath="{.data['ca\.json']}" \
configmaps/step-certificates-config | jq .authority.provisioners[0].key.kid
# "yBkH6Chw8aECj1Itrb80X0oUR5MLQeftGw9bStcue"
# Name des Provisioners
kubectl -n step-certificates get \
-o jsonpath="{.data['ca\.json']}" \
configmaps/step-certificates-config | jq .authority.provisioners[0].name
# "admin"
Schritt 6: StepClusterIssuer für cert-manager konfigurieren
Jetzt verbinden wir step-certificates mit cert-manager. Der StepClusterIssuer ist cluster-weit gültig und kann in jedem Namespace Zertifikate ausstellen:
cat <<EOF | kubectl apply -f -
apiVersion: certmanager.step.sm/v1beta1
kind: StepClusterIssuer
metadata:
name: step-issuer
spec:
caUrl: "https://step-certificates.step-certificates.svc.cluster.local"
caBundle: "<base64-kodiertes Root-CA-Zertifikat aus Schritt 4>"
provisioner:
name: "admin"
kid: "yBkH6Chw8aECj1Itrb80X0oUR5MLQeftGw9bStcue"
passwordRef:
name: "step-certificates-provisioner-password"
key: "password"
namespace: "step-certificates"
enableCertificateChain: true
EOF
Der caBundle ist der base64-kodierte Inhalt des Root-Zertifikats aus Schritt 4.
Schritt 7: DNS-Auflösung mit Pi-hole einrichten
Damit alle Geräte im lokalen Netzwerk die Domain *.r.k auflösen können, fügen wir einen DNS-Record in Pi-hole hinzu. Die kurze TLD .r.k ist dabei bewusst gewählt – kurz zu tippen und klar als lokale Domain erkennbar:
pihole-FTL --config misc.dnsmasq_lines '["address=/.r.k/10.0.2.211"]'
systemctl restart pihole-FTL
Der Wildcard-Record *.r.k zeigt auf 10.0.2.211, die IP des Traefik-Ingress-Controllers.
Schritt 8: Ingress mit automatischem TLS
Jetzt der entscheidende Test: ein Ingress, der automatisch ein gültiges Wildcard-Zertifikat bekommt. Die Annotation cert-manager.io/issuer-kind: StepClusterIssuer weist cert-manager an, den Step-Issuer zu verwenden:
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
cert-manager.io/issuer: step-issuer
cert-manager.io/issuer-group: certmanager.step.sm
cert-manager.io/issuer-kind: StepClusterIssuer
traefik.ingress.kubernetes.io/router.middlewares: kube-system-redirect-scheme@kubernetescrd
name: blog-r-k
namespace: blog
spec:
ingressClassName: traefik
rules:
- host: blog.r.k
http:
paths:
- backend:
service:
name: blog
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- '*.r.k'
secretName: blog.r.k-tls
EOF
cert-manager erkennt die Annotation, stellt automatisch eine Certificate-Ressource aus, kommuniziert mit step-certificates und legt das fertige TLS-Zertifikat im Secret blog.r.k-tls ab. Traefik nutzt dieses Secret für die TLS-Terminierung.
Schritt 9: Verifizierung
Der finale Test zeigt, dass alles funktioniert:
curl -s https://test.r.k -v | grep subjectAltName
# subject: CN=*.r.k
# subjectAltName: host "test.r.k" matched cert's "*.r.k"
Kein curl -k, keine Zertifikatswarnungen im Browser – das Zertifikat ist von unserer eigenen CA signiert und wird als gültig erkannt, sofern das Root-CA-Zertifikat im System- oder Browser-Trust-Store eingetragen ist.
Fazit
Das Setup mag auf den ersten Blick nach vielen Schritten aussehen, ist aber in der Praxis gut automatisierbar. Die Kombination aus step-certificates + cert-manager + StepClusterIssuer funktioniert zuverlässig und lässt sich prima in GitOps-Workflows (z.B. mit FluxCD) integrieren.
Was mich besonders überzeugt hat: sobald der StepClusterIssuer einmal läuft, ist das Ausstellen neuer Zertifikate für neue Services komplett automatisch – einfach Ingress mit den richtigen Annotationen anlegen, und cert-manager erledigt den Rest.
Nächste Schritte
Ein paar Dinge, die ich noch angehen möchte:
- Das Root-CA-Zertifikat automatisch in neue Nodes/Pods einbinden (z.B. über ein DaemonSet oder Init-Container)
- SOPS/Age für die CA- und Provisioner-Passwörter im GitOps-Repo
- Backup der CA – step-certificates hat einen eingebauten Backup-Mechanismus, den es zu konfigurieren gilt
Fragen oder Anmerkungen? Gerne via Mastodon oder direkt per Mail.