4.3. Cert-Manager TLS Automation

In this lab you install Cert-Manager and configure a Let’s Encrypt ClusterIssuer so that any Ingress in the cluster can receive a signed TLS certificate automatically.

Step 1 — Install Cert-Manager

Add the following Helm chart call to __main__.py, after the Traefik block:

cert_manager = aks.install_helm_chart(
    "cert-manager",
    chart="cert-manager",
    repo="https://charts.jetstack.io",
    namespace="cert-manager",
    values={
        "crds": {"enabled": True}
    }
)

Explanation

crds.enabled: true tells the Helm chart to deploy the Cert-Manager CRD bundle alongside the controller. The CRDs define the ClusterIssuer, Certificate, and CertificateRequest kinds — without them the Kubernetes API server rejects those resources as unknown types.

Values keyEffect
crds.enabledInstalls Cert-Manager CRDs as part of the Helm release

Step 2 — Create a ClusterIssuer

A ClusterIssuer tells Cert-Manager which ACME server to use and how to prove domain ownership. First, add add_custom_resource to the Kubernetes class in kubernetes.py, directly before install_helm_chart:

def add_custom_resource(
    self,
    name: str,
    opts: pulumi.ResourceOptions = None,
    **kwargs
) -> k8s.apiextensions.CustomResource:
    return k8s.apiextensions.CustomResource(
        name,
        **kwargs,
        opts=pulumi.ResourceOptions.merge(
            self.child_opts,
            pulumi.ResourceOptions(provider=self._k8s_provider).merge(opts)
        )
    )

Then add the following block to __main__.py after the cert-manager Helm chart:

aks.add_custom_resource(
    "letsencrypt-prod",
    api_version="cert-manager.io/v1",
    kind="ClusterIssuer",
    metadata={"name": "letsencrypt-prod"},
    spec={
        "acme": {
            "server": "https://acme-v02.api.letsencrypt.org/directory",
            "email": "noreply@netrics.ch",
            "privateKeySecretRef": {
                "name": "letsencrypt-prod"
            },
            "solvers": [{
                "http01": {
                    "ingress": {
                        "class": "traefik"
                    }
                }
            }]
        }
    },
    opts=pulumi.ResourceOptions(depends_on=[cert_manager])
)

Explanation

add_custom_resource complements install_helm_chart by allowing any Kubernetes custom resource to be declared from __main__.py without exposing _k8s_provider as a public attribute. pulumi.ResourceOptions.merge combines the component’s child_opts (which sets parent=self for the resource tree) with the Kubernetes provider. The optional opts parameter is merged last, so callers can layer in extra options such as depends_on without overriding the provider or parent. Kwargs are forwarded directly to k8s.apiextensions.CustomResource, so the caller controls api_version, kind, metadata, and spec.

depends_on=[cert_manager] is required here because Cert-Manager registers the ClusterIssuer CRD when it starts. Without it Pulumi may try to create the ClusterIssuer before the CRD exists, which causes an “unknown resource type” error. The explicit dependency ensures Pulumi waits for the Helm release to complete before applying the custom resource.

Cert-Manager implements the ACME protocol to obtain certificates from Let’s Encrypt. With the HTTP-01 solver, the challenge flow is:

ACME stepWhat happens
OrderCert-Manager places a certificate order with the Let’s Encrypt ACME server
ChallengeLet’s Encrypt issues an HTTP-01 challenge token
SolveCert-Manager creates a temporary Ingress that Traefik serves on /.well-known/acme-challenge/
ValidateLet’s Encrypt fetches the token over HTTP and confirms domain ownership
IssueLet’s Encrypt signs the certificate; Cert-Manager stores it as a Kubernetes Secret

privateKeySecretRef names the Kubernetes Secret where the ACME account private key is stored. The key is created on first registration and reused for all subsequent orders.

CRD availability timing

Pulumi may attempt to create the ClusterIssuer before the Cert-Manager CRDs are registered. If pulumi up reports no matches for kind "ClusterIssuer", run it a second time — the CRDs will already be in place and the issuer will be created successfully.


Step 3 — Deploy and Verify

Apply the changes:

pulumi up

Once the deploy completes, confirm all Cert-Manager pods are running and the issuer is ready:

kubectl get pods -n cert-manager
kubectl get clusterissuer letsencrypt-prod

Expected output:

NAME                                         READY   STATUS    RESTARTS   AGE
cert-manager-...                             1/1     Running   0          1m
cert-manager-cainjector-...                  1/1     Running   0          1m
cert-manager-webhook-...                     1/1     Running   0          1m

NAME               READY   AGE
letsencrypt-prod   True    30s

Explanation

Cert-Manager deploys three pods:

PodRole
cert-managerCore controller — watches Certificate resources and drives the ACME flow
cert-manager-cainjectorInjects CA bundles into webhook configurations and CRDs
cert-manager-webhookValidates and mutates Cert-Manager resources via admission webhooks

READY: True on the ClusterIssuer confirms Cert-Manager has registered an ACME account with Let’s Encrypt and is ready to issue certificates for your domain.


Step 4 — Test HTTPS with a Signed Certificate

Verify end-to-end TLS by deploying a test Ingress that requests a certificate from Let’s Encrypt.

Create https.yaml, replacing YOUR_USERNAME with your username:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: secure
  namespace: tests
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - secure.YOUR_USERNAME.labs.netrics.dev
      secretName: tls-secure
  rules:
    - host: secure.YOUR_USERNAME.labs.netrics.dev
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello
                port:
                  number: 80

Apply and watch the certificate being issued:

kubectl apply -f https.yaml
kubectl get certificate -n tests -w

Once READY shows True, test the HTTPS endpoint:

curl https://secure.$(pulumi stack output dns_zone)

Explanation

Adding the cert-manager.io/cluster-issuer annotation to an Ingress is all that is needed to trigger automatic certificate issuance. Cert-Manager watches for annotated Ingress resources and drives the full ACME flow without any further configuration:

Resource created by Cert-ManagerPurpose
CertificateRequestTracks a single ACME order for the requested hostnames
CertificateLong-lived object that Cert-Manager keeps renewed
Secret/tls-secureStores the signed certificate and private key in PEM format

Once the Secret exists, Traefik reads it automatically and terminates TLS for secure.YOUR_USERNAME.labs.netrics.dev using the Let’s Encrypt-signed certificate.

Certificate issuance time

Let’s Encrypt must validate the HTTP-01 challenge before signing the certificate. This typically takes 30–60 seconds. Use kubectl get certificate -n tests -w to watch progress — wait for READY to become True before running curl.


Complete files

kubernetes.py (add_custom_resource method — add before install_helm_chart)

def add_custom_resource(
    self,
    name: str,
    opts: pulumi.ResourceOptions = None,
    **kwargs
) -> k8s.apiextensions.CustomResource:
    return k8s.apiextensions.CustomResource(
        name,
        **kwargs,
        opts=pulumi.ResourceOptions.merge(
            self.child_opts,
            pulumi.ResourceOptions(provider=self._k8s_provider).merge(opts)
        )
    )

__main__.py (Cert-Manager block — add after the Traefik block)

cert_manager = aks.install_helm_chart(
    "cert-manager",
    chart="cert-manager",
    repo="https://charts.jetstack.io",
    namespace="cert-manager",
    values={
        "crds": {"enabled": True}
    }
)

aks.add_custom_resource(
    "letsencrypt-prod",
    api_version="cert-manager.io/v1",
    kind="ClusterIssuer",
    metadata={"name": "letsencrypt-prod"},
    spec={
        "acme": {
            "server": "https://acme-v02.api.letsencrypt.org/directory",
            "email": "noreply@netrics.ch",
            "privateKeySecretRef": {
                "name": "letsencrypt-prod"
            },
            "solvers": [{
                "http01": {
                    "ingress": {
                        "class": "traefik"
                    }
                }
            }]
        }
    },
    opts=pulumi.ResourceOptions(depends_on=[cert_manager])
)

https.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: secure
  namespace: tests
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - secure.YOUR_USERNAME.labs.netrics.dev
      secretName: tls-secure
  rules:
    - host: secure.YOUR_USERNAME.labs.netrics.dev
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello
                port:
                  number: 80