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-manager installiert (inklusive CRDs)
  • cert-manager-step-issuer installiert
  • 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.