Alle Rezepte
External Secrets Operator · K8s / OpenShift 4.20+ · Secret-Management

Secrets ohne Vault:
ein Namespace als Secret-Hub

Acht Abschnitte vom Problem bis zum Betrieb: Ein dedizierter Namespace wird zum zentralen Tresor, ESO synchronisiert die Werte in die App-Namespaces – in Git liegen nur Referenzen, nie Werte. Inklusive ArgoCD-Anbindung und Ingress über die Gateway API.

0ÜberblickProblem, Idee, Grenzen 1ArchitekturKomponenten, Datenfluss 2Secret-HubNamespace, RBAC, Reader 3App anbindenToken, Store, ExternalSecret 4Auth & VariantenSA-Auth, ClusterStore, Policies 5GitOpsArgoCD, ignoreDifferences 6Gateway APICerts, Gateway, HTTPRoute 7BetriebTroubleshooting, Bewertung
0

Überblick: Warum ein Secret-Hub?

GitOps will den gesamten Cluster-Zustand in Git – Secrets sind die Ausnahme. Passwörter, API-Keys und Zertifikate gehören in kein Repository, auch in kein privates: Git-Historie ist unlöschbar, Repos werden geklont, CI-Runner bekommen Zugriff.

Die üblichen Auswege im Vergleich

AnsatzPrinzipNachteil
Sealed Secretsverschlüsselte Secrets in GitRotation aufwendig, Cluster-gebundener Schlüssel
SOPS / ageverschlüsselte Files in GitKey-Verteilung, Entschlüsselung im CI
HashiCorp Vaultexterner Secret-ManagerBetriebsaufwand, Lizenzkosten (Enterprise)
Cloud Secret ManagerAWS/GCP/Azure-BackendCloud-Bindung, air-gapped unmöglich
ESO + Namespace-Hubzentraler K8s-Namespace als Quellekein externes System nötig

Die Idee: Der ESO Kubernetes-Provider kann einen anderen Namespace im selben Cluster als Secret-Backend nutzen. Ein dedizierter Namespace secret-hub wird zum zentralen Tresor – Admins legen dort die echten Werte ab, App-Namespaces bekommen sie per ESO synchronisiert, in Git liegen nur ExternalSecret-Referenzen.

Der Secret-Hub ist der Tresorraum der Bank – Anwendungen betreten ihn nie selbst. ESO ist der Bote mit Leseausweis, der Kopien in die Schließfächer der App-Namespaces legt.

Was das Pattern kann

  • Kein externes System – kein Vault, kein Cloud-Dienst, keine Lizenzkosten
  • Air-gapped-tauglich – alles bleibt im Cluster
  • Vertraute Primitive – Secret, ServiceAccount, RBAC
  • GitOps-tauglich – nur Referenzen im Repo
  • Migrationspfad – Backend später gegen Vault tauschbar, ohne die ExternalSecret-Manifeste anzufassen

Was es nicht ist: ein Vault-Ersatz

  • keine dynamischen Secrets (kurzlebige DB-Credentials on demand)
  • kein Lease-Management, keine automatische Revocation
  • keine automatische Rotation der Nutzdaten
  • kein detailliertes Audit-Logging (nur K8s-API-Ebene)
  • keine Transit Encryption, keine PKI-Engine

Für statische Secrets in einem klar abgegrenzten Cluster trotzdem eine tragfähige, wartungsarme Lösung.

Verfügbarkeit und Support

PlattformInstallationSupport
OpenShift 4.20+GA im Red Hat Operator Catalog, per OLM (Subscription)Red Hat
Vanilla K8s / KKPHelm-Chart external-secrets oder deklarativ via ArgoCDCommunity
Base64 ist keine Verschlüsselung: Die Werte im Hub liegen nur base64-kodiert vor, geschützt allein durch RBAC und etcd-Encryption. Wer Cluster-Admin-Rechte hat, sieht alles – und ein kompromittierter secret-hub trifft alle abhängigen Namespaces auf einmal.
1

Architektur: Komponenten und Datenfluss

Drei Namespaces spielen zusammen: der ESO Operator beobachtet die CRDs, der secret-hub hält die Quelle, der App-Namespace bekommt die Kopie. Die Anwendung selbst merkt von alldem nichts – sie sieht nur ein normales Kubernetes-Secret.

Der Weg eines Secrets zur Laufzeit

  1. ExternalSecret lesenESO ermittelt den referenzierten SecretStore.
  2. Token holenESO liest das Bearer Token aus dem Secret eso-reader-token im App-Namespace.
  3. AuthentifizierenGegen die Kubernetes API als ServiceAccount eso-reader.
  4. Quelle lesenDas Quell-Secret aus dem secret-hub abrufen.
  5. Ziel schreibenNatives K8s-Secret im App-Namespace erzeugen oder aktualisieren.
  6. WiederholenNach Ablauf des refreshInterval beginnt der Zyklus von vorn.
Manuelle Änderungen am Ziel-Secret sind Sandburgen: Der nächste Refresh überschreibt sie kommentarlos mit dem Stand aus dem Hub. Wer „mal eben“ ein Passwort im App-Namespace patcht, wundert sich spätestens nach dem refreshInterval.

Die Komponenten im Überblick

KomponenteOrtAufgabe
ESO Operatorexternal-secretsbeobachtet clusterweit SecretStore, ClusterSecretStore, ExternalSecret, PushSecret; schreibt pro Reconcile native Secrets
Secret db-credentialssecret-hubSingle Source of Truth, Typ Opaque
ServiceAccount eso-readersecret-hubLese-Identität; Rechte entstehen erst durchs RoleBinding
Role + RoleBindingsecret-hubminimale Leserechte, namespace-gebunden
Secret eso-reader-tokenApp-NamespaceBearer Token unter Key token – das „Secret Zero“
SecretStoreApp-NamespaceBackend, Remote-Namespace, CA, Auth-Methode
ExternalSecretApp-NamespaceReferenz: „Hole X aus dem Store, erzeuge Secret Y“
PodApp-Namespacekonsumiert per secretKeyRef-Env oder Volume-Mount

Der Namespace-Name external-secrets ist reine Helm-Konvention – der Operator kann überall laufen, die CRDs sind davon unabhängig.

Bearer Token: Besitz genügt

Das Token ist ein zeitlich begrenztes, signiertes JWT für eso-reader. „Bearer“ heißt Inhaber (RFC 6750): Wer das Token besitzt, darf es benutzen – ohne weiteren Identitätsnachweis. Das Token ist damit selbst ein hochsensibles Credential.

Bearer Token = Konzertticket – wer es vorzeigt, kommt rein, egal wer es gekauft hat. Und das Token im App-Namespace ist der Generalschlüssel zum Schlüsselkasten: das eine Credential, mit dem alle anderen geholt werden („Secret Zero“).
Architekturbild als ASCII-Skizze
              ┌────────────────────────┐
              │  ESO Operator          │
              │  ns: external-secrets  │
              └───────┬────────┬───────┘
               liest  │        │  schreibt
      ┌───────────────┘        └───────────────┐
      ▼                                        ▼
┌────────────────────────┐        ┌──────────────────────────┐
│ ns: secret-hub         │        │ ns: app-1                │
│ Zentraler Tresor       │        │                          │
│  Secret db-credentials │        │  Secret eso-reader-token │
│  SA eso-reader         │        │        ▼                 │
│  Role + RoleBinding    │        │  SecretStore             │
│  Bearer Token ─────────┼─manuell┼──▶     ▼                 │
└────────────────────────┘        │  ExternalSecret          │
                                  │        ▼                 │
                                  │  Secret db-credentials   │
                                  │        ▼                 │
                                  │  Pod (env / volume)      │
                                  └──────────────────────────┘
2

Secret-Hub einrichten

Ein gewöhnlicher Namespace, der ausschließlich als Secret-Quelle dient: kein Workload, Schreibrechte nur für Admins oder CI/CD, App-ServiceAccounts haben keinerlei Zugriff. Optional per NetworkPolicies zusätzlich isoliert.

Fünf Ressourcen, ein Tresor

  1. Namespace secret-hubMit Label purpose: secret-hub – reine Kennzeichnung.
  2. Secret db-credentialsDie echten Werte: username, password, host, port, dbname. Änderungen hier propagieren nach dem refreshInterval automatisch in alle verknüpften Namespaces.
  3. ServiceAccount eso-readerDie Lese-Identität – für sich genommen rechtelos.
  4. Role eso-secret-readerget/list/watch auf secrets plus create auf selfsubjectrulesreviews.
  5. RoleBindingVerknüpft ServiceAccount und Role – erst jetzt existieren die Rechte.

Keine Schreib- oder Löschrechte, und weil die Role namespace-gebunden ist, gibt es keinen Zugriff auf Secrets außerhalb des secret-hub.

Der unscheinbare zweite Rule-Block: Ohne create auf selfsubjectrulesreviews kann der Kubernetes-Provider seine eigenen Rechte nicht validieren – der SecretStore wird nie Valid, in den ESO-Logs steht nur ein knappes forbidden. Die Leserechte auf Secrets allein reichen nicht.
Komplette YAML: 01-secret-hub/secret-hub.yaml

Alle fünf Ressourcen in einem File, mit kubectl apply -f 01-secret-hub/secret-hub.yaml anwenden.

apiVersion: v1
kind: Namespace
metadata:
  name: secret-hub
  labels:
    purpose: secret-hub
---
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: secret-hub
  labels:
    managed-by: admin
type: Opaque
stringData:
  username: app-user
  password: "Geheim!2026"
  host: postgres.database.svc.cluster.local
  port: "5432"
  dbname: orders
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: eso-reader
  namespace: secret-hub
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: eso-secret-reader
  namespace: secret-hub
rules:
  - apiGroups: [""]
    resources: [secrets]
    verbs: [get, list, watch]
  - apiGroups: [authorization.k8s.io]
    resources: [selfsubjectrulesreviews]
    verbs: [create]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: eso-reader-binding
  namespace: secret-hub
subjects:
  - kind: ServiceAccount
    name: eso-reader
    namespace: secret-hub
roleRef:
  kind: Role
  name: eso-secret-reader
  apiGroup: rbac.authorization.k8s.io

ESO installieren: Vanilla / KKP

Per Helm, danach Pods und CRDs prüfen.

helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace --wait --timeout 120s

kubectl get pods -n external-secrets
kubectl get crd | grep external-secrets.io

Drei Pods: Haupt-Controller, Cert-Controller (Webhook-Zertifikate), Validating Webhook. Fünf CRDs, u. a. externalsecrets, secretstores, clustersecretstores, pushsecrets.

ESO installieren: OpenShift 4.20+

Per OLM aus dem redhat-operators-Katalog; der Rest des Setups ist identisch.

apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
  name: external-secrets-operator
  namespace: external-secrets
spec:
  channel: stable
  installPlanApproval: Automatic
  name: external-secrets-operator
  source: redhat-operators
  sourceNamespace: openshift-marketplace
oc apply -f 00-eso-install/subscription.yaml
oc wait --for=condition=Available \
  deployment/external-secrets-operator \
  -n external-secrets --timeout=120s

Namespace (mit openshift.io/cluster-monitoring: "true") und eine leere OperatorGroup gehören dazu.

3

App-Namespace anbinden

Ein manueller Schritt (Token-Transfer), danach läuft alles deklarativ über Git: SecretStore zeigt auf den Hub, ExternalSecret beschreibt das Mapping, ESO erzeugt das Ziel-Secret.

Schritt 1 · Token-Transfer (der einzige manuelle Schritt)

Ein Token für eso-reader erzeugen und als Secret im App-Namespace ablegen – ohne dieses „Secret Zero“ kann ESO den Hub nicht lesen.

kubectl create namespace app-1

kubectl create secret generic eso-reader-token \
  --namespace app-1 \
  --from-literal=token="$(kubectl create token eso-reader \
    --namespace secret-hub --duration=8760h)"

Rotieren geht mit demselben Befehl plus --dry-run=client -o yaml | kubectl apply -f -, das überschreibt das bestehende Secret.

# JWT-Claims pruefen: Ablauf, ServiceAccount, Issuer
kubectl get secret eso-reader-token -n app-1 -o jsonpath='{.data.token}' \
  | base64 -d | cut -d. -f2 | base64 -d 2>/dev/null | jq
Die Laufzeit wird still gekappt: Fordert man mit --duration mehr an, als das Server-Maximum des kube-apiservers erlaubt, gibt es keinen Fehler – das Token wird einfach kürzer ausgestellt. Wer sich auf „ein Jahr“ verlässt, steht ggf. viel früher mit SecretSyncedError da. Nach dem Erzeugen immer den exp-Claim prüfen.

Schritt 2 · SecretStore und ExternalSecret

Der SecretStore definiert Backend, Remote-Namespace, CA (aus der kube-root-ca.crt-ConfigMap) und Auth. Das ExternalSecret mappt Remote-Keys auf lokale Keys – diese YAML ist GitOps-tauglich, weil sie nur Referenzen enthält.

Hinweis: Der SecretStore muss im selben Namespace liegen wie die ExternalSecret-Ressourcen, die ihn nutzen. Für clusterweite Nutzung gibt es den ClusterSecretStore (Abschnitt 4).
YAML: 02-app-namespace/secret-store.yaml
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: secret-hub-store
  namespace: app-1
spec:
  provider:
    kubernetes:
      remoteNamespace: secret-hub
      server:
        caProvider:
          type: ConfigMap
          name: kube-root-ca.crt
          key: ca.crt
      auth:
        token:
          bearerToken:
            name: eso-reader-token
            key: token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: app-1
spec:
  refreshInterval: 1m
  secretStoreRef:
    kind: SecretStore
    name: secret-hub-store
  target:
    name: db-credentials
    creationPolicy: Owner
  data:
    - secretKey: username
      remoteRef:
        key: db-credentials
        property: username
    - secretKey: password
      remoteRef:
        key: db-credentials
        property: password
    - secretKey: host
      remoteRef:
        key: db-credentials
        property: host
    - secretKey: port
      remoteRef:
        key: db-credentials
        property: port
    - secretKey: dbname
      remoteRef:
        key: db-credentials
        property: dbname

Schritt 3 · Verifikation

Erst den Store, dann das ExternalSecret, dann das erzeugte Secret prüfen – in dieser Reihenfolge grenzt man Fehler am schnellsten ein.

kubectl get secretstore -n app-1                  # erwartet: Valid
kubectl get externalsecret -n app-1               # erwartet: SecretSynced
kubectl describe externalsecret db-credentials -n app-1
kubectl get secret db-credentials -n app-1 \
  -o jsonpath='{.data.username}' | base64 -d
Test-Anwendung: Secret als Env-Variablen konsumieren

Ein minimaler HTTP-Server zeigt die injizierten Werte an. Wichtig ist nur das Muster: valueFrom.secretKeyRef auf das von ESO erzeugte Secret – alternativ als Volume-Mount. Der ESO-Mechanismus ist für die App unsichtbar.

env:
  - name: DB_USER
    valueFrom:
      secretKeyRef:
        name: db-credentials
        key: username
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: db-credentials
        key: password
kubectl port-forward svc/demo-app 8080:8080 -n app-1
curl http://localhost:8080/index.html
4

Auth-Varianten und erweiterte Konzepte

Das statische Bearer Token ist der nachvollziehbare Einstieg – für den Dauerbetrieb ist die ServiceAccount-Auth die bessere Wahl: kein Token-Transfer, keine Rotation, kein Secret Zero.

Bearer Token (statisch)

  • Pro – einfach nachvollziehbar, funktioniert überall
  • Contra – manueller Setup-Schritt
  • Contra – Token läuft ab, Rotation nötig
  • Contra – Secret Zero liegt im App-Namespace

ServiceAccount-Auth (empfohlen)

  • Kein Transfer – ESO fordert Tokens selbst über die TokenRequest API an
  • Keine Rotation – pro Zugriff ein frisches, kurzlebiges Token
  • Kein Secret Zero – nichts Sensibles im App-Namespace
  • Voll GitOps-tauglich – auch das Auth-Setup ist deklarativ

ServiceAccount-Auth einrichten

Seit Kubernetes 1.24 gibt es keine unbegrenzt gültigen ServiceAccount-Tokens mehr; die TokenRequest API stellt nur befristete aus. ESO nutzt genau das: Im SecretStore steht statt des Tokens der ServiceAccount – zusätzlich braucht der ESO-Controller das Recht, Tokens für eso-reader anzufordern.

YAML: SecretStore mit SA-Auth + Token-Requester-RBAC
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: secret-hub-store
  namespace: app-1
spec:
  provider:
    kubernetes:
      remoteNamespace: secret-hub
      server:
        caProvider:
          type: ConfigMap
          name: kube-root-ca.crt
          key: ca.crt
      auth:
        serviceAccount:
          name: eso-reader
          namespace: secret-hub
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: eso-token-requester
  namespace: secret-hub
rules:
  - apiGroups: [""]
    resources: [serviceaccounts/token]
    resourceNames: [eso-reader]
    verbs: [create]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: eso-token-requester
  namespace: secret-hub
subjects:
  - kind: ServiceAccount
    name: external-secrets
    namespace: external-secrets
roleRef:
  kind: Role
  name: eso-token-requester
  apiGroup: rbac.authorization.k8s.io

Tokens unbegrenzt gültig machen? Offiziell nein. Das Maximum lässt sich per --service-account-max-token-expiration am kube-apiserver anheben, aber Managed-Cluster (EKS, GKE, AKS) erlauben das meist nicht, typisches Maximum ist ein Jahr – und sicherheitstechnisch ist es ein Anti-Pattern. Legacy-Tokens (kubernetes.io/service-account-token) sind unbegrenzt, aber deprecated.

ClusterSecretStore: ein Store für viele Namespaces

Sollen mehrere App-Namespaces aus demselben Hub lesen, ersetzt ein ClusterSecretStore die vielen einzelnen Stores. Die conditions steuern per namespaceSelector, wer ihn nutzen darf; im ExternalSecret steht dann kind: ClusterSecretStore.

ClusterSecretStore ohne conditions ist eine offene Tresortür: Ohne die Einschränkung kann jeder Namespace im Cluster alle Secrets aus dem Hub lesen. Es gibt keine Fehlermeldung, kein Event – nur einen stillen, erheblichen Sicherheitsverlust.
Die conditions sind der Türsteher mit Gästeliste – nur Namespaces mit dem passenden Label (eso-access: "true") kommen an den Store.
YAML: ClusterSecretStore mit namespaceSelector
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: secret-hub-cluster-store
spec:
  provider:
    kubernetes:
      remoteNamespace: secret-hub
      server:
        caProvider:
          type: ConfigMap
          name: kube-root-ca.crt
          key: ca.crt
          namespace: secret-hub
      auth:
        serviceAccount:
          name: eso-reader
          namespace: secret-hub
  conditions:
    - namespaceSelector:
        matchLabels:
          eso-access: "true"

creationPolicy und deletionPolicy

Zwei Stellschrauben am target: Wie geht ESO mit dem Ziel-Secret um – und was passiert, wenn die Quelle verschwindet?

creationPolicyVerhalten
OwnerESO erstellt und besitzt das Secret (Default)
Orphanerstellt ohne OwnerReference
Mergeergänzt bestehendes Secret, ohne es zu besitzen
Noneerstellt kein Secret (nur für Templates)
deletionPolicyVerhalten
RetainZiel-Secret bleibt bestehen
DeleteZiel-Secret wird gelöscht
Mergenur betroffene Keys werden entfernt
dataFrom, Templates und PushSecret

dataFrom übernimmt ein komplettes Secret, ohne jeden Key einzeln zu mappen:

spec:
  dataFrom:
    - extract:
        key: db-credentials

Templates formatieren das Ziel-Secret, etwa zu einem Connection-String:

target:
  name: db-connection
  creationPolicy: Owner
  template:
    engineVersion: v2
    data:
      connection-string: "postgresql://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .dbname }}"

PushSecret ist der umgekehrte Weg – ein von der App generiertes Credential zentral verfügbar machen:

apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
  name: push-to-hub
  namespace: app-1
spec:
  refreshInterval: 1m
  secretStoreRefs:
    - name: secret-hub-store
      kind: SecretStore
  selector:
    secret:
      name: generated-credentials
  data:
    - match:
        secretKey: password
        remoteRef:
          remoteKey: app-1-credentials
          property: password
5

GitOps-Integration mit ArgoCD

Das Pattern lebt von einer sauberen Trennung: Referenzen und Infrastruktur nach Git, Werte und Tokens niemals. ArgoCD deployt beides – ESO selbst und die App-Manifeste.

Gehört in Git

  • SecretStore + ExternalSecret – reine Referenzen
  • App-Manifeste – Deployment, Service
  • Gateway-Infrastruktur – cert-manager-CRs, Gateway, HTTPRoutes
  • ESO selbst – als ArgoCD-Application aufs Helm-Chart

Gehört nie in Git

  • Die Secret-Werte – der Inhalt von 01-secret-hub/
  • Das Bearer Tokeneso-reader-token

Bei ServiceAccount-Auth (Abschnitt 4) entfällt der zweite Punkt komplett – dann ist wirklich alles außer den Werten deklarativ.

ArgoCD und die von ESO erzeugten Secrets

ArgoCD markiert das ESO-Secret als „out of sync“, weil es nicht in Git steht. Abhilfe: die Differenz auf Applikations-Ebene ignorieren – oder das Secret per Template-Label ganz aus dem Tracking nehmen.

# Variante 1: Application ignoriert Secret-Daten
spec:
  ignoreDifferences:
    - group: ""
      kind: Secret
      jsonPointers:
        - /data
# Variante 2: ESO setzt das Tracking-Label leer
spec:
  target:
    template:
      metadata:
        labels:
          argocd.argoproj.io/instance: ""
Dauer-OutOfSync ist Tarnfarbe für echte Drift: Bleibt die ESO-Differenz unbehandelt, ist die Application permanent gelb – und die eine Abweichung, die wirklich ein Problem ist, fällt im Grundrauschen nicht mehr auf. Erst ignorieren konfigurieren, dann auf Sync-Status vertrauen.
YAML: ESO und Demo-App als ArgoCD-Applications
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: external-secrets
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://charts.external-secrets.io
    chart: external-secrets
    targetRevision: "*"
    helm:
      releaseName: external-secrets
      valuesObject:
        installCRDs: true
  destination:
    server: https://kubernetes.default.svc
    namespace: external-secrets
  syncPolicy:
    automated:
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: eso-demo-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/ORG/eso-demo.git
    targetRevision: main
    path: manifests
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: app-1
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - PruneLast=true
    retry:
      limit: 3
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m
6

Ingress über die Gateway API

TLS-Secrets sind namespace-gebunden – die zentrale Frage ist deshalb: In welchem Namespace liegt das Zertifikat? Die App-Namespaces brauchen kein eigenes: Das Gateway terminiert TLS zentral, die HTTPRoutes verweisen nur per parentRef darauf.

Zwei Zertifikats-Patterns

PatternCert-NamespaceGateway-NamespaceReferenceGrantEinsatz
Agateway-infragateway-infraneinStandard, Gateway-Team verwaltet Certs
Bcert-storegateway-infrajaSecurity-Team verwaltet Certs separat

Bei Pattern B braucht es einen ReferenceGrant – und zwar im Namespace des Secrets, nicht beim Gateway. Im Gateway wird das Secret dann mit namespace: cert-store referenziert.

Der ReferenceGrant ist eine Vollmacht – und Vollmachten stellt immer der Besitzer aus. Deshalb liegt die CR beim Secret (im Cert-Namespace), nicht beim Gateway, das zugreifen will.
YAML: Certificate (Pattern A) und ReferenceGrant (Pattern B)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-tab-local
  namespace: gateway-infra
spec:
  secretName: wildcard-tab-local-tls
  issuerRef:
    name: selfsigned-issuer
    kind: ClusterIssuer
  commonName: "*.tab.local"
  dnsNames:
    - "*.tab.local"
    - "tab.local"
  duration: 8760h
  renewBefore: 720h
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: allow-gateway-to-certs
  namespace: cert-store          # liegt beim Secret!
spec:
  from:
    - group: gateway.networking.k8s.io
      kind: Gateway
      namespace: gateway-infra
  to:
    - group: ""
      kind: Secret

Gateway und HTTPRoute

Das Gateway lauscht auf 443 (TLS Terminate) und 80 (Redirect), erlaubt Routes aber nur aus Namespaces mit dem Label gateway-access: "true". Jede App bringt zwei HTTPRoutes mit: eine für HTTPS, eine für den 301-Redirect von HTTP.

kubectl label namespace app-1 gateway-access=true --overwrite
Fehlendes Namespace-Label = unsichtbare Route: Gateway und HTTPRoute sind beide fehlerfrei deployed, trotzdem antwortet nichts. Im Route-Status steht dann NotAllowedByListeners – der Listener-Selector filtert den Namespace einfach heraus, ohne dass beim Apply irgendetwas meckert.

gatewayClassName muss zum installierten Controller passen: kgateway, cilium, eg (Envoy Gateway), istio, contour, nginx oder traefik.

YAML: Gateway mit HTTPS/HTTP-Listenern
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: main-gateway
  namespace: gateway-infra
spec:
  gatewayClassName: kgateway
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.tab.local"
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: wildcard-tab-local-tls
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              gateway-access: "true"
    - name: http
      protocol: HTTP
      port: 80
      hostname: "*.tab.local"
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              gateway-access: "true"
YAML: HTTPRoute + HTTP-zu-HTTPS-Redirect
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: demo-app-route
  namespace: app-1
spec:
  parentRefs:
    - name: main-gateway
      namespace: gateway-infra
      sectionName: https
  hostnames:
    - demo.tab.local
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: demo-app
          port: 8080
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: demo-app-redirect
  namespace: app-1
spec:
  parentRefs:
    - name: main-gateway
      namespace: gateway-infra
      sectionName: http
  hostnames:
    - demo.tab.local
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

Verifikation direkt am Gateway, unabhängig von DNS:

GATEWAY_IP=$(kubectl get gateway main-gateway -n gateway-infra \
  -o jsonpath='{.status.addresses[0].value}')

curl -k --resolve "demo.tab.local:443:${GATEWAY_IP}" https://demo.tab.local/
7

Betrieb: Komplettablauf, Troubleshooting, Bewertung

Der ganze Aufbau als chronologische Schrittfolge, die häufigsten Fehlerbilder mit Diagnosewegen – und eine ehrliche Einordnung, wann das Pattern trägt und wann nicht.

Der Komplettablauf in vier Phasen

  1. Phase A · ESO installierenHelm (Vanilla/KKP) oder OLM-Subscription (OpenShift 4.20+); Pods und die fünf CRDs verifizieren. → Abschnitt 2
  2. Phase B · Secret-Hub aufbauenNamespace, Quell-Secret, ServiceAccount, Role (inkl. selfsubjectrulesreviews!), RoleBinding. → Abschnitt 2
  3. Phase C · App anbindenToken-Transfer (oder gleich SA-Auth), SecretStore, ExternalSecret; Status bis SecretSynced verfolgen. → Abschnitte 3–4
  4. Phase D · GitOps und IngressManifeste nach Git, ArgoCD-Applications, ignoreDifferences, Gateway + HTTPRoutes + Namespace-Label. → Abschnitte 5–6

Troubleshooting: Symptom → Ursache → Lösung

SymptomUrsacheLösung
SecretSyncedErrorToken abgelaufen oder falschToken neu erzeugen
InvalidProviderConfigremoteNamespace falschNamespace-Namen prüfen
SecretStore not foundStore in anderem NamespaceStore in denselben NS legen
forbidden in LogsRBAC unvollständigRole auf selfsubjectrulesreviews prüfen
Key nicht gefundenproperty stimmt nichtKeys im Quell-Secret prüfen
NotAllowedByListenersNamespace-Label fehltgateway-access=true setzen
BackendNotFoundService existiert nicht / heißt anderskubectl get svc im Ziel-NS
Hinweis: Liefert curl gegen die Gateway-IP das Richtige, der Browser aber nicht, liegt es an DNS oder Browser-Cache: /etc/hosts prüfen, DNS-Cache leeren (Chrome: chrome://net-internals/#dns, Firefox: about:networking#dns) oder Inkognito-Fenster.
Diagnose-Befehle

Immer von der Quelle zur Senke: ESO-Status, Token, RBAC, dann Routing.

# ExternalSecret-Events und ESO-Logs
kubectl describe externalsecret db-credentials -n app-1
kubectl logs -n external-secrets deployment/external-secrets --tail=50

# Store-Status (erwartet: Valid)
kubectl get secretstore secret-hub-store -n app-1 -o yaml | yq '.status'

# Keys im Quell-Secret
kubectl get secret db-credentials -n secret-hub -o jsonpath='{.data}' | jq 'keys'
# Token-Ablauf: exp ist ein Unix-Timestamp
kubectl get secret eso-reader-token -n app-1 -o jsonpath='{.data.token}' \
  | base64 -d | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp'
date -d @<TIMESTAMP>
# RBAC manuell testen: Lesen im Hub ja, anderswo Forbidden
TOKEN=$(kubectl get secret eso-reader-token -n app-1 \
  -o jsonpath='{.data.token}' | base64 -d)

kubectl --token="$TOKEN" get secrets -n secret-hub    # erwartet: Liste
kubectl --token="$TOKEN" get secrets -n kube-system   # erwartet: Forbidden
# Gateway API: Accepted-Status aller Routes + attachedRoutes
kubectl get httproute -A -o custom-columns=\
'NS:.metadata.namespace,NAME:.metadata.name,ACCEPTED:.status.parents[0].conditions[?(@.type=="Accepted")].status,REASON:.status.parents[0].conditions[?(@.type=="Accepted")].reason'

kubectl get gateway main-gateway -n gateway-infra \
  -o jsonpath='{range .status.listeners[*]}{.name}: attachedRoutes={.attachedRoutes}{"\n"}{end}'
Deinstallation und Aufräumen

Erst die Demo-Ressourcen rückwärts entfernen, dann ESO, zuletzt die CRDs. Blockieren Finalizer, vorher alle Custom Resources löschen.

kubectl delete -f 05-routes/ -f 04-gateway/ -f 03-test-app/ -f 02-app-namespace/
kubectl delete secret eso-reader-token -n app-1
kubectl delete -f 01-secret-hub/
kubectl delete namespace app-1 gateway-infra

helm uninstall external-secrets -n external-secrets
kubectl delete namespace external-secrets

# CRDs; bei haengenden Finalizern zuerst alle CRs loeschen
kubectl delete externalsecrets,secretstores,clustersecretstores,pushsecrets --all -A
kubectl get crds -o name | grep external-secrets.io | xargs kubectl delete

Geeignet für

  • statische Secrets in einem abgegrenzten Cluster
  • Teams ohne dedizierten Secret-Manager-Betrieb
  • air-gapped und On-Prem-Umgebungen
  • als Zwischenschritt vor einer Vault-Einführung

Weniger geeignet für

  • Compliance-Umgebungen mit Audit-Trail-Anforderungen
  • häufige Credential-Rotation
  • Multi-Cluster-Setups mit zentralem Secret-Management (pro Cluster ein Hub oder Cross-Cluster-Zugriff nötig)
  • dynamische Datenbank-Credentials

Exit-Strategie: Migration zu Vault

Der große Vorteil des Patterns: Beim Backend-Wechsel bleiben alle ExternalSecret-Manifeste und Deployments unverändert – nur der SecretStore wird umgestellt, die Apps konsumieren weiterhin ein normales Kubernetes-Secret.

# Vorher: Kubernetes-Provider
spec:
  provider:
    kubernetes:
      remoteNamespace: secret-hub

# Nachher: Vault-Provider
spec:
  provider:
    vault:
      server: "https://vault.example.com:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "app-1-role"