DEV Community

Cover image for cert-manager Helm chart initContainers and trust-manager issuer
Moep90 for kadet-hub

Posted on Originally published at kadet-hub.org

cert-manager Helm chart initContainers and trust-manager issuer

Two requests against the cert-manager project's Helm charts have no value
to set in the current releases, cert-manager v1.21.2 and trust-manager
v0.25.0: init containers on the cert-manager controller, and a
trust-manager webhook certificate signed by an issuer you manage instead
of the self-signed one the chart creates. Both can be fixed on the rendered
manifests without forking the chart. This post does it with
Kapitan's mutate
generator, one section per issue.

The code is
cert-manager-mutate
in kadet-hub/examples. Its CI job renders both charts, fails unless every
patch below is in the output, deploys the result to a kind cluster and
checks it with Chainsaw. The guide
Patch a Helm chart compares this approach
with Helm post-renderers, Kustomize and Flux's postRenderers.

Rendering and patching

Each chart is pinned by version and by the digest of
its OCI artifact in a class and rendered with Kapitan's
helm input. Both charts take tag and digest for every image in their
values, so no patch is needed for that. A target adds
mutate as a second compile entry on the same output_path, which rewrites
the rendered files in place:

# inventory/targets/trust-manager.yml
parameters:
  kapitan:
    compile:
      - input_type: kadet
        input_paths: [lib/mutate]
        output_path: trust-manager
        output_type: plain
        input_params:
          mutations: ${mutations}
Enter fullscreen mode Exit fullscreen mode

Every mutation has to match at least one object. A chart release that
renames or drops a patched object fails the compile instead of shipping
without the patch.

cert-manager Helm chart: initContainers {#cert-manager-initcontainers}

cert-manager/cert-manager#6071
asks for a value that adds init containers to the controller Deployment.
The maintainer inteon
answered
in June 2023 that "we don't want to parameterise all fields in our Helm
chart. Instead, you should use a Helm postprocessing step to add these init
containers." The stale bot closed the issue in November 2023. The v1.21.2
chart has extraContainers, which adds containers next to the controller,
and still no value for init containers.

The mutation adds the list with a JSON Patch:

# inventory/targets/cert-manager.yml
parameters:
  mutations:
    init-containers:
      match: {apiVersion: apps/v1, kind: Deployment, name: cert-manager}
      json6902:
        - op: add
          path: /spec/template/spec/initContainers
          value:
            - name: init
              image: docker.io/library/busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
              command: [echo, init container added by mutate]
              securityContext:
                allowPrivilegeEscalation: false
                capabilities: {drop: [ALL]}
                readOnlyRootFilesystem: true
                runAsUser: 65534
Enter fullscreen mode Exit fullscreen mode

match compares apiVersion, kind and name exactly, so the webhook and
cainjector Deployments stay as rendered. add creates initContainers
because the chart renders none. On a Deployment that already has the list,
add would replace it. The path /spec/template/spec/initContainers/-
appends to an existing list instead, and fails the compile on today's
chart:

mutate: mutation init-containers: cert-manager/templates/deployment.yaml: json6902 path '/spec/template/spec/initContainers/-' does not exist
Enter fullscreen mode Exit fullscreen mode

The chart's pod sets runAsNonRoot: true, and busybox runs as root.
Without runAsUser the controller never starts:

NAME                            READY   STATUS                            RESTARTS   AGE
cert-manager-559884b95-ndzkb    0/1     Init:CreateContainerConfigError   0          15s
Enter fullscreen mode Exit fullscreen mode
CreateContainerConfigError: container has runAsNonRoot and image will run as root (pod: "cert-manager-559884b95-ndzkb_cert-manager(39b2da2d-cb31-49e3-873b-d94083bfb131)", container: init)
Enter fullscreen mode Exit fullscreen mode

The check diffs the mutated render against the plain one, both loaded and
dumped with sorted keys:

@@ -72,6 +72,19 @@
             - ALL
           readOnlyRootFilesystem: true
       enableServiceLinks: false
+      initContainers:
+      - command:
+        - echo
+        - init container added by mutate
+        image: docker.io/library/busybox:1.38.0@sha256:fd7dc98638c8e305f4dc34e979f1c0fdfdcaeb0fbf8fcff77ae834b6da3d7e6e
+        name: init
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            drop:
+            - ALL
+          readOnlyRootFilesystem: true
+          runAsUser: 65534
       nodeSelector:
         kubernetes.io/os: linux
       securityContext:
Enter fullscreen mode Exit fullscreen mode

On the kind cluster, Chainsaw waits for the controller pod to be Ready with
the init container init terminated with reason Completed and exit code
0, and reads its log:

init container added by mutate
Enter fullscreen mode Exit fullscreen mode

trust-manager Helm chart: a custom issuer for the webhook {#trust-manager-custom-issuer}

cert-manager/trust-manager#778,
opened in October 2025, asks to sign the webhook certificate with an issuer
or cluster issuer managed outside the chart, for clusters whose rules a
self-signed issuer does not meet. No maintainer has replied; the bot
marked it stale in April 2026 and rotten in July 2026.

Whenever app.webhook.tls.helmCert.enabled is false, the default, the
chart's
certificate.yaml
renders a selfSigned Issuer and the webhook Certificate that uses it
(excerpt):

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: {{ include "trust-manager.name" . }}
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: {{ include "trust-manager.name" . }}
spec:
  issuerRef:
    name: {{ include "trust-manager.name" . }}
    kind: Issuer
    group: cert-manager.io
Enter fullscreen mode Exit fullscreen mode

The other setting, helmCert.enabled: true, has Helm generate the
certificate with a validity of 10 years, which the chart's values file
advises against in production. Two mutations replace the issuer:

# inventory/targets/trust-manager.yml
parameters:
  mutations:
    no-selfsigned-issuer:
      match: {apiVersion: cert-manager.io/v1, kind: Issuer, name: trust-manager}
      delete: true
    webhook-issuer:
      match: {apiVersion: cert-manager.io/v1, kind: Certificate, name: trust-manager}
      merge:
        spec:
          issuerRef:
            name: internal-ca
            kind: ClusterIssuer
Enter fullscreen mode Exit fullscreen mode

merge is a JSON Merge Patch, so group: cert-manager.io stays.
The rendered diff:

@@ -15,8 +15,8 @@
   - trust-manager.cert-manager.svc
   issuerRef:
     group: cert-manager.io
-    kind: Issuer
-    name: trust-manager
+    kind: ClusterIssuer
+    name: internal-ca
   privateKey:
     rotationPolicy: Always
   revisionHistoryLimit: 1
@@ -24,17 +24,3 @@
   secretTemplate:
     annotations:
       cert-manager.io/allow-direct-injection: 'true'
----
-apiVersion: cert-manager.io/v1
-kind: Issuer
-metadata:
-  labels:
-    app.kubernetes.io/instance: trust-manager
-    app.kubernetes.io/managed-by: Helm
-    app.kubernetes.io/name: trust-manager
-    app.kubernetes.io/version: v0.25.0
-    helm.sh/chart: trust-manager-v0.25.0
-  name: trust-manager
-  namespace: cert-manager
-spec:
-  selfSigned: {}
Enter fullscreen mode Exit fullscreen mode

The ClusterIssuer internal-ca is not part of either chart. In the example,
internal-ca.yaml
creates it: a selfSigned ClusterIssuer issues a CA certificate into the
cert-manager namespace, and a CA ClusterIssuer signs with that key. A
ClusterIssuer backed by your own PKI takes its place in the same issuerRef.

With approver-policy installed, app.webhook.tls.approverPolicy.enabled:
true
also renders a CertificateRequestPolicy whose selector.issuerRef
names the Issuer trust-manager; that selector needs the same change. The
example does not install approver-policy and does not test it.

trust-manager v0.25.0 annotates its ValidatingWebhookConfiguration with
cert-manager.io/inject-ca-from-secret: cert-manager/trust-manager-tls, so
cainjector copies the ca.crt of the webhook's Secret into the caBundle.
With a CA issuer, that ca.crt is the CA's certificate. On the kind
cluster, Chainsaw asserts that the Certificate trust-manager is Ready with
the new issuerRef and that no Issuer trust-manager exists. A script
then verifies the webhook certificate against the CA and compares
fingerprints. From the
CI job
of the pull request that added the example:

/tmp/tmp.1y1xcx7s6B/tls.crt: OK
subject=CN = trust-manager.cert-manager.svc
issuer=CN = internal-ca
internal-ca: sha256 Fingerprint=EE:31:AC:56:63:EC:89:CB:E6:3F:C4:4F:BB:4D:00:8B:66:56:43:5D:9A:23:BE:FC:B7:B2:A5:10:64:91:B0:75
Secret trust-manager-tls ca.crt: sha256 Fingerprint=EE:31:AC:56:63:EC:89:CB:E6:3F:C4:4F:BB:4D:00:8B:66:56:43:5D:9A:23:BE:FC:B7:B2:A5:10:64:91:B0:75
ValidatingWebhookConfiguration trust-manager caBundle: sha256 Fingerprint=EE:31:AC:56:63:EC:89:CB:E6:3F:C4:4F:BB:4D:00:8B:66:56:43:5D:9A:23:BE:FC:B7:B2:A5:10:64:91:B0:75
ConfigMap default/internal-ca: sha256 Fingerprint=EE:31:AC:56:63:EC:89:CB:E6:3F:C4:4F:BB:4D:00:8B:66:56:43:5D:9A:23:BE:FC:B7:B2:A5:10:64:91:B0:75
Enter fullscreen mode Exit fullscreen mode

The Bundle internal-ca in
bundle.yaml
copies the CA into a ConfigMap in every namespace. It is created through
trust-manager's webhook, so the API server accepted the webhook's
certificate before the Bundle reported Synced. In a local run with the
Certificate pointed back at the chart's Issuer, the same verification
fails:

error 18 at 0 depth lookup: self-signed certificate
error /tmp/tmp.iCcWKSE67P/tls.crt: verification failed
Enter fullscreen mode Exit fullscreen mode

If the chart stops rendering the two objects, the compile fails instead
of leaving the patches without effect. With helmCert.enabled: true the
chart renders neither the Issuer nor the Certificate, and Kapitan stops
with:

kadet_component_mutate.mutations.MutationError: mutate: mutation webhook-issuer: expects 1+, matched 0
Error compiling trust-manager: Could not load Kadet module: mutate
Enter fullscreen mode Exit fullscreen mode

Installing the result

check applies cert-manager first, server-side because three of its CRDs
exceed the annotation size of a client-side apply, and waits until its
Deployments are available. cert-manager's webhook then still refused
connections for a few seconds; in one local run the apply of
internal-ca.yaml failed three times with connect: connection refused
before it went through. Every apply is therefore repeated for up to a
minute. trust-manager and the Bundle follow. In the CI job above the
check took 96 seconds: 25 to verify mutate and compile, 65 for the kind
cluster and the installs, and 6 for Chainsaw.

Kapitan's helm input runs helm template, so there is no Helm release,
and hooks would be applied as ordinary objects. The cert-manager class
leaves out startupapicheck, a hook Job that waits for the webhook during
helm install. Where releases and
helm rollback matter, the
guide covers Flux's
postRenderers, which keep them.

To run it yourself:

git clone https://github.com/kadet-hub/examples
cd examples
cert-manager-mutate/check
Enter fullscreen mode Exit fullscreen mode

It needs gh (authenticated), jq, oras, uv, helm, kind, kubectl, chainsaw,
openssl and Docker.

Originally published at kadet-hub.org.

Top comments (0)